PostgreSQL · RetrieveMultiple
Read functions in depth: table rows, OUT values, one value, records, writes, procedures
Functions as row sources, every shape PostgreSQL has, in six scenarios. A function is named with Function (named arguments) or Function (positional), and the rest of the query works on its rows as on a table's. Every argument is a typed parameter. RealCase6 shows a RETURNS TABLE function with named and positional arguments; this sample shows the other shapes:
RETURNS SETOF account: rows of a table's type, with a criterion, a link entity and an order.- OUT parameters: one row, one column per OUT value.
- A single value: one row, its column named after the function.
RETURNS SETOF record: no column list of its own, so the query declares it withReturns(...).- A function that writes: refused by a read (it runs read-only) unless the source says
AllowWrites(). - A procedure: not a row source (ARX8001); procedures are Execute's.
Set up for this page
Besides the sample itself, it uses SampleContext.cs (the dbContext every sample is given); SampleOutput.cs (Step and Show, which print); SampleContext.Application.cs (dbContext.Service, the application's ARX service); SampleCatalog.cs (the tables' ids, keys, lookups and display names), in the files below.
Before scenario 1 the sample runs this, so the service and the rows the scenarios use are there:
var active = new ArxEntity("account");
active["name"] = "Fn Active";
active["account_number"] = "ACC-R791";
var activeId = await service.CreateAsync(active);
var held = new ArxEntity("account");
held["name"] = "Fn Held";
held["account_number"] = "ACC-R792";
held["status"] = new ArxSelectionSetValue("AccountStatus", "ON_HOLD");
held["parent_account_id"] = new ArxEntityReference("account", activeId);
await service.CreateAsync(held);
foreach (var number in new[] { "SO-R791", "SO-R792", "SO-R793" })
{
var order = new ArxEntity("sales_order");
order["order_number"] = number;
order["account_id"] = new ArxEntityReference("account", activeId);
order["ordered_on"] = new DateTime(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc);
await service.CreateAsync(order);
}1 RETURNS SETOF account: rows of a table's type, linked
fn_accounts_with_status('ON_HOLD') returns whole account rows. Its rows take a criterion and an order like a table's, and a link entity: here the parent account's name.
Already in scope: service (ArxPgDbService)
var query = new ArxQueryExpression(ArxRowSource.Function("fn_accounts_with_status", ("p_status", "ON_HOLD")));
query.ColumnSet.AddColumns("name", "account_number", "status");
query.Criteria.AddCondition("account_number", ArxConditionOperator.BeginsWith, "ACC-R79");
var parent = query.AddLink("account", "parent_account_id", "id", ArxJoinOperator.Left);
parent.EntityAlias = "parent";
parent.ColumnSet.AddColumn("name");
query.AddOrder("name", ArxOrderType.Ascending);
foreach (var row in (await service.RetrieveMultipleAsync(query)).Entities)
{
Console.WriteLine($"{row["name"]} ({row["account_number"]}, {row["status"]}), parent: {row["parent.name"] ?? "-"}");
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT f.name, f.account_number, f.status, p.name AS parent
FROM fn_accounts_with_status('ON_HOLD') f
LEFT JOIN account p ON p.id = f.parent_account_id
WHERE f.account_number LIKE 'ACC-R79%';Expect: 1 row: Fn Held, ACC-R792, ON_HOLD, Fn Active.
2 OUT parameters: one row
fn_account_totals(OUT accounts, OUT contacts): one row, an attribute per OUT value.
Already in scope: service (ArxPgDbService)
var totals = (await service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_account_totals")))).Entities.Single();
Console.WriteLine($"accounts: {totals["accounts"]}, contacts: {totals["contacts"]}");Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT (SELECT count(*) FROM account) AS accounts, (SELECT count(*) FROM contact) AS contacts;Expect: the two numbers the sample printed (they count every sample's rows since the last reset).
3 One value, a positional argument
fn_order_count(p_account_id) returns one bigint: one row, the column named after the function.
Already in scope: service (ArxPgDbService), accountId (ArxRecordId)
var row = (await service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_order_count", accountId.AsInt64())))).Entities.Single();
Console.WriteLine($"fn_order_count = {row["fn_order_count"]} ({row["fn_order_count"]!.GetType().Name})");Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT fn_order_count(a.id)
FROM account a
WHERE a.account_number = 'ACC-R791';Expect: 3.
4 RETURNS SETOF record: declare the columns
fn_account_rows() returns SETOF record: PostgreSQL needs the caller to say which columns of which types come back. Without that it's ARX8001; with Returns(...) the rows read like a table's.
Already in scope: service (ArxPgDbService)
try
{
await service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_account_rows")));
}
catch (ArxException error) when (error.Code == ArxErrorCode.NotSupported)
{
Console.WriteLine(error.Message);
}
var query = new ArxQueryExpression(ArxRowSource.Function("fn_account_rows")
.Returns(("id", typeof(long)), ("name", typeof(string)), ("account_number", typeof(string))));
query.Criteria.AddCondition("account_number", ArxConditionOperator.BeginsWith, "ACC-R79");
query.AddOrder("account_number", ArxOrderType.Ascending);
Console.WriteLine("declared: " + string.Join(", ", (await service.RetrieveMultipleAsync(query)).Entities.Select(x => $"{x["account_number"]} {x["name"]}")));Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT account_number, name
FROM fn_account_rows() AS f(id bigint, name text, account_number varchar)
WHERE account_number LIKE 'ACC-R79%'
ORDER BY account_number;Expect: 2 rows: ACC-R791 Fn Active, ACC-R792 Fn Held.
5 A function that writes: refused, then allowed
fn_log_action inserts into activity_log and returns the row. A read runs a function in a read-only transaction, so the insert is refused (ARX6001) and nothing is written: a read can't change data by accident. AllowWrites() says this one may; the row is then written and returned. (Inside an ARX transaction the function runs in that transaction, as the caller decided.)
Already in scope: service (ArxPgDbService), accountId (ArxRecordId)
try
{
await service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_log_action", accountId.AsInt64(), "refused export")));
}
catch (ArxException error) when (error.Code == ArxErrorCode.PermissionDenied)
{
Console.WriteLine(error.Message);
}
var written = await service.RetrieveMultipleAsync(new ArxQueryExpression(
ArxRowSource.Function("fn_log_action", ("p_account_id", accountId.AsInt64()), ("p_action", "allowed export")).AllowWrites()));
Console.WriteLine($"with AllowWrites(): logged '{written.Entities.Single()["action"]}'");Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT l.action
FROM activity_log l
JOIN account a ON a.id = l.account_id
WHERE a.account_number = 'ACC-R791';Expect: 1 row, allowed export (the refused call wrote nothing).
6 A procedure is not a row source
A procedure returns no rows to select from: ARX8001, pointing at Execute.
Already in scope: service (ArxPgDbService)
try
{
await service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("sp_recalculate_order_totals")));
}
catch (ArxException error) when (error.Code == ArxErrorCode.NotSupported)
{
Console.WriteLine(error.Message);
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT proname, prokind
FROM pg_proc
WHERE proname IN ('sp_recalculate_order_totals', 'fn_order_count')
ORDER BY proname;Expect: fn_order_count f (a function), sp_recalculate_order_totals p (a procedure).
Run the complete sample
The code above comes from one program, built in a fresh project and run against the database. To run it yourself, here is every file and step.
The program, its files and how to run it
Run it locally
- .NET SDK: .NET SDK 10.0.100 or later 10.0.x (target
net10.0). - Create the project:
Terminal dotnet new console -n ArxSample -f net10.0 cd ArxSample - Install the packages:
Terminal dotnet add package ARX.SDK.PostgreSql --version 1.0.0-rc.1 - Database: a PostgreSQL 16 (tested with 16.15) database with the sample schema.
- Configuration: set
ARX_SAMPLES_POSTGRESQLto its connection string. Nothing is hard-coded. - Source code: replace
Program.csand add the 5 other files below, with the file names shown. - Run it:
Terminal dotnet run - Expected output: below.
The files
Runs the sample. Generated for this page; it reads the connection string from the environment.
using ArxSamples.PostgreSql;
using ArxSamples.PostgreSql.Operations.RetrieveMultiple.AdvancedCases;
// The connection string comes from the environment, never from the code (see "Configure the connection").
var connectionString = Environment.GetEnvironmentVariable("ARX_SAMPLES_POSTGRESQL")
?? throw new InvalidOperationException("Set the ARX_SAMPLES_POSTGRESQL environment variable to your PostgreSQL connection string.");
await new Advanced8_ReadFunctionsInDepth().RunAsync(new SampleContext(connectionString));
The sample, as it is in the repository.
using ARX.SDK;
using ARX.SDK.Query;
using ARX.SDK.Services;
using static ArxSamples.PostgreSql.SampleOutput;
namespace ArxSamples.PostgreSql.Operations.RetrieveMultiple.AdvancedCases;
/// <summary>
/// Functions as row sources, every shape PostgreSQL has, in six scenarios. A function is named with
/// <see cref="ArxRowSource.Function(string, (string, object?)[])"/> (named arguments) or
/// <see cref="ArxRowSource.Function(string, object?[])"/> (positional), and the rest of the query works on its rows as on
/// a table's. Every argument is a typed parameter. RealCase6 shows a <c>RETURNS TABLE</c> function with named and
/// positional arguments; this sample shows the other shapes:
/// <list type="number">
/// <item><c>RETURNS SETOF account</c>: rows of a table's type, with a criterion, a link entity and an order.</item>
/// <item>OUT parameters: one row, one column per OUT value.</item>
/// <item>A single value: one row, its column named after the function.</item>
/// <item><c>RETURNS SETOF record</c>: no column list of its own, so the query declares it with <c>Returns(...)</c>.</item>
/// <item>A function that writes: refused by a read (it runs read-only) unless the source says <c>AllowWrites()</c>.</item>
/// <item>A procedure: not a row source (ARX8001); procedures are Execute's.</item>
/// </list>
/// </summary>
public sealed class Advanced8_ReadFunctionsInDepth : ISample
{
public string Name => "retrieve-multiple-advanced-8-read-functions-in-depth";
public string Title => "Read functions in depth: table rows, OUT values, one value, records, writes, procedures";
public async Task RunAsync(SampleContext dbContext)
{
var active = new ArxEntity("account");
active["name"] = "Fn Active";
active["account_number"] = "ACC-R791";
var activeId = await dbContext.Service.CreateAsync(active);
var held = new ArxEntity("account");
held["name"] = "Fn Held";
held["account_number"] = "ACC-R792";
held["status"] = new ArxSelectionSetValue("AccountStatus", "ON_HOLD");
held["parent_account_id"] = new ArxEntityReference("account", activeId);
await dbContext.Service.CreateAsync(held);
foreach (var number in new[] { "SO-R791", "SO-R792", "SO-R793" })
{
var order = new ArxEntity("sales_order");
order["order_number"] = number;
order["account_id"] = new ArxEntityReference("account", activeId);
order["ordered_on"] = new DateTime(2026, 11, 8, 10, 0, 0, DateTimeKind.Utc);
await dbContext.Service.CreateAsync(order);
}
await Scenario1_RowsOfATablesTypeAsync(dbContext);
await Scenario2_OutValuesAsync(dbContext);
await Scenario3_OneValueAsync(dbContext, activeId);
await Scenario4_RecordsWithDeclaredColumnsAsync(dbContext);
await Scenario5_AFunctionThatWritesAsync(dbContext, activeId);
await Scenario6_AProcedureIsNotARowSourceAsync(dbContext);
}
/// <summary>
/// 1. <c>fn_accounts_with_status('ON_HOLD')</c> returns whole account rows. Its rows take a criterion and an order like a
/// table's, and a link entity: here the parent account's name.
/// </summary>
private static async Task Scenario1_RowsOfATablesTypeAsync(SampleContext dbContext)
{
Step("1. RETURNS SETOF account: rows of a table's type, linked");
var query = new ArxQueryExpression(ArxRowSource.Function("fn_accounts_with_status", ("p_status", "ON_HOLD")));
query.ColumnSet.AddColumns("name", "account_number", "status");
query.Criteria.AddCondition("account_number", ArxConditionOperator.BeginsWith, "ACC-R79");
var parent = query.AddLink("account", "parent_account_id", "id", ArxJoinOperator.Left);
parent.EntityAlias = "parent";
parent.ColumnSet.AddColumn("name");
query.AddOrder("name", ArxOrderType.Ascending);
foreach (var row in (await dbContext.Service.RetrieveMultipleAsync(query)).Entities)
{
Show($"{row["name"]} ({row["account_number"]}, {row["status"]}), parent: {row["parent.name"] ?? "-"}");
}
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT f.name, f.account_number, f.status, p.name AS parent
* FROM fn_accounts_with_status('ON_HOLD') f
* LEFT JOIN account p ON p.id = f.parent_account_id
* WHERE f.account_number LIKE 'ACC-R79%';
* -- Expect: 1 row: Fn Held, ACC-R792, ON_HOLD, Fn Active.
* ===========================================================
*/
}
/// <summary>2. <c>fn_account_totals(OUT accounts, OUT contacts)</c>: one row, an attribute per OUT value.</summary>
private static async Task Scenario2_OutValuesAsync(SampleContext dbContext)
{
Step("2. OUT parameters: one row");
var totals = (await dbContext.Service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_account_totals")))).Entities.Single();
Show($"accounts: {totals["accounts"]}, contacts: {totals["contacts"]}");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT (SELECT count(*) FROM account) AS accounts, (SELECT count(*) FROM contact) AS contacts;
* -- Expect: the two numbers the sample printed (they count every sample's rows since the last reset).
* ===========================================================
*/
}
/// <summary>3. <c>fn_order_count(p_account_id)</c> returns one bigint: one row, the column named after the function.</summary>
private static async Task Scenario3_OneValueAsync(SampleContext dbContext, ArxRecordId accountId)
{
Step("3. One value, a positional argument");
var row = (await dbContext.Service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_order_count", accountId.AsInt64())))).Entities.Single();
Show($"fn_order_count = {row["fn_order_count"]} ({row["fn_order_count"]!.GetType().Name})");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT fn_order_count(a.id)
* FROM account a
* WHERE a.account_number = 'ACC-R791';
* -- Expect: 3.
* ===========================================================
*/
}
/// <summary>
/// 4. <c>fn_account_rows()</c> returns <c>SETOF record</c>: PostgreSQL needs the caller to say which columns of which types
/// come back. Without that it's ARX8001; with <c>Returns(...)</c> the rows read like a table's.
/// </summary>
private static async Task Scenario4_RecordsWithDeclaredColumnsAsync(SampleContext dbContext)
{
Step("4. RETURNS SETOF record: declare the columns");
try
{
await dbContext.Service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_account_rows")));
}
catch (ArxException error) when (error.Code == ArxErrorCode.NotSupported)
{
ShowError(error);
}
var query = new ArxQueryExpression(ArxRowSource.Function("fn_account_rows")
.Returns(("id", typeof(long)), ("name", typeof(string)), ("account_number", typeof(string))));
query.Criteria.AddCondition("account_number", ArxConditionOperator.BeginsWith, "ACC-R79");
query.AddOrder("account_number", ArxOrderType.Ascending);
Show("declared: " + string.Join(", ", (await dbContext.Service.RetrieveMultipleAsync(query)).Entities.Select(x => $"{x["account_number"]} {x["name"]}")));
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT account_number, name
* FROM fn_account_rows() AS f(id bigint, name text, account_number varchar)
* WHERE account_number LIKE 'ACC-R79%'
* ORDER BY account_number;
* -- Expect: 2 rows: ACC-R791 Fn Active, ACC-R792 Fn Held.
* ===========================================================
*/
}
/// <summary>
/// 5. <c>fn_log_action</c> inserts into activity_log and returns the row. A read runs a function in a read-only
/// transaction, so the insert is refused (ARX6001) and nothing is written: a read can't change data by accident.
/// <c>AllowWrites()</c> says this one may; the row is then written and returned. (Inside an ARX transaction the
/// function runs in that transaction, as the caller decided.)
/// </summary>
private static async Task Scenario5_AFunctionThatWritesAsync(SampleContext dbContext, ArxRecordId accountId)
{
Step("5. A function that writes: refused, then allowed");
try
{
await dbContext.Service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("fn_log_action", accountId.AsInt64(), "refused export")));
}
catch (ArxException error) when (error.Code == ArxErrorCode.PermissionDenied)
{
ShowError(error);
}
var written = await dbContext.Service.RetrieveMultipleAsync(new ArxQueryExpression(
ArxRowSource.Function("fn_log_action", ("p_account_id", accountId.AsInt64()), ("p_action", "allowed export")).AllowWrites()));
Show($"with AllowWrites(): logged '{written.Entities.Single()["action"]}'");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT l.action
* FROM activity_log l
* JOIN account a ON a.id = l.account_id
* WHERE a.account_number = 'ACC-R791';
* -- Expect: 1 row, allowed export (the refused call wrote nothing).
* ===========================================================
*/
}
/// <summary>6. A procedure returns no rows to select from: ARX8001, pointing at Execute.</summary>
private static async Task Scenario6_AProcedureIsNotARowSourceAsync(SampleContext dbContext)
{
Step("6. A procedure is not a row source");
try
{
await dbContext.Service.RetrieveMultipleAsync(new ArxQueryExpression(ArxRowSource.Function("sp_recalculate_order_totals")));
}
catch (ArxException error) when (error.Code == ArxErrorCode.NotSupported)
{
ShowError(error);
}
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT proname, prokind
* FROM pg_proc
* WHERE proname IN ('sp_recalculate_order_totals', 'fn_order_count')
* ORDER BY proname;
* -- Expect: fn_order_count f (a function), sp_recalculate_order_totals p (a procedure).
* ===========================================================
*/
}
}
What every sample is given: the connection string (and the ISample interface the samples implement).
namespace ArxSamples.PostgreSql;
/// <summary>One documentation sample: a scenario a real application has, shown end to end.</summary>
public interface ISample
{
/// <summary>The name to run it alone: <c>dotnet run -- <name></c>.</summary>
string Name { get; }
/// <summary>One line on what it shows.</summary>
string Title { get; }
Task RunAsync(SampleContext dbContext);
}
/// <summary>
/// What a sample is given. Every sample gets the connection string; the application's service and catalog
/// (SampleContext.Application.cs) and the plain Npgsql read-back helper (SampleContext.Direct.cs) live in their own
/// files, so a sample needs only the files it uses.
/// </summary>
public sealed partial class SampleContext
{
public SampleContext(string connectionString)
{
ConnectionString = connectionString;
}
public string ConnectionString { get; }
}
How the samples print: Step for each scenario, Show for what it did, ShowError for an error caught on purpose.
using ARX.SDK;
namespace ArxSamples.PostgreSql;
/// <summary>How the samples print: a numbered step for each scenario, then what it did.</summary>
public static class SampleOutput
{
/// <summary>The start of a scenario: <c>Step("1. By the id Create returned")</c>.</summary>
public static void Step(string text) => Console.WriteLine($" > {text}");
/// <summary>What the scenario did.</summary>
public static void Show(string text) => Console.WriteLine($" {text}");
/// <summary>An ARX error the scenario caught on purpose.</summary>
public static void ShowError(ArxException exception)
=> Console.WriteLine($" caught {exception.GetType().Name}: {exception.Message}");
}
The application's ARX service and its schema catalog, created once and shared by the samples.
using ARX.SDK.PostgreSql.Services;
using ARX.SDK.Services;
namespace ArxSamples.PostgreSql;
/// <summary>The application's ARX service and its schema catalog, shared by the real and advanced samples.</summary>
public sealed partial class SampleContext
{
private ArxSchemaCatalog? _catalog;
private ArxPgDbService? _service;
/// <summary>The application's schema catalog: table ids, keys and selection sets (SampleCatalog.cs).</summary>
public ArxSchemaCatalog Catalog => _catalog ??= SampleCatalog.Build();
/// <summary>
/// One service for the whole application. It holds no connection: every call opens one from the Npgsql pool and
/// closes it, unless you pass your own connection or transaction.
/// </summary>
public ArxPgDbService Service => _service ??= new ArxPgDbService(ConnectionString, Catalog);
}
The application's schema catalog: every table's id column and keys, and its selection sets.
using ARX.SDK.Services;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ArxSamples.PostgreSql;
/// <summary>An account's industry. Stored in account.industry (smallint) as the enum's number.</summary>
public enum Industry : short
{
Retail = 1,
Manufacturing = 2,
Healthcare = 3,
Finance = 4,
Technology = 5
}
/// <summary>
/// The application's schema catalog: every table's id column and keys, and every picklist (selection set),
/// defined once.
/// </summary>
/// <remarks>
/// <para>
/// <b>Ids.</b> Every table the application writes is declared with its one id column: its name, its .NET type, and
/// whether the database generates it (<c>ArxIdColumn.Generated</c>: identity, sequence, default) or the caller supplies
/// it (<c>ArxIdColumn.Supplied</c>: a natural id, a client-generated Guid). A table with no id column, such as a log or a
/// table identified by several columns together, is declared <c>ArxIdColumn.NoIdColumn</c>. ARX never reads ids from
/// the database: the developer knows the schema, and an undeclared table is refused (ARX1016).
/// </para>
/// <para>
/// <b>Keys.</b> Optional business keys: one or more columns whose values identify a record, such as an account number,
/// or a region and a SKU together. Create doesn't use them (the database's unique constraints do the enforcing); Update
/// and Delete will be able to find rows by them. <c>ValidateSchemaAsync</c> checks ids and keys against the database
/// when you ask it to.
/// </para>
/// <para>
/// <b>Selection sets.</b> /// A selection set (picklist, option set) is a column that only accepts a fixed list of codes, each with a display
/// text: an account's status (ACTIVE, ON_HOLD, CLOSED), a contact's preferred channel (E, P, S). The catalog tells ARX,
/// for each set:
/// </para>
/// <list type="bullet">
/// <item><b>Name</b>: what an <see cref="ArxSelectionSetValue"/> refers to, for example <c>new ArxSelectionSetValue("AccountStatus", "ON_HOLD")</c>.</item>
/// <item><b>Kind</b>: what the codes are, matching the column: Integer, String, Character, Boolean, Decimal, Guid, or a
/// PostgreSQL enum type (DatabaseEnum).</item>
/// <item><b>Multiplicity</b>: Single (one code), or several codes stored as an array (Array), bit flags (Flags), a
/// delimited string (Delimited) or a JSON array (Json).</item>
/// <item><b>Options</b>: each code with its display text and order. A retired option (<c>IsActive: false</c>) stays
/// readable in old rows, but can't be written any more.</item>
/// </list>
/// <para>
/// Build the catalog once at startup and give it to the service: <c>new ArxPgDbService(connectionString, SampleCatalog.Build())</c>.
/// ARX checks every <see cref="ArxSelectionSetValue"/> against it in memory, before any SQL is sent, and stores the code
/// itself in the column: no lookup table, no extra query. A plain value (a string or number that isn't an
/// ArxSelectionSetValue) isn't checked.
/// </para>
/// <para>
/// The catalog is plain code, so the options can come from wherever you keep them: written out here, taken from a .NET
/// enum (<c>AddEnum</c>), loaded from JSON (<see cref="SetsJson"/> and <see cref="FromJson"/> below), or loaded at
/// startup from your own option table and passed to <c>new ArxSelectionSetDefinition(name, kind, options)</c>. ARX
/// never reads option tables or files itself.
/// </para>
/// </remarks>
public static class SampleCatalog
{
/// <summary>
/// Selection sets kept as JSON, as an application might keep them in a file, a settings store or a database column.
/// Here the JSON is in the class itself; in an application, read it from wherever you keep it
/// (<c>File.ReadAllText("selection-sets.json")</c>, configuration, an HTTP call) and pass it to <see cref="FromJson"/>.
/// "multiplicity" and "active" are optional (Single and true).
/// </summary>
public const string SetsJson = """
[
{
"name": "LeadSource",
"kind": "String",
"options": [
{ "value": "WEB", "text": "Website", "order": 0 },
{ "value": "REFERRAL", "text": "Referral", "order": 1 },
{ "value": "EVENT", "text": "Trade show", "order": 2 },
{ "value": "COLD_CALL", "text": "Cold call", "order": 3, "active": false }
]
},
{
"name": "ContactRating",
"kind": "Integer",
"multiplicity": "Single",
"options": [
{ "value": 1, "text": "Poor" },
{ "value": 2, "text": "Fair" },
{ "value": 3, "text": "Good" },
{ "value": 4, "text": "Very good" },
{ "value": 5, "text": "Excellent" }
]
}
]
""";
public static ArxSchemaCatalog Build()
{
var builder = ArxSchemaCatalog.Create()
// ---------- tables: id column and keys ----------
// Generated bigint ids (bigserial): the database fills them in. The keys are the unique columns that also
// identify a record.
.Table("account", ArxIdColumn.Generated<long>("id")).Key("account", "account_number")
.Table("contact", ArxIdColumn.Generated<long>("id")).Key("contact", "email")
// Retrieve: a contact's account_id reads back as a reference to its account, named by the account's
// name; a contact is named by its first and last names.
.Lookup("contact", "account_id", "account")
.DisplayName("account", "name")
.DisplayName("contact", new[] { "first_name", "last_name" }, " ")
.Table("product_category", ArxIdColumn.Generated<long>("id")).Key("product_category", "name")
.Table("product", ArxIdColumn.Generated<long>("id")).Key("product", "sku")
.Table("sales_order", ArxIdColumn.Generated<long>("id")).Key("sales_order", "order_number")
.Table("order_line", ArxIdColumn.Generated<long>("id"))
.Table("data_type_showcase", ArxIdColumn.Generated<long>("id"))
// A natural id: the caller gives the ISO country code.
.Table("country", ArxIdColumn.Supplied<string>("code"))
// A generated uuid id (DEFAULT gen_random_uuid()).
.Table("device", ArxIdColumn.Generated<Guid>("id")).Key("device", "serial_number")
// No id column: a price is identified by its region and SKU together, a compound key.
.Table("price_list", ArxIdColumn.NoIdColumn).Key("price_list", "region", "sku")
// No primary key at all: the same action can be logged twice.
.Table("activity_log", ArxIdColumn.NoIdColumn)
// A partitioned table without an id: a visit is a fact, never updated (Retrieve Advanced8).
.Table("web_visit", ArxIdColumn.NoIdColumn)
// ---------- selection sets ----------
// From a .NET enum: the set is named after the enum ("Industry"), the codes are its numbers (Retail = 1, …),
// the display texts are its member names. Column: account.industry smallint.
.AddEnum<Industry>()
// Written out: String codes with display texts, single choice. Column: account.status text.
.Add("AccountStatus", ArxSelectionSetKind.String, ("ACTIVE", "Active"), ("ON_HOLD", "On hold"), ("CLOSED", "Closed"))
// Multiple choice, stored as an array of codes. RESELLER is retired: rows that have it still read back,
// but new writes can't use it. Column: account.segments text[].
.Add(new ArxSelectionSetDefinition("CustomerSegment", ArxSelectionSetKind.String, new[]
{
new ArxSelectionSetOption("SMB", "Small business", 0),
new ArxSelectionSetOption("ENTERPRISE", "Enterprise", 1),
new ArxSelectionSetOption("PUBLIC", "Public sector", 2),
new ArxSelectionSetOption("RESELLER", "Reseller", 3, IsActive: false)
}, ArxSelectionSetMultiplicity.Array))
// Single-character codes. Column: contact.preferred_channel char(1).
.Add("ContactChannel", ArxSelectionSetKind.Character, ('E', "Email"), ('P', "Phone"), ('S', "Text message"))
// The labels of a PostgreSQL enum type: the value is written with a cast to that type.
// Column: data_type_showcase.mood showcase_mood.
.Add(new ArxSelectionSetDefinition("Mood", ArxSelectionSetKind.DatabaseEnum, new[]
{
new ArxSelectionSetOption("happy", "Happy", 0),
new ArxSelectionSetOption("neutral", "Neutral", 1),
new ArxSelectionSetOption("sad", "Sad", 2)
}, databaseTypeName: "showcase_mood"));
// Loaded from JSON: LeadSource (contact.lead_source text) and ContactRating (contact.rating smallint).
foreach (var set in FromJson(SetsJson))
{
builder.Add(set);
}
return builder.Build();
}
/// <summary>Reads selection set definitions from JSON (the shape of <see cref="SetsJson"/>).</summary>
public static IEnumerable<ArxSelectionSetDefinition> FromJson(string json)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() } // "String", "Integer", "Array", … as names
};
var sets = JsonSerializer.Deserialize<List<JsonSet>>(json, options)
?? throw new InvalidOperationException("The selection set JSON is empty.");
foreach (var set in sets)
{
yield return new ArxSelectionSetDefinition(
set.Name,
set.Kind,
set.Options.Select(x => new ArxSelectionSetOption(Code(x.Value, set.Kind), x.Text, x.Order, x.Active)),
set.Multiplicity,
set.DatabaseTypeName);
}
}
/// <summary>A JSON value as a code of the set's kind: JSON has no char, Guid or 64-bit integer of its own.</summary>
private static object Code(JsonElement value, ArxSelectionSetKind kind) => kind switch
{
ArxSelectionSetKind.Integer => value.GetInt64(),
ArxSelectionSetKind.Decimal => value.GetDecimal(),
ArxSelectionSetKind.Boolean => value.GetBoolean(),
ArxSelectionSetKind.Guid => value.GetGuid(),
ArxSelectionSetKind.Character => value.GetString()![0],
_ => value.GetString()! // String and DatabaseEnum
};
private sealed record JsonSet(
string Name,
ArxSelectionSetKind Kind,
List<JsonOption> Options,
ArxSelectionSetMultiplicity Multiplicity = ArxSelectionSetMultiplicity.Single,
string? DatabaseTypeName = null);
private sealed record JsonOption(JsonElement Value, string Text, int Order = 0, bool Active = true);
}
Expected output
What dotnet run printed when the sample was verified, on a freshly created sample schema. Ids differ when the tables already hold rows.
> 1. RETURNS SETOF account: rows of a table's type, linked
Fn Held (ACC-R792, ON_HOLD), parent: Fn Active
> 2. OUT parameters: one row
accounts: 2, contacts: 0
> 3. One value, a positional argument
fn_order_count = 3 (Int64)
> 4. RETURNS SETOF record: declare the columns
caught ArxDatabaseException: ARX8001 NotSupported: RetrieveMultiple on 'fn_account_rows' isn't possible on this object: a column definition list is required for functions returning "record".
declared: ACC-R791 Fn Active, ACC-R792 Fn Held
> 5. A function that writes: refused, then allowed
caught ArxDatabaseException: ARX6001 PermissionDenied: RetrieveMultiple on 'fn_log_action' was refused: the row source writes data, and a Retrieve runs functions read-only. If the function is meant to write, use ArxRowSource.Function(...).AllowWrites(): cannot execute INSERT in a read-only transaction.
with AllowWrites(): logged 'allowed export'
> 6. A procedure is not a row source
caught ArxDatabaseException: ARX8001 NotSupported: RetrieveMultiple on 'sp_recalculate_order_totals' isn't possible on this object: sp_recalculate_order_totals() is a procedure. A procedure is called with Execute, not read with Retrieve.