CL.PostgreSQL — Schema & Migrations
Two complementary mechanisms keep a database in step with the code:
- Declarative sync reconciles a live table to an entity class. It runs at startup, is CRC-gated so an unchanged model costs nothing, and never drops anything unless you ask.
- Imperative migrations are ordered
IMigrationclasses for changes sync cannot infer — backfills, data transforms, renames with logic, extension installs.
Entity attributes
[Table]
| Property | Default | Purpose |
|---|---|---|
Name |
class name | Table name. |
Schema |
the connection's defaultSchema |
PostgreSQL schema. Every statement is qualified with it. When the attribute does not name one, the entity lands in the connection's configured defaultSchema (public unless changed), which is created if missing. |
Comment |
— | Emitted as COMMENT ON TABLE. |
Collation |
— | Column collation applied to text columns. |
Unlogged |
false |
Faster writes, not crash-safe, not replicated. |
AccessMethod / AccessMethodName |
Heap |
Table access method, for extensions that provide one. |
[Column]
| Property | Default | Purpose |
|---|---|---|
Name |
property name | Column name. |
DataType |
Unspecified |
Leave unset to infer from the CLR type. |
Size |
0 |
varchar/char length; fractional-seconds precision for time types. |
Precision / Scale |
10 / 2 |
numeric precision. |
Primary |
false |
Part of the primary key. |
AutoIncrement |
false |
GENERATED BY DEFAULT AS IDENTITY. |
NotNull |
false |
Forces NOT NULL. Non-nullable CLR value types get it anyway. |
Unique |
false |
A named unique constraint. |
Index |
false |
A plain btree index. |
DefaultValue |
— | Raw SQL default, e.g. now(), 'active', 0. |
Comment |
— | Emitted as COMMENT ON COLUMN. |
OnUpdateCurrentTimestamp |
false |
Creates a BEFORE UPDATE trigger (see below). |
PreviousName |
— | Triggers RENAME COLUMN instead of drop-and-add. |
StorageType |
Default |
Binary / VarBinary both map to bytea. |
Type inference
With no explicit DataType the CLR type decides, and it picks a native PostgreSQL type:
| CLR | PostgreSQL |
|---|---|
bool |
boolean |
byte, sbyte, short |
smallint (no 1-byte or unsigned integers exist) |
int, ushort |
integer |
long, uint |
bigint |
ulong |
numeric(20,0) (exceeds bigint at the top of its range) |
float / double |
real / double precision |
decimal |
numeric(p,s) |
string |
character varying(defaultStringSize) |
Guid |
uuid — 16 bytes, not CHAR(36) |
DateTime, DateTimeOffset |
timestamp with time zone |
DateOnly / TimeOnly / TimeSpan |
date / time / interval |
byte[] |
bytea |
IPAddress |
inet |
short[], int[], long[], string[], decimal[], Guid[], bool[] |
the matching array type (any other array falls back to text) |
| enum | integer |
Other attributes
[CompositeIndex("name", "col", "col2", Unique = true)]on the class. A unique composite becomes a table constraint, soON CONFLICTcan arbitrate on it.[Index(Name, Unique, Include)]on a property.Includebecomes a realINCLUDEcovering clause (PostgreSQL 11+).[ForeignKey("table", "column", OnDelete, OnUpdate)]. The referenced table is qualified with the owning schema unless written asschema.table.[SoftDelete("DeletedUtc")]on the class — reads filterIS NULL, deletes stamp the column.[RetainDays(90, "CreatedUtc")]on the class — the retention worker prunes older rows.[Ignore]on a property — excluded from every operation.
Declarative sync
await pg.SyncTableAsync<User>();
await pg.SyncSchemaAsync(typeof(User), typeof(Order));
Sync modes
syncMode in config decides how destructive a reconcile may be:
| Mode | Behaviour |
|---|---|
developer |
Reconciles aggressively, dropping removed columns, indexes and FKs. |
production (default) |
Adds and modifies only. A change needing a drop is deferred and the table is flagged DriftPending. |
migration |
One-shot destructive reconcile, with a schema backup taken first. Idempotent. |
The CRC sentinel
Each entity's generated DDL is hashed and stored in __schema_state. On the next start an
unchanged model matches its stored CRC and the whole diff is skipped — no catalog queries, no
DDL. The hash is computed over normalised, sorted DDL lines, so reflection ordering cannot
change it.
The trade-off is that the gate keys on the model, not the database: while the CRC still
matches, schema drift introduced outside the library (someone dropping a column by hand) is
not noticed. Change the model, or delete the table's row from __schema_state, to force a
full diff.
Concurrency
The pass runs under a pg_advisory_lock keyed by a stable hash of the lock name. Several
instances booting together contend for it; the winner runs the DDL and the rest wait, then
find matching CRCs and do nothing. The lock is session-scoped, so it is released even if the
process dies.
Type comparison
The diff canonicalises both sides before comparing. PostgreSQL reports types through
format_type (character varying(255), timestamp with time zone) while the model generates
its own spelling, and defaults come back with a cast attached ('active'::text). Comparing
those literally would report a difference on nearly every column and rewrite the whole
database on each boot — which is exactly what the pre-4.8 analyzer did.
OnUpdateCurrentTimestamp
PostgreSQL has no such column clause. Sync creates a per-column trigger function and a
BEFORE UPDATE row trigger:
CREATE OR REPLACE FUNCTION "public"."fn_users_updated_utc_touch"() RETURNS trigger AS $$
BEGIN NEW."updated_utc" = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "trg_users_updated_utc_touch" BEFORE UPDATE ON "public"."users"
FOR EACH ROW EXECUTE FUNCTION "public"."fn_users_updated_utc_touch"();
Both statements are idempotent, so they are re-emitted rather than probed for.
Imperative migrations
public sealed class AddUserRegions : Migration
{
public AddUserRegions() : base(appVersion: "1.4.0", order: 1, description: "Backfill regions") { }
public override async Task UpAsync(IMigrationContext ctx, CancellationToken ct)
{
await ctx.ExecuteAsync("""
UPDATE "public"."users" SET "region" = 'eu' WHERE "region" IS NULL
""", ct: ct);
}
public override async Task DownAsync(IMigrationContext ctx, CancellationToken ct)
{
await ctx.ExecuteAsync("""
UPDATE "public"."users" SET "region" = NULL WHERE "region" = 'eu'
""", ct: ct);
}
}
Register and run:
pg.RegisterMigrationsFrom(typeof(Program).Assembly);
var pending = await pg.GetPendingMigrationsAsync();
var result = await pg.Migrations.MigrateAsync();
// Rollback takes a MigrationVersion, and removes everything strictly newer than it.
await pg.Migrations.RollbackAsync(new MigrationVersion("1.3.0", 0));
Migrations are ordered by (appVersion, order), run inside a transaction, and recorded in a
__migrations table. Storing history in the database rather than a local file is what
makes this safe across instances: before 4.8 the tracker wrote to
{dataDir}/migrations/migration_history.json, so every node kept its own history and all of
them re-ran everything.
The runner takes the same advisory lock as declarative sync, so only one node migrates.
IMigrationContext gives you ExecuteAsync, QueryAsync<T>, ScalarAsync<T> and
SyncTableAsync<T> (a bridge into declarative sync), plus the raw Connection and
Transaction — everything but the DDL inside SyncTableAsync runs on the migration's
transaction, since PostgreSQL commits DDL immediately.
Soft delete & retention
[Table(Name = "users")]
[SoftDelete(nameof(DeletedUtc))]
public sealed class User
{
[Column(Name = "deleted_utc")] public DateTime? DeletedUtc { get; set; }
}
Reads filter "deleted_utc" IS NULL; DeleteAsync stamps it (with a client-side
DateTime.UtcNow) instead of removing the row. IncludeDeleted() opts a query out, and
HardDeleteAsync really deletes.
The filter applies to every repository read, CountAsync included — so CountAsync() and
GetAllAsync().Count agree. Use Query<T>().IncludeDeleted().CountAsync() for the physical
row count. Writes (UpdateAsync, AdjustAsync / IncrementAsync / DecrementAsync,
HardDeleteAsync) target a row by primary key and are deliberately not filtered, so a
soft-deleted row can still be corrected or restored.
[RetainDays(90, nameof(CreatedUtc))] is picked up by a background worker that prunes rows
past the window in batches.
Registration order does not matter. The worker's entry list is live: SyncTableAsync /
SyncSchemaAsync hand each entity to it as they run, and the first [RetainDays] entity to
arrive starts the loop (idempotently). Syncing your models after CodeLogic.StartAsync()
— the order shown in every quick-start on these pages — works.
Because PostgreSQL has no DELETE … LIMIT, each batch selects rows by ctid with
FOR UPDATE SKIP LOCKED, so concurrent passes do not block one another.
The worker runs on a timer once the library starts: a first pass five minutes after startup,
then once every 24 hours. RetentionWorker.RunOnceAsync() performs a single purge pass
synchronously and returns the number of rows deleted — useful for a maintenance command, or
for a test that should not wait out the timer.
Backups & restore
await pg.BackupManager.BackupTableSchemaAsync("users", "public");
await pg.BackupManager.BackupDatabaseSchemaAsync();
await pg.BackupManager.CleanupOldBackupsAsync(olderThanDays: 30);
await pg.RestoreSchemaAsync("users", "public"); // BackupManager.RestoreTableSchemaAsync + clears __schema_state
PostgreSQL has no SHOW CREATE TABLE, so the DDL is reconstructed from the catalogs —
columns via format_type, constraints via pg_get_constraintdef, indexes via
pg_get_indexdef, plus comments. The result is a replayable script.
These are schema-only backups. Restore drops and recreates the table, so its data is
lost. Use pg_dump for data.