PostgreSQL · Create
Preload the schema at service setup: keys only, data types, Strict
The schema preloaded when the service is set up, in four scenarios: what a preload of "account" finds, including the contact table it added because account.primary_contact_id references it (with that reason, in the JSON); the convenient setup, keys only; the typed setup, data types for the columns you name; and Strict, where the preloaded columns are an allow list.
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), in the files below.
1 Preload "account": what it found, and the table it added
Preload "account" with every column: contact comes too, because a column of account references it.
Already in scope: connectionString (string)
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account");
var preload = new ArxPreLoadTable(tables);
var schema = await preload.LoadAsync(connectionString);
foreach (var table in schema.Tables)
{
Console.WriteLine($"{table.Name,-8} {table.Reason}");
Console.WriteLine($" id {table.IdColumn}; keys {string.Join(" ", table.Keys)}; {table.Columns.Count} columns");
Console.WriteLine($" lookups {string.Join(", ", table.Lookups.Select(x => $"{x.Key} -> {x.Value}"))}");
}
// The catalog as JSON: keep it in a file or a settings store and reload it with AddJson (RetrieveMultiple Advanced10).
var json = schema.ToJson();
Console.WriteLine($"ToJson: {json.Length:N0} characters; contact's entry starts:");
foreach (var line in json.Split('\n').SkipWhile(x => !x.Contains("\"name\": \"contact\"")).Take(3))
{
Console.WriteLine(" " + line.TrimEnd());
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT conname, conrelid::regclass AS from_table, confrelid::regclass AS to_table
FROM pg_constraint
WHERE conname = 'account_primary_contact_fkey';Expect: 1 row, account -> contact: the reference the preload followed to add contact (reason "added: referenced by account.primary_contact_id").
2 The convenient setup: ArxPreLoadMode.KeysOnly
Convenient: keys only. The ids, keys and lookups you would otherwise declare by hand.
Already in scope: connectionString (string)
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account");
var schema = await new ArxPreLoadTable(tables, ArxPreLoadMode.KeysOnly).LoadAsync(connectionString);
var service = new ArxPgDbService(connectionString, schema);
var contact = new ArxEntity("contact");
contact["first_name"] = "Paz";
contact["last_name"] = "Preload";
contact["email"] = "paz.preload@example.com";
var contactId = await service.CreateAsync(contact);
// primary_contact_id is a lookup the preload found: the reference is checked against contact's id type first.
var account = new ArxEntity("account");
account["name"] = "Preloaded Pantry";
account["account_number"] = "ACC-PRE81";
account["primary_contact_id"] = new ArxEntityReference("contact", contactId);
var accountId = await service.CreateAsync(account);
Console.WriteLine($"created contact {contactId} and account {accountId}, its primary contact");Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT a.name, c.first_name, c.last_name
FROM account a JOIN contact c ON c.id = a.primary_contact_id
WHERE a.account_number = 'ACC-PRE81';Expect: 1 row: Preloaded Pantry, Paz Preload.
3 The typed setup: ArxPreLoadMode.DataTypes for four columns
Typed: data types for the columns you name. A wrong type is refused before the database.
Already in scope: connectionString (string)
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account", ["name", "account_number", "employee_count", "founded_on"]);
var schema = await new ArxPreLoadTable(tables).LoadAsync(connectionString);
var service = new ArxPgDbService(connectionString, schema);
schema.TryGetTable("account", out var table);
Console.WriteLine("loaded: " + string.Join(", ", table!.Columns.Values));
var account = new ArxEntity("account");
account["name"] = "Typed Tailors";
account["account_number"] = "ACC-PRE82";
account["employee_count"] = 12;
account["founded_on"] = new DateOnly(2019, 4, 1);
Console.WriteLine($"created account {await service.CreateAsync(account)}");
var wrong = new ArxEntity("account");
wrong["name"] = "Wrongly Typed";
wrong["account_number"] = "ACC-PRE83";
wrong["employee_count"] = "twelve";
try
{
await service.CreateAsync(wrong);
}
catch (ArxValidationException exception) when (exception.Code == ArxErrorCode.DataTypeMismatch)
{
Console.WriteLine(exception.Message);
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT account_number, employee_count, founded_on
FROM account
WHERE account_number IN ('ACC-PRE82', 'ACC-PRE83');Expect: 1 row, ACC-PRE82, 12, 2019-04-01. ACC-PRE83 was refused before the database (ARX4004).
4 Strict: the preloaded columns are an allow list
Strict: a column that wasn't preloaded is refused (ARX1018); the same service not Strict writes it.
Already in scope: connectionString (string)
// A sign-up endpoint may write these three columns, nothing else.
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account", ["name", "account_number", "website"]);
var schema = await new ArxPreLoadTable(tables).LoadAsync(connectionString);
var strict = new ArxPgDbService(connectionString, schema) { Strict = true };
var signUp = new ArxEntity("account");
signUp["name"] = "Strict Stationers";
signUp["account_number"] = "ACC-PRE84";
signUp["website"] = "https://stationers.example.com";
signUp["credit_limit"] = 1_000_000m;
try
{
await strict.CreateAsync(signUp);
}
catch (ArxValidationException exception) when (exception.Code == ArxErrorCode.NotPreloaded)
{
Console.WriteLine(exception.Message);
}
// Not Strict (the default): the preload is what ARX knows ahead of time, and other columns still work.
var relaxed = new ArxPgDbService(connectionString, schema);
Console.WriteLine($"not Strict: created account {await relaxed.CreateAsync(signUp)} with its credit limit");Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT account_number, website, credit_limit
FROM account
WHERE account_number = 'ACC-PRE84';Expect: 1 row, credit_limit 1000000.00: written by the service that isn't Strict; the Strict one refused credit_limit before the database (ARX1018).
Good to know
new ArxPreLoadTable(tables) names the tables: an empty array loads every column, a list loads only those columns (the id is always loaded). LoadAsync reads them from the database once, at startup, and returns an ordinary ArxSchemaCatalog; no operation reads the database's catalog afterwards.
Two modes, the developer's choice. KeysOnly (convenient): the id, the business keys and the lookups, what you would otherwise declare by hand. DataTypes (the default, typed): those plus every loaded column's type, so a value of the wrong type is refused before the database, a string goes into an enum or jsonb column, and enum columns are read as text from the first read. Strict needs DataTypes.
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 3 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.Create.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 Advanced6_DynamicTableSchemaServiceSetup().RunAsync(new SampleContext(connectionString));
The sample, as it is in the repository.
using ARX.SDK;
using ARX.SDK.PostgreSql.Services;
using ARX.SDK.Services;
using static ArxSamples.PostgreSql.SampleOutput;
namespace ArxSamples.PostgreSql.Operations.Create.AdvancedCases;
/// <summary>
/// The schema preloaded when the service is set up, in four scenarios: what a preload of "account" finds,
/// including the contact table it added because account.primary_contact_id references it (with that reason, in the
/// JSON); the convenient setup, keys only; the typed setup, data types for the columns you name; and Strict, where the
/// preloaded columns are an allow list.
/// </summary>
/// <remarks>
/// <para><c>new ArxPreLoadTable(tables)</c> names the tables: an empty array loads every column, a list loads only those
/// columns (the id is always loaded). <c>LoadAsync</c> reads them from the database once, at startup, and returns an
/// ordinary <see cref="ArxSchemaCatalog"/>; no operation reads the database's catalog afterwards.</para>
/// <para>Two modes, the developer's choice. <b>KeysOnly</b> (convenient): the id, the business keys and the lookups,
/// what you would otherwise declare by hand. <b>DataTypes</b> (the default, typed): those plus every loaded column's
/// type, so a value of the wrong type is refused before the database, a string goes into an enum or jsonb column, and
/// enum columns are read as text from the first read. <c>Strict</c> needs DataTypes.</para>
/// </remarks>
public sealed class Advanced6_DynamicTableSchemaServiceSetup : ISample
{
public string Name => "create-advanced-6-dynamic-table-schema-service-setup";
public string Title => "Preload the schema at service setup: keys only, data types, Strict";
public async Task RunAsync(SampleContext dbContext)
{
await Scenario1_PreloadAccountAndSeeWhatWasAddedAsync(dbContext);
await Scenario2_TheConvenientSetupKeysOnlyAsync(dbContext);
await Scenario3_TheTypedSetupDataTypesAsync(dbContext);
await Scenario4_StrictTheColumnsAreAnAllowListAsync(dbContext);
}
/// <summary>1. Preload "account" with every column: contact comes too, because a column of account references it.</summary>
private static async Task Scenario1_PreloadAccountAndSeeWhatWasAddedAsync(SampleContext dbContext)
{
Step("1. Preload \"account\": what it found, and the table it added");
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account");
var preload = new ArxPreLoadTable(tables);
var schema = await preload.LoadAsync(dbContext.ConnectionString);
foreach (var table in schema.Tables)
{
Show($"{table.Name,-8} {table.Reason}");
Show($" id {table.IdColumn}; keys {string.Join(" ", table.Keys)}; {table.Columns.Count} columns");
Show($" lookups {string.Join(", ", table.Lookups.Select(x => $"{x.Key} -> {x.Value}"))}");
}
// The catalog as JSON: keep it in a file or a settings store and reload it with AddJson (RetrieveMultiple Advanced10).
var json = schema.ToJson();
Show($"ToJson: {json.Length:N0} characters; contact's entry starts:");
foreach (var line in json.Split('\n').SkipWhile(x => !x.Contains("\"name\": \"contact\"")).Take(3))
{
Show(" " + line.TrimEnd());
}
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT conname, conrelid::regclass AS from_table, confrelid::regclass AS to_table
* FROM pg_constraint
* WHERE conname = 'account_primary_contact_fkey';
* -- Expect: 1 row, account -> contact: the reference the preload followed to add contact
* -- (reason "added: referenced by account.primary_contact_id").
* ===========================================================
*/
}
/// <summary>2. Convenient: keys only. The ids, keys and lookups you would otherwise declare by hand.</summary>
private static async Task Scenario2_TheConvenientSetupKeysOnlyAsync(SampleContext dbContext)
{
Step("2. The convenient setup: ArxPreLoadMode.KeysOnly");
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account");
var schema = await new ArxPreLoadTable(tables, ArxPreLoadMode.KeysOnly).LoadAsync(dbContext.ConnectionString);
var service = new ArxPgDbService(dbContext.ConnectionString, schema);
var contact = new ArxEntity("contact");
contact["first_name"] = "Paz";
contact["last_name"] = "Preload";
contact["email"] = "paz.preload@example.com";
var contactId = await service.CreateAsync(contact);
// primary_contact_id is a lookup the preload found: the reference is checked against contact's id type first.
var account = new ArxEntity("account");
account["name"] = "Preloaded Pantry";
account["account_number"] = "ACC-PRE81";
account["primary_contact_id"] = new ArxEntityReference("contact", contactId);
var accountId = await service.CreateAsync(account);
Show($"created contact {contactId} and account {accountId}, its primary contact");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT a.name, c.first_name, c.last_name
* FROM account a JOIN contact c ON c.id = a.primary_contact_id
* WHERE a.account_number = 'ACC-PRE81';
* -- Expect: 1 row: Preloaded Pantry, Paz Preload.
* ===========================================================
*/
}
/// <summary>3. Typed: data types for the columns you name. A wrong type is refused before the database.</summary>
private static async Task Scenario3_TheTypedSetupDataTypesAsync(SampleContext dbContext)
{
Step("3. The typed setup: ArxPreLoadMode.DataTypes for four columns");
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account", ["name", "account_number", "employee_count", "founded_on"]);
var schema = await new ArxPreLoadTable(tables).LoadAsync(dbContext.ConnectionString);
var service = new ArxPgDbService(dbContext.ConnectionString, schema);
schema.TryGetTable("account", out var table);
Show("loaded: " + string.Join(", ", table!.Columns.Values));
var account = new ArxEntity("account");
account["name"] = "Typed Tailors";
account["account_number"] = "ACC-PRE82";
account["employee_count"] = 12;
account["founded_on"] = new DateOnly(2019, 4, 1);
Show($"created account {await service.CreateAsync(account)}");
var wrong = new ArxEntity("account");
wrong["name"] = "Wrongly Typed";
wrong["account_number"] = "ACC-PRE83";
wrong["employee_count"] = "twelve";
try
{
await service.CreateAsync(wrong);
}
catch (ArxValidationException exception) when (exception.Code == ArxErrorCode.DataTypeMismatch)
{
ShowError(exception);
}
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT account_number, employee_count, founded_on
* FROM account
* WHERE account_number IN ('ACC-PRE82', 'ACC-PRE83');
* -- Expect: 1 row, ACC-PRE82, 12, 2019-04-01. ACC-PRE83 was refused before the database (ARX4004).
* ===========================================================
*/
}
/// <summary>4. Strict: a column that wasn't preloaded is refused (ARX1018); the same service not Strict writes it.</summary>
private static async Task Scenario4_StrictTheColumnsAreAnAllowListAsync(SampleContext dbContext)
{
Step("4. Strict: the preloaded columns are an allow list");
// A sign-up endpoint may write these three columns, nothing else.
var tables = new ArxPreLoadTableRegister()
.PreLoadTable("account", ["name", "account_number", "website"]);
var schema = await new ArxPreLoadTable(tables).LoadAsync(dbContext.ConnectionString);
var strict = new ArxPgDbService(dbContext.ConnectionString, schema) { Strict = true };
var signUp = new ArxEntity("account");
signUp["name"] = "Strict Stationers";
signUp["account_number"] = "ACC-PRE84";
signUp["website"] = "https://stationers.example.com";
signUp["credit_limit"] = 1_000_000m;
try
{
await strict.CreateAsync(signUp);
}
catch (ArxValidationException exception) when (exception.Code == ArxErrorCode.NotPreloaded)
{
ShowError(exception);
}
// Not Strict (the default): the preload is what ARX knows ahead of time, and other columns still work.
var relaxed = new ArxPgDbService(dbContext.ConnectionString, schema);
Show($"not Strict: created account {await relaxed.CreateAsync(signUp)} with its credit limit");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT account_number, website, credit_limit
* FROM account
* WHERE account_number = 'ACC-PRE84';
* -- Expect: 1 row, credit_limit 1000000.00: written by the service that isn't Strict; the Strict one refused
* -- credit_limit before the database (ARX1018).
* ===========================================================
*/
}
}
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}");
}
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. Preload "account": what it found, and the table it added
account requested
id id (Int64, generated); keys (account_number); 17 columns
lookups parent_account_id -> account, primary_contact_id -> contact
contact added: referenced by account.primary_contact_id
id id (Int64, generated); keys (email); 16 columns
lookups account_id -> account
ToJson: 6,988 characters; contact's entry starts:
"name": "contact",
"reason": "added: referenced by account.primary_contact_id",
"id": {
> 2. The convenient setup: ArxPreLoadMode.KeysOnly
created contact 1 and account 1, its primary contact
> 3. The typed setup: ArxPreLoadMode.DataTypes for four columns
loaded: id int8 (long), name text (string), account_number varchar (string), employee_count int4 (int), founded_on date (DateOnly)
created account 2
caught ArxValidationException: ARX4004 DataTypeMismatch: Attribute 'employee_count' on 'account' has a String value, but its column is int4 (int): refused before the database.
> 4. Strict: the preloaded columns are an allow list
caught ArxValidationException: ARX1018 NotPreloaded: Column 'credit_limit' of 'account' wasn't preloaded, and the service is Strict. Preloaded: id, name, account_number, website. Add it to the ArxPreLoadTable, or turn Strict off.
not Strict: created account 3 with its credit limit