Changelog
What changed in Socigy.OpenSource.DB. NativeAOT trim-warning fixes in `Core` with a real publish gate, migration refusals for changes that have no correct SQL (encrypting a populated column, a forked migration chain), `[Renamed]` accepting either the database or the C# name, `[Index]` no longer depending on attribute order, Vault leases that renew and revoke instead of piling up, and `InsertFields.ServerDefaultsWhenUnset` in 0.3.8; the `[Index]` attribute (single-column and composite, unique, partial, covering, sorted, with portable index methods and scaffolding support), plus migration rollback fixes (a DOWN script dropped an auto-increment sequence before the table depending on it, and the first migration dropped the history table it had to record the rollback in) in 0.3.7; NativeAOT support (string-named projection / `keep` overloads and a reflection-based predicate folder replacing `Expression.Compile`, so the library and a consuming app publish with `PublishAot`), plus Vault/OpenBao encryption fixes — per-column profiles activate, `UseSocigyVaultEncryption()` primes before migrations, and background rotation no longer overflows its timer — in 0.3.6; a single `InsertFields` enum (plus a `keep` selector) replacing the per-call insert booleans on the context, static, and bulk paths, plus fixes making migration apply idempotent across app restarts and safe across concurrent replicas, cancellation-token support on the generated query and write APIs, and a binary-COPY UTC-timestamp fix, in 0.3.5; modular-monolith and multi-project fixes (`required` members, flowing Npgsql/Bcl dependencies, a `contextName` for lowercase databases, and per-call `[Default]` control on every insert path) in 0.3.4; Binary COPY bulk insert, scalar/affected/DTO procedure returns, database-first scaffolding, and Transit data-key envelope encryption with per-column profiles and OpenBao support in 0.3.3; runtime-named typed tables ([TableType] and DynamicTable) in 0.3.2; the database-context bulk insert plus scalar and aggregate API in 0.3.1; and 0.3.0's field encryption, rotating credentials, and HashiCorp Vault package.
v0.3.8
Fixes across the AOT boundary, the migration generator, and Vault credentials. The theme is the same in each: a change that could not be carried out correctly used to be carried out anyway — silently. A migration that cannot work is now refused, an index that cannot apply is not emitted, and a credential rotation no longer leaves its predecessor behind.
Added
InsertFields.ServerDefaultsWhenUnset.ServerDefaultsomits every[Default]column, whether or not the row carries a value for it. The new option omits one only where the property still holds its CLR type default (0,false,null,default(DateTime),Guid.Empty), so a value the application assigned is written andkeepbecomes unnecessary in the common case. The fluent equivalent isExcludeAutoFieldsWhenUnset(). ExistingServerDefaultsbehaviour is untouched.
var message = new OutboxMessage { OccurredAt = producedAt };
await db.Outbox.InsertAsync(message, InsertFields.ServerDefaults); // occurred_at = now()
await db.Outbox.InsertAsync(message, InsertFields.ServerDefaultsWhenUnset); // occurred_at = producedAtfields and keep compose rather than competing: keep names the columns you always write by hand and
fields decides what happens to the rest, so a call site that already passes a keep list can adopt the new
mode without giving it up. The fluent builder takes an include list too
(ExcludeAutoFieldsWhenUnset(nameof(Row.Id))), and the AOT-safe string[] overloads accept the option.
On the multi-row and COPY paths ServerDefaults still resolves the column set once and runs one plan for the
whole batch. The new mode depends on each row, so rows are grouped by which columns they leave unset and one
plan runs per group — identical cost when the rows agree, and a log line at Information naming the columns
responsible when they do not. See INSERT.
SCGDB027 flags an insert passing InsertFields.ServerDefaults while the row type has [Default]
columns the call does not name in keep. It defaults to Info, so it appears as an editor suggestion and
stays out of build output; promote it with dotnet_diagnostic.SCGDB027.severity = warning to audit a
codebase. See Generator diagnostics.
It ships as a Roslyn analyzer rather than as part of the generator. That is the difference between
covering every call shape and covering two of them: analyzers run after generation, so the invocation binds
and the row type, the InsertFields value and the keep list are read from real symbols — including calls
through the generated context's table set, which is how most application code inserts, and an
InsertFields value held in a shared constant.
AddSocigyVaultClient registers the Vault connection and authentication once, so several Vault features
can be enabled without each repeating Address / Token / AppRole. See
HashiCorp Vault.
Lease options on VaultCredentialsOptions: RenewLeases, RevokeOnRefresh, RevokeOnShutdown, and
MaxLeaseLifetime.
A NativeAOT page covering what is already AOT-safe, the one API shape that has to change in application code, and the one dependency that currently rules AOT out.
Fixed
Coreno longer produces trim or AOT warnings. Six of them: anActivator.CreateInstanceand twoArray.CreateInstancecalls plus aType.MakeGenericTypein the expression evaluator, and a reflection fallback in the UPDATE visitor that produced twoIL2075s. UnderTreatWarningsAsErrorsthese failed a consuming application's NativeAOT publish outright, producing no binary at all.The
IL2075pair is removed rather than annotated: the fallback reflected over the row's members even though every call site passes a generatedIDbTable, which the typed path already serves better (it applies the[ValueConvertor]and thejsonbcast that raw reflection skipped). It is now a clearNotSupportedExceptioninstead. It was also reachable from thestring[]SET-clause builder, so converting every application call site to the AOT-safe overloads was not enough to escape it.NOTEThis was invisible in the library's own build becauseCoretargetsnetstandard2.0, where the trim and AOT analysers cannot run at all. Three gates now cover it: the Core sources are compiled a second time under a modern target framework with those analysers on andIL*promoted to errors, the generated code is analysed through anIsAotCompatibleproject, and a sample application is published withPublishAot=trueon every build — the last one because the Roslyn analysers never flag expression-tree construction, so the first two can be clean while a real publish still fails.[Renamed]now matches under either name. A column has a C# property name and a database column name; the comparer matched only the property name while the tool's own remedy message printed the database name. Following that advice therefore did nothing: the attribute was read, never matched, and the column still went through a data-destroyingDROP+ADD. Both names are now accepted, at the class level too (where the convention had been the opposite), and the message prints a name the matcher honours. If an old name resolves to two different columns, generation fails rather than guessing.[Index]no longer depends on attribute order.[Index]written above[Table]was read before the table name was known and capturednull, emittingCREATE INDEX … ON ""— which compiles cleanly and then aborts at apply time with42601: zero-length delimited identifier.[Table]/[FlagTable]are now resolved before anything else reads them, indexes are stamped like constraints already were, and the planner refuses an index with no table name rather than emitting invalid SQL. A snapshot written by 0.3.7 carrying a null index table name is normalised on load, so upgrading does not produce a spuriousDROP INDEX.Encrypting an existing column is refused instead of mis-generated. Adding
[Encrypted]to a populatedtextcolumn emitted the genericALTER COLUMN … TYPE bytea USING "col"::bytea. That cast cannot do the job:text::byteais an I/O-conversion cast, so any value containing a backslash aborts the migration, and where it succeeds it stores readable plaintext in a column the model thereafter reports as encrypted — a failure that surfaces only at the first typed read. SQL cannot encrypt; only the application holds the key. The generator now stops and prints the two-phase shape to hand-author. Removing[Encrypted]is refused for the same reason in reverse, which previously was not even flagged. See Schema generation.Changing a column's encryption profile is no longer silent. Both sides are
bytea, so the change was invisible to the diff and produced no statement, no warning, and no comment — while every affected row held ciphertext the new profile could not read. The profile is now recorded instructure.jsonand a change emits a[SOCIGY:MANUAL]migration in both directions. Snapshots written before 0.3.8 record no encryption information, and an absent value means "not recorded", so upgrading does not report a change on every encrypted column.A migration that would fork the chain is refused at generation time. A new migration's parent comes from the schema snapshot, and the snapshot only advances after the file is written — so an abandoned attempt left in the folder still claims the parent the next attempt takes, and the fresh timestamp in the filename means the tool cannot overwrite its own earlier emission. The fork was only ever detected at apply time, where it takes the module's whole schema offline and names neither file as the cause. The generator now reads the migrations on disk first and refuses, naming the file.
Vault database credentials are renewed, not re-leased. Every refresh previously called for brand-new credentials and never revoked the old lease, so up to
ceil(TTL / RefreshInterval) + 1dynamic PostgreSQL roles were alive per database at once. A refresh now renews the existing lease while Vault allows it — which keeps the username, password, and therefore the connection pool stable — and only re-leases pastMaxLeaseLifetimeor when Vault declines. A re-lease revokes its predecessor immediately, and every held lease is revoked at shutdown.WARNINGRevocation needsrevocation_statementson the Vault role that terminate the role's backends and drop it. PostgreSQL refuses to drop a role that still owns or is granted anything (SQLSTATE 2BP01), so without them every revocation fails silently. See Rotating database credentials.Rotating credentials no longer strand a connection pool. The driver keys pools by exact connection string and never evicts one, so each rotation left the previous pool object alive for the life of the process. The credentials provider now owns a data source per database and disposes it when the credential is replaced.
Conflicting Vault connection settings throw instead of being discarded. Every
AddSocigyVault*helper shares one client, and the first registration won: a later helper'sAddress,Token,AppRoleIdandAppRoleSecretIdwere captured in a factory that was never invoked. Those are the process's auth identity, so pointing two features at different Vault servers quietly used one for both. Divergent settings now fail at registration, naming both helpers and the setting.The generated connection factory compiles for any database prefix. Its constructor name was hard-coded while its class name came from configuration, so any prefix other than the default emitted uncompilable code.
NU5046: the Vault package now ships its icon. It declared the icon with<None Update="icon.png">, which only modifies an item that already exists — and the default item globs that would have created it are not applied in the outer build of a cross-targeting project, which is wherePackruns. That was latent while the package was single-target and became a harddotnet packfailure the moment 0.3.8 added a second target framework. It uses<None Include>now, matching the main package, which had already hit this.The usual workaround — clearing
PackageIcon— packs green and ships a package with no icon, so CI now asserts the contents of every produced package rather than trusting the exit code.
Known limitations
Referencing
Socigy.OpenSource.DB.HashiCorpstill rules out a NativeAOT publish. TheIL2026warnings come from reflection-based JSON serialisation insideVaultSharpitself, which no application-side setting can silence. This is now stated in the NativeAOT and Vault pages rather than left to be discovered at publish time.The
Expressioncolumn-selector overloads are still not marked[Obsolete]. They are correct under the JIT, which is most applications, and a blanket obsoletion would warn everyone for a NativeAOT-only concern. An analyser that flags them only whenPublishAotis set is the better fit and is planned.
v0.3.7
Indexes are now part of the model: [Index] on a property or a class, with composite keys, uniqueness, partial filters, covering columns, sort order, and a portable index method. Migration rollback also gets the fixes that made it unusable: a DOWN script dropped an auto-increment sequence before the table depending on it, and the first migration's DOWN dropped the very table the rollback had to record itself in.
Added
[Index]declares a database index. Put it on a property for a single-column index, or on the class listing the properties for a composite one, the same way[Unique]works. It may be applied more than once, so a column can carry a plain index and a partial one at the same time.
[Table("users")]
[Index(nameof(TenantId), nameof(Email), Unique = true)]
public partial class User
{
[Index] public string Email { get; set; }
[Index(Where = "status <> 'deleted'")] public string? Status { get; set; }
}Options: Unique, Method, Where (partial), Include (covering), and sort order via Descending / Nulls for the whole index or DescendingColumns / NullsFirstColumns / NullsLastColumns per column. Names are derived from the table and key columns (IX_, UX_ when unique) unless Name is set, with an option-derived suffix so two indexes over the same columns cannot collide, and are shortened deterministically rather than being silently truncated by the server at its identifier limit. Indexes participate in migrations like any other schema element: created with a new table, dropped and recreated when redefined, and reverted by the DOWN script. See Indexes.
Portable index methods. Method takes a DbIndexMethods constant naming what the index is for (Default, Hash, FullText, Spatial, Contains, BlockRange) rather than one database's access method, so a model stays portable; PostgreSQL maps them to btree, hash, gin, gist, gin, and brin. Where and the RawMethod escape hatch are passed to the database verbatim and are documented as tying the model to one engine. When an engine cannot express an option, one that only affects performance (method, covering columns, sort order, a filter on a non-unique index) is dropped with a warning, while one that changes what the database enforces (uniqueness, or a filter on a unique index) is reported as an error instead of being silently weakened.
Scaffolding recovers indexes. scaffold reads existing indexes out of the database and emits [Index] attributes for them, so scaffolding a database and generating a migration no longer produces a migration dropping the indexes it already has. Access methods come back as portable constants where one fits. Indexes backing a primary key or UNIQUE constraint are skipped (they are already recovered as constraints), and expression indexes, which have no attribute form, are reported and left out rather than mis-read. See Database-first scaffolding.
SCGDB026. An [Index] naming a property the table does not have is now a build error. nameof gets the same check from the compiler; this covers string literals, where a typo previously surfaced only when the generated migration failed to apply. See Generator diagnostics.
Fixed
Rolling back a migration no longer fails on an auto-increment sequence. The DOWN script dropped a new table's sequence before the table itself. An
[AutoIncrement]column is created withDEFAULT nextval('<sequence>'), so the table depends on the sequence andDROP TABLE ... CASCADEdoes not cover it; PostgreSQL rejected the rollback withcannot drop sequence … because other objects depend on it. Every migration creating a table with an[AutoIncrement]column was affected, not only the first. The DOWN now drops the table first and its sequence after.The first migration's rollback no longer drops the migration history table.
_scg_migrationswas generated into the DOWN script like any other table, but the migration runner writes the rollback bookkeeping row into it inside the same transaction as that script, so dropping it made the row impossible to write and rolling back the first migration could never succeed. The history table and its sequence are now excluded from every DOWN script, the same way Entity Framework Core keeps__EFMigrationsHistory. A full rollback ends with an empty user schema and an intact history table whose last row records the rollback. Because that table survives, the first migration creates it withCREATE TABLE IF NOT EXISTS, so rolling back and then forward again re-applies cleanly instead of failing with42P07: relation "_scg_migrations" already exists. Every other table is created unguarded, as before: one that already exists is a genuine conflict and should fail loudly.Dropping a table drops the sequence it owned. The sequence was previously left behind, so a later migration re-creating a table of the same name silently reused it and continued its old numbering instead of starting from 1. It is dropped after the table, and only when no other table still references it: a sequence named explicitly with
[AutoIncrement(SequenceName = "…")]and shared between tables is left in place, with a warning naming the sequence and the table still using it.Rolling back a dropped table re-creates its sequence. The DOWN re-created the table with
DEFAULT nextval(…)but never re-created the sequence, so the rollback only worked while the sequence happened to still exist. It is now re-created before the table.
DownSql is edited so every DROP TABLE precedes that table's DROP SEQUENCE, and nothing drops _scg_migrations). Databases already migrated with older files are unaffected until they are rolled back.v0.3.6 (17 July 2026)
The library is now NativeAOT-compatible: it (and an app that consumes it) publishes with dotnet publish -p:PublishAot=true. Vault/OpenBao field encryption also gets the fixes that made the documented wiring unusable: per-column profiles now activate, encryption can be activated before migrations, and background rotation no longer crashes the host at boot.
Added
await app.UseSocigyVaultEncryption()activates Vault encryption before your first data access.AddSocigyVault*Encryptiononly registers the encryptors; they were primed exclusively by a startupIHostedService, which does not run untilapp.Run(). The documented quickstart doesawait app.EnsureLatest{Db}Migration()betweenBuild()andRun(), so any migration, bootstrap, or seed touching an[Encrypted]column threw "no IFieldEncryptor is configured" and the process died at boot — invisible with the local key provider, which configures its key synchronously. The new awaitable entry point primes and activates every registered profile up front; it extendsIHost(so it works on aWebApplication) and has anIServiceProvideroverload for hostless apps. It is idempotent, so the startup priming afterwards finds the work done and does not contact Vault twice, and a failed attempt (a sealed/unreachable Vault) is retried rather than cached. See HashiCorp Vault / OpenBao.SocigyFieldEncryption.IsProfileConfigured(profile).IsConfiguredonly ever reported the default profile, so it could not tell you whether an[Encrypted(Profile = "…")]column was ready — a missing profile stayed silent until the first read/write of such a column threw. The new overload reports any profile (null/empty means the default).NativeAOT: the library publishes with
PublishAot=true. The conveniencekeepselector and the querySelect/OrderBy/OrderByDesc, the updateWithFields/ExceptFields, and the joinOrderBy/OrderByDescpreviously took only anExpression<Func<T, object?[]>>, whosenew object?[] { ... }body emitsExpression.NewArrayInit([RequiresDynamicCode], IL3050), and the WHERE / UPDATE translator folded parameter-independent operands withExpression.Compile()(also[RequiresDynamicCode]), so adotnet publish -p:PublishAot=truefailed with dozens of IL3050 errors plus trim/AOT warnings onCore. Every one of those array-selector APIs now has an AOT-safeparams string[]/string[]overload that names columns by string (a property name such asnameof(Row.Id), or a DB column name), and the translator folds operands through a reflection interpreter instead ofCompile(). The library and a consuming app now publish AOT-clean (no IL3050 / IL3053). TheExpressionoverloads are unchanged for JIT callers.
Fixed
- A per-column encryption profile registered via DI is actually activated. Registering an envelope (default) encryptor alongside
AddSocigyVaultTransitEncryption(o => o.Profile = "…")activated only the first: both registered their primer withAddHostedService, which goes throughTryAddEnumerableand de-duplicates by implementation type, so the second primer was silently dropped and the first write to an[Encrypted(Profile = "…")]column threw — recommending the very DI helper that had been called. The priming work is now a per-registration service (which DI never de-duplicates) collected by a single hosted service, so the default and every named profile are primed. A transit-only registration was never affected; it is precisely the documented default-plus-profile combination that broke. - Background key rotation no longer kills the host at startup.
EnableBackgroundRotation = truewith the defaultRotationInterval(90 days) threwArgumentOutOfRangeException: dueTime ('7776000000') must be less than or equal to '4294967294'out ofStartAsync:System.Threading.Timercaps a dueTime at ~49.7 days. No unusual configuration was needed — only turning the documented feature on. Rotation now arms in clamped hops and rotates once the full interval has really elapsed, so any interval works. The token/credential renewal scheduler is clamped the same way (a lease TTL over ~74.6 days, or a longRefreshInterval, could throw on a timer thread where onlyObjectDisposedExceptionwas caught). EnableBackgroundRotationworks in EaaS-direct (Transit) mode.AddSocigyVaultTransitEncryptionnever readEnableBackgroundRotation/RotationInterval, so enabling rotation there was a silent no-op even though both options are offered on its type. It now registers a rotator, and a rotator per mode coexists (envelope + transit both rotating no longer drops one).- Predicate folding preserves the operand's CLR type (NativeAOT interpreter). With
Expression.Compile()removed for AOT, parameter-independent sub-expressions (captured-variable arithmetic, method calls, indexers, conversions, ternaries) are folded by a reflection interpreter, which now follows C# numeric promotion so the bound parameter keeps the type the compiled delegate produced: negating a value stays its own integral/floating type instead of collapsing todouble(which also lost precision for a largelong),~on an unsigned value keeps the unsigned bit pattern instead of sign-extending, a cast from a floating/decimal value to an integral type truncates toward zero instead of rounding, and a lifted nullable operation with anulloperand folds tonull(and anullrelational comparison tofalse). Each shape is verified against the compiled-delegate result. - The AOT-safe string
Select/OrderBycolumn names are quote-escaped. A name that does not resolve to a known column is taken to be a DB column name and quoted as-is; an embedded double-quote is now doubled ("→""), so a dynamically supplied name cannot break out of the quoted identifier. The same escaping is applied on the joinOrderBystring path. x.Col ?? nullemits aCOALESCEagain. The literal-nullrewrite toIS NULL/IS NOT NULLran for any operator, not just==/!=, so a coalesce whose right side is thenullliteral was rewritten to"col" IS NOT NULL— a boolean where a value belongs. The rewrite is now gated to (in)equality, matching the captured-null rewrite beside it.
v0.3.5 (28 June 2026)
The per-call insert field control is now a single, explicit InsertFields enum with an optional per-column keep selector.
Changed
InsertFieldsenum replaces the per-call insert booleans. The per-call insert field control on the context (InsertAsync/InsertMultipleAsync), static (Table.InsertMultipleAsync/InsertMultipleCopyAsync), and bulk (BulkCopy.InsertMultipleCopyAsync) paths is now a singleInsertFieldsenum (Default/IncludeAutoIncrement/ServerDefaults) instead of theincludeAutoFields/excludeDbDefaultsbooleans. The two opposite-polarity booleans were confusing (includeAutoFields: falsedid not exclude[Default]columns); the enum makes the three intents explicit and the contradictory combination unrepresentable. The fluentInsert()builder is unchanged.
Added
keepselector on the convenience insert methods. Akeepselector on those same methods writes specific[Default]columns yourself (e.g. a manual id) while the server fills the rest, the convenience-method equivalent of the fluentExcludeAutoFields(include), working on both batchedINSERTand binaryCOPY.CancellationTokenon the generated query and write APIs. The streamingExecuteAsync,CountAsync,SumAsync/AvgAsync/MinAsync/MaxAsync,ScalarAsync, the staticInsertAsync/UpdateAsync, and the insert/update/delete command builders now accept an optionalCancellationTokenand flow it toOpenAsyncand command execution, so a cancelled request (e.g. a dropped HTTP connection) stops the database work instead of running to completion and holding the connection. The parameter is optional and defaults todefault, so existing call sites are unchanged.
Fixed
- Migration apply is idempotent across app restarts again. The second and later startups no longer re-run already-applied migrations and crash at boot with
42P07: relation "..." already exists. The startup check for whether_scg_migrationsexists read PostgreSQL'sto_regclassvalue as a rawregclass, which Npgsql cannot materialize once the table is present, so the probe threw on every run after the first and the failure was swallowed into "no migrations applied". The probe now casts to a boolean (to_regclass(...) IS NOT NULL), matching the existence check already used for dynamic tables. See Applying migrations. - A failed version read no longer triggers a destructive re-apply.
GetCurrentMigrationVersion()previously caught every exception and returned "no version", which the manager read as an empty database and answered by re-running every UP migration. It now logs and rethrows instead of swallowing, and the forward apply loop independently skips any migration already recorded as applied. The skip is rollback-aware, so an UP, then DOWN, then UP still re-applies the migration. - Concurrent migration apply across replicas is serialized. When several instances of an app start at once (a rolling deploy or a scaled-up replica set), each calling
EnsureLatest{Db}Migration(), they previously raced the sameCREATE TABLE/ DDL and crashed with42P07/42701/23505, or left the schema half-applied. Migration apply now holds a PostgreSQL session-level advisory lock for its duration, so exactly one instance migrates at a time and the rest wait, then skip what was already applied. The lock auto-releases if a migrator process dies, so it cannot deadlock the fleet, and a database that is already current still short-circuits without taking the lock. See Applying migrations. - Database creation no longer crashes on a concurrent first start.
CREATE DATABASEhas noIF NOT EXISTSand cannot run in a transaction, so two instances creating the database at the same time raised42P04. The "already exists" race is now treated as success. - Binary COPY no longer throws on a UTC
DateTime.InsertMultipleCopyAsyncwroteDateTimevalues with an explicittimestamp without time zonetype, which Npgsql rejects for aKind=Utcvalue, so bulk-insertingDateTime.UtcNowthrew. UTC values are now normalized to the column's wall-clock so they round-trip instead of failing. InsertReturningAsync<T>works forGuid/uuidkeys. It routed the returned value throughConvert.ChangeType, which throws for non-IConvertibletargets likeGuid,DateTimeOffset, andbyte[], so returning a generateduuidprimary key failed. It now returns the value directly when it is already the requested type.RETURNING *propagation no longer silently drops server-generated values. A column-name mismatch was caught by a blanketcatchthat skipped the column, leaving a generated key unset on the in-memory row while still reporting success. The catch is narrowed so a genuine reader fault surfaces instead.- Single-table predicates quote column identifiers.
WHERE/SELECT/ORDER BYon a single table emitted bare column names (joins already quoted theirs), so a column whose name is a reserved word (order,user) or mixed-case produced invalid or wrong SQL. Identifiers are now quoted consistently with the join path. - Dynamic-table column cache is bounded. The per-
(type, runtime table name)cache behindDynamicTable.MapTypeAsyncgrew without limit; in a multi-tenant app with many runtime table names it now evicts entries to stay within a fixed cap. - Vault Transit decrypt cache no longer hands back a shared array. The decrypt cache returned the same
byte[]instance it stored, and abyte[]-typed encrypted column hands that array straight to the caller, so application code mutating the buffer corrupted the cached value for every later read of that row. The cache now keeps and returns private copies. - Offline re-encryption fails fast on a NULL primary key. Re-encryption pages the table by keyset comparison over the primary key, and a NULL key column would silently drop those rows from every batch after the first. It now throws a clear error naming the table and column instead.
- Aggregate overflow is now an actionable error.
SumAsync/AvgAsyncwiden the way PostgreSQL does (SUMof abigintreturnsnumeric), so requesting a too-narrow result type threw a bareOverflowException. The message now explains the widening and points you to a wider type such asdecimal; the methods also document this. ReadSUM(bigint)asdecimal. - Vault keyring parsing is culture-invariant and overflow-safe. A corrupted keyring key/version field now fails as a clear "malformed keyring" error rather than an unscoped
FormatException/OverflowException. - Plain enum columns are readable again. Querying any table with a plain (non-
[FlaggedEnum]) enum column threwSystem.InvalidCastException, because the row materializer read it viaGetFieldValue<TEnum>, for which Npgsql has no handler. Enum columns now read through their underlying integer. Verified end to end against a live database. - Binary COPY and parameterized inserts handle
DateOnly/TimeOnly/TimeSpan/DateTimeOffset. These valid column types were missing from the COPY and parameter type maps and fell back totext, so binary COPY (and a NULL bind on the parameterized path) threw or wrote the wrong type. They now map todate/time/interval/timestamptz. timestamptzcolumns propagate back onto aDateTimeOffsetproperty.WithValuePropagation()(RETURNING *) read such a column as theDateTimeNpgsql returns and could not convert it to aDateTimeOffsetmember; it now maps correctly.- Stored-procedure
affected/voidmethods accept and flow aCancellationToken. The scalar, streaming, and DTO procedure methods already did; the non-query ones were missed. - A
DateTime(Kind=Utc) stored into atimestampcolumn is no longer shifted by the session time zone. Because a non-nullDateTimewas bound without an explicit type, Npgsql inferredtimestamptzfromKind=Utcand PostgreSQL converted it to local wall-clock on store, so under a non-UTC sessionDateTime.UtcNowlanded hours off. The insert, update, bulk-COPY, dynamic-table, and delete paths now store the wall-clock verbatim. Verified against a live database. == null/!= nullagainst a captured variable matches the right rows. Only a literalnullwas translated toIS NULL/IS NOT NULL; anull-valued variable becamecol = @p/col <> @p, which (SQL three-valued logic) matched no rows. A null operand now usesIS NULL/IS NOT NULLwhether literal or captured.string.Equals(value, StringComparison.OrdinalIgnoreCase)is case-insensitive. The trailingStringComparisonwas ignored and the comparison ran case-sensitively; it now emitsLOWER(col) = LOWER(@p)for the *IgnoreCase comparisons.Contains/StartsWith/EndsWithescape LIKE wildcards in projections. The WHERE path already escaped%_\; the SELECT/ORDER BY path did not, so e.g.Contains("50%")matched any string containing50. Both paths now escape consistently.- A full (
WithAllFields) update with a customWHEREno longer clobbers primary keys. TheSETclause included the primary-key and serial columns, so an update whoseWHEREmatched multiple rows wrote the instance's key into every one (duplicate-key error / data loss). The PK and auto-increment columns are now excluded fromSET, and the PK-derivedWHEREquotes its column. jsonbcolumns update correctly viaWithFields. A selective update of a JSON column bound the serialized string astext(jsonb = texterror); it is now cast tojsonb.- Instance
DELETEhandles enum,DateTimeOffset, and the wider numeric primary-key types. Its type map lacked the enum and temporal cases the insert path has, so such a key was sent astextand threw or matched nothing. - JOIN multi-key descending sort applies
DESCto every key.OrderByDesc(x => new[]{ a, b })emittedORDER BY a, b DESC, sorting only the last key descending (and paging the wrong rows withLimit). Each key now getsDESC. Limit(0)on a JOIN or set-operation query returns zero rows instead of the whole result (the guard was> 0, droppingLIMIT 0).- An outer-join right side with no primary key yields
null, not a zeroed instance. The no-match test required all primary-key columns to be NULL; a PK-less table now falls back to all selected columns being NULL. HasFlagin aWHEREwith a composite flag no longer silently returns nothing.x.Role.HasFlag(A | B)bound the OR-ed integer and matched no junction row (each row holds one flag); it now throws a clear error telling you to combine flags with&&(HasFlag(A) && HasFlag(B)). Single-flagHasFlagis unchanged. (HasFlagpredicates are also excluded from the query-shape cache so a composite value can't reuse a single-flag plan.)- Granting a flagged-enum value is idempotent. The junction
Insert{Flag}ran a bareINSERT, so granting an already-present flag (or anEditRole/Syncbatch containing a duplicate) threw23505and could half-apply. It now usesINSERT ... ON CONFLICT DO NOTHING. - Changing a
[Default]on an existing column generates valid DDL. TheALTER COLUMN ... SET DEFAULTpath emitted the raw$socigy$…token instead of translating it, so the migration failed at apply ("unterminated dollar-quoted string"). It is now translated like the create/add-column paths. - A primary-key change has a correct DOWN. The rollback dropped the new primary key but never restored the old one, leaving the table with no primary key; the prior key is now re-created in the DOWN script.
- Rolling back a seed row containing a NULL works. The no-primary-key seed
DELETEmatched oncol = NULL(never true), so the delete silently did nothing; it now usesIS NULL. A copy/paste guard in migration naming that skipped removed-constraint hashing was also corrected. - The CLI returns a non-zero exit code on failure. Generate/scaffold error paths (missing assembly, missing
--connection/--from-schema, missingschema.json) logged an error but exited0, so a failed build-time generation looked like success in CI. They now return1. - A malformed
socigy.jsonreports the actual parse error. The config loader caught and discarded the deserialization exception, leaving only an opaque "Invalid configuration"; the underlying JSON error is now surfaced and chained. - Vault renewal retries quickly after a failure. A failed token or credential renewal rescheduled at the long fallback interval (token: 30 minutes; credentials: 2/3 of a now-stale lease), which could fall after the lease/token had already expired. A failed attempt now retries at the ~30-second floor.
- The generated sequence accessor quotes the sequence name.
GetNextValueAsyncused an unquoted identifier innextval(...)whilePeekCurrentValueAsyncused a quoted one; they now agree (correct for case-sensitive sequence names). - Database-first scaffolding hardening. A
[Default]value containing control characters is now JSON-escaped (valid C# string), and a foreign key with NULL column arrays no longer throws while reading the schema. - Database-first scaffolding preserves
numeric(precision, scale). A scaffoldeddecimal/numericcolumn collapsed to a barenumeric, silently dropping its precision and scale; the reader now captures them so a regenerated column matches the source. (Covered by a new live DB → schema → classes and a DB → schema → DDL → DB round-trip test.) DbCheck.Value(nameof(Prop))matches the real column name for acronyms. The CHECK builder snake-cased naively (IPAddress→i_p_address), while columns useJsonNamingPolicy.SnakeCaseLower(ip_address), so a check referencing such a column failed at apply with "column does not exist".DbCheck.Valuenow uses the same policy as the generator.- Rolling back a renamed column that has a UNIQUE/FK constraint produces valid DDL. The DOWN re-add of the old constraint emitted the raw PascalCase property name (
UNIQUE ("Phone")) instead of the column name, so the rollback failed. Unresolved constraint columns now fall back to the snake_case column name. - A
DateTimeOffsetwith a non-zero offset no longer crashes every insert path.DateTimeOffset.Nowcarries the local UTC offset, but Npgsql only writes aDateTimeOffsetat offset 0 totimestamptz, so inserting one threwArgumentException("only offset 0 (UTC) is supported"). The binary-COPY, single-row, multi-row, update, delete, dynamic-table, and stored-procedure paths now normalize to the same UTC instant (ToUniversalTime()) before binding. Verified against a live database. - Unsigned integer columns (
ushort/uint/ulong) no longer crash on write. Npgsql has no wire mapping for unsigned CLR types, so a value bound without an explicit type threwInvalidCastException. Every write path now widens them to the signed/decimal type the column maps to (int/long/numeric) before binding. Verified against a live database. - Stored-procedure parameters get the same value coercion as inserts. The generated procedure-call binding set no explicit type and performed none of the enum/
DateTime/DateTimeOffset/unsigned normalization the insert path does, so anenum,DateTime.UtcNow,DateTimeOffset.Now, or unsigned procedure argument threw at execution (or silently shifted by the session time zone). The procedure binder now applies the same normalization. Limit(0)on a single-table query returns zero rows. Only the JOIN and set-operation builders honoredLIMIT 0; on a plainTable.Query(...)the parser treatedlimit == 0as "no limit set" (the-1sentinel), took the cached no-LIMITfast path on a filtered query, and returned the whole table. A computedpageSizethat lands on 0 now correctly returns nothing.DynamicTable.MapTypeAsyncresolves columns for the right schema. It listed columns with a bareWHERE table_name = @tagainstinformation_schema.columns, which matches the name in every schema, so a same-named table in another schema (a common multi-tenant layout) merged foreign columns into the extra-column set and corrupted every materialized row. It now resolves the relation through the connection'ssearch_pathwithto_regclassand reads exactly that table's columns, matching how the rest of the type's queries resolve the name.- A database error mid-result is recorded as a failed span, not a successful one. Npgsql streams rows, so a server-side error surfacing partway through enumeration would still let the reader's disposal mark the trace span
Complete(success). The instrumented reader now records the failure on the span when a row read throws, so the error is observable in traces and the error counter. - A custom parameter-redaction hook's output is length-capped.
RedactParameteroutput bypassedMaxParameterValueLength, so a hook that echoed or expanded the value could bloat every span and log line. The cap now applies to the hook's output as well. - A nested call to a different database no longer runs on the wrong connection. In a modular monolith with more than one generated context, calling one database's
ExecuteAsync/ExecuteTransactionAsyncfrom inside another's unit of work joined the ambient scope unconditionally (the scope pointer is process-wide), so the inner database's queries executed against the outer database's connection and transaction. A nested call now joins the ambient scope only when it targets the same database (same connection factory and key); a different database opens its own scope. A transactional call nested in a same-database non-transactional scope now throws instead of silently running with no transaction. - Unsigned columns (
ushort/uint/ulong) round-trip correctly. The write side widens them tointeger/bigint/numeric, but the read side cast straight back to the unsigned CLR type, which Npgsql has no reader handler for, so querying such a column threwInvalidCastExceptioneven though the insert succeeded. The generated row, projection, publicReadValue/ConvertFrom, and aggregate/scalar read paths now narrow from the widened storage type. The baked[TableType]CREATE TABLEalso maps these types (andsbyte) to the matching widened SQL type instead oftext. Verified against a live database. - The public
ConvertFrom(reader, …)materializer tolerates widened value types. Reading abyte/sbyte(storedsmallint) or an unsigned column through the documented manual-read API did an unboxing cast that only succeeds when the boxed storage type exactly equals the property type, so it threw. It now routes through the width-tolerant converter the engine uses. MinAsync/MaxAsync/ScalarAsyncof aDateTimeOffsetcolumn no longer throw. They converted the scalar with rawConvert.ChangeType, which throws forDateTimeOffset(notIConvertible) — and Npgsql returns atimestamptzas a UTCDateTime. They now use the same converter as the row path, which maps the UTCDateTimeonto theDateTimeOffset(and handles the widened unsigned types). Verified against a live database.- A
byte-backed enum value convertor reads back correctly.DbEnumValueConvertorrequired the DB value's type to exactly equal the enum's underlying type, but abyte-backed enum is stored assmallint(returned asshort), so reading it threw. It now converts to the underlying type before materializing the enum. - Compiled-query cache: a captured
nulloperand no longer reuses the wrong SQL.x.Name == valueis translated toIS NULLwhenvalueis null at runtime and to= @potherwise, but the structural cache key didn't distinguish the two, so caching one shape and replaying it for the other returned wrong rows. An (in)equality whose value operand is a captured (non-literal) nullable type is no longer cached (literals and non-nullable value-type keys such asint/Guidstill cache). - Compiled-query cache: case-insensitive string compares don't collide with case-sensitive ones.
x.Name.Equals(s, StringComparison.OrdinalIgnoreCase)emitsLOWER(…) = LOWER(…)while the case-sensitive overload emits= @p, but both hashed the same, so the second query reused the first's SQL. TheStringComparisonvalue is now folded into the cache key (or the shape is left uncacheable when it's a runtime value). - Compiled-query cache:
DB.CustomField("col")no longer reuses another column's SQL. The custom-column name is spliced into the SQL text but collapsed to a value token in the cache key, so two different column names shared one cached plan and queried the wrong column.CustomFieldpredicates are now uncacheable, likeCustom/HasFlag. - Offline re-encryption no longer masks the original error on a failed batch. The batch rollback was unguarded, so a rollback on an already-broken transaction threw and replaced the real failure; it is now wrapped like the other transactional sites.
- Concurrent migration rollback can't double-apply a DOWN. The forward (UP) apply loop skips migrations already recorded as applied, but the rollback (DOWN) loop did not, so a rolling rollback across replicas (or a rollback racing another) could re-run a migration's
DownSqlfor one already rolled back and write a duplicate rollback row. The DOWN loop now applies the same rollback-aware applied-set guard and skips a migration that is no longer applied. - A value convertor on a primary key is honored in the
WHEREclause. An instanceUPDATE/DELETEbuilds itsWHEREfrom the primary key, but that path bound the raw CLR value while the column stores the convertor's output, so the update/delete silently matched no rows. The primary-key value (and its read-back) now go through the same convertor the insert/SET path uses. - Updating an enum column backed by a custom value convertor no longer throws. The
UPDATEbuilder coerced by the declared enum type and forced the column's integer DB type, so a convertor that stores an enum as text (or any non-underlying representation) threwInvalidCastException. It now coerces and types the parameter by the runtime value, matching the insert path, so the convertor's output is written as-is. ToLower()/ToUpper()in a dynamic-table projection are no longer silently dropped. TheSELECTvisitor emitted only the bare column for any string method other thanContains/StartsWith/EndsWith, so e.g. a case-insensitive compare became case-sensitive. It now emitsLOWER(...)/UPPER(...)(matching theWHEREvisitor) and throws on a genuinely unsupported string method instead of producing wrong SQL.- A null argument to
Contains/StartsWith/EndsWith/Equalsin a predicate fails fast. A null pattern becameLIKE '%%'(matching every row) andEquals(null)becamecol = NULL(matching none); both now throw a clearArgumentNullExceptionpointing at the correct null check, in both theWHEREandSELECTvisitors. - Vault token renewal is serialized. A scheduled renewal racing a manual one could both re-login and overwrite the shared client (wasted Vault logins and a nondeterministic active token). Renewal/relogin now runs under a lock so only one proceeds at a time.
- Each registered database keeps its own context options. In a modular monolith calling
Add{Db}Contextfor several databases, the options were registered as one shared (non-keyed) singleton and every factory resolved that single instance, so every database after the first silently inherited the first'sConnectionKey/ connection lifetime (wrong connection string, wrong pinning). Each factory now captures the options configured for its own registration. - Enum columns backed by a custom value convertor work on every write path. The runtime-vs-declared enum check that fixed the parameterized update is now also applied to binary COPY, the dynamic-table writer, and the delete-by-key path, so a convertor that stores an enum as text (or any non-underlying representation) no longer throws
FormatException/InvalidCastExceptionon those paths. Binary COPY also derives the wire type from the actual value, so the convertor's output type is written correctly. Verified against a live database. - A composite
[Flags]value can't be written as one unqueryable junction row. Granting/revoking a flagged-enum value with multiple bits set (e.g.Reader | Writer) stored a single junction row holding the combined integer, which no single-flag query could ever match (the read side already rejects composite values). The write side now rejects a composite value with a clear error; grant/revoke flags individually or pass them separately to the sync method. - Adding an
[AutoIncrement]column in a migration generates a working sequence. AnALTER TABLE ... ADD COLUMNfor a new auto-increment column created a table-qualified sequence but the column default referenced an un-prefixed name that was never created, so the migration failed at apply (relation "_id_seq" does not exist). The default now references the created sequence, the sequence is typed to the column, and the rollback drops the column before the sequence it depends on. - A join aggregate over a
DateTimeOffsetcolumn no longer throws.MinAsync/MaxAsync<DateTimeOffset>on aJoin(...)query used rawConvert.ChangeType(which can't targetDateTimeOffset— Npgsql returnstimestamptzas a UTCDateTime), throwingInvalidCastException. The join aggregate now uses the same converter as the single-table path. Verified against a live database. DynamicTable.InstantiateAsync()creates a usable table for enum columns. The bakedCREATE TABLEtyped an enum column astext, but the insert path binds the enum as its underlying integer, so every insert into the created table failed (column is of type text but expression is of type integer). The baked DDL now types an enum column as its integral type. Verified against a live database.ForEachAsynchonors itsCancellationTokenduring the read. The token was only checked between rows, not threaded into the streaming reader, so a cancellation could not interrupt an in-flight row read; it now flows to the database read. The streaming reader also disposes itsDbCommandon every exit path.- Database-first scaffolding maps
character(n)(n>1) tostring. A fixed-lengthchar(n)column scaffolded as a C#char, which can hold only one character (truncation); onlycharacter(1)now maps tochar, wider ones tostring. - Database-first scaffolding skips a cross-schema foreign key instead of emitting uncompilable code. A foreign key whose target table lives in another (un-scaffolded) schema produced a
[ForeignKey(typeof(<MissingType>))]reference; it is now skipped with a warning. - A
byte/sbyteDTO property (including abyte-backed enum) read from a procedure result no longer throws. These are stored assmallint(Npgsql returnsshort), and the DTO mapper read them withGetFieldValue<byte>, which throws. They now narrow fromshort, matching the unsigned handling already in place. Verified against a live database. - Rotating an envelope-encryption keyring no longer fails concurrent reads.
RefreshAsync/RotateAsyncdisposed the previous keyring synchronously, which zeroes its keys — so an in-flightEncrypt/Decryptthat had already captured it could fail its MAC check (aCryptographicExceptionon valid data) or decrypt garbage. The old keyring is now disposed after a short grace window, so in-flight operations drain first while the old keys are still eventually zeroed. - A from-scratch
CREATE TABLEmigration emitsNOT NULLfor required columns. Non-nullable, non-primary-key columns were created NULLABLE because the analyzer marks a required column's nullability as "unset" rather than explicitly false, and the generator only emittedNOT NULLfor an explicit false. Required columns are now correctly createdNOT NULL. - A get-only / computed / init-only property on a
[Table]model is ignored instead of breaking the build. Such a property was treated as a mapped column, and the generated materializer assigned to it (CS0200). A non-writable property is now skipped (like[Ignore]), so a computed property compiles and works. - A
{{Type.Property}}SQL placeholder for a non-column property is reported, not silently mis-resolved. Referencing an[Ignore],[FlaggedEnum], static, or get-only property in a procedure.sqlbody fabricated a quoted column name with no backing column (a runtime "column does not exist"). It now reports the same unknown-property diagnostic as any other non-column reference. - Migration ids include seconds, so two migrations generated in the same minute don't collide. The id (the ordering prefix and part of the filename) was truncated to the minute, so two
generateruns in the same minute could overwrite each other's file or sort ambiguously. Ids are nowyyyyMMddHHmmss; an existing minute id remains a lexical prefix of a same-minute seconds id, so apply order is unchanged. - The schema snapshot is written atomically after a
generate. The snapshot file was moved aside and only then rewritten, leaving a window where it didn't exist — a crash there made the next run treat the project as un-migrated and re-emit every migration. The new snapshot is now written to a temp file and moved into place, so it is never missing or half-written. ExecuteReturningAsync<DateTimeOffset>no longer throws. It converted theRETURNINGvalue with rawConvert.ChangeType, which throws forDateTimeOffset(atimestamptzcomes back as a UTCDateTime); it now uses the same converter as the row/aggregate paths. Verified against a live database.- Long-running migrations no longer time out. The migration command used Npgsql's default 30-second timeout, so a slow DDL step (e.g. an
ALTER COLUMN ... TYPEthat rewrites a large table, or a data backfill) could be aborted mid-apply and rolled back. The migration command timeout is now disabled. WHEREfilters onDateTime/DateTimeOffset/ unsigned values now bind correctly. The predicate parameter binding never got the normalization the write paths have, so a filter likex => x.At == DateTime.UtcNow(against atimestampcolumn) was inferred astimestamptzand shifted by the session time zone — silently matching the wrong rows — a non-UTCDateTimeOffsetfilter threw, and an unsigned-column filter threw. The WHERE parameter path now applies the sameKind=Utc → Unspecifiedrelabel,DateTimeOffset → UTCnormalization, and unsigned widening as the insert path. Verified against a live database.- A selective (
WithFields) update of aDateTime/DateTimeOffset/ unsigned column binds correctly. TheWithFieldsSET path normalized only enums, so it had the same shift/throw as the WHERE path above; it now applies the full normalization like the full-field update. - An empty
WithFields(...)selector fails with a clear error instead of emitting malformedSET WHERE ...; a duplicate member in aWithFieldsselector is de-duplicated (PostgreSQL rejects two assignments to the same column). - Vault credential/token renewal is never scheduled past a short lease's expiry. The 30-second busy-loop floor was applied even when it exceeded the lease lifetime, so a lease shorter than ~45s could be scheduled to renew at or after it had already expired. The floor is now capped against the lease, so a very short lease renews at 2/3 of its lifetime (before expiry) instead.
- A custom
[AutoIncrement("name")]sequence works on a runtime-instantiated table.DynamicTable.InstantiateAsync()baked the column asserial, which auto-creates a{table}_{column}_seqsequence rather than the custom-named one, so the column's value still flowed but the runtime sequence accessor (GetNextValueAsync/PeekCurrentValueAsync) targeted a sequence that didn't exist and threw. The baked DDL now creates the custom-named sequence and points the column default at it. - A
Containsfilter (= ANY(@array)) normalizes each array element like a scalar==filter. Filtering with a collection —roles.Contains(x.Role)(enum),times.Contains(x.At)(DateTime/DateTimeOffset), or an unsigned collection — threw (no Npgsql wire mapping for enum/unsigned arrays; a non-UTCDateTimeOffset[]rejected) or, for aKind=Utc DateTime[], silently matched the wrong rows. Each element is now normalized exactly as the scalar=path is, and the array binds with the matching element type. An array literal (new[]{…}.Contains(x.Col), which binds to the span-basedContains) is also supported now. Verified against a live database. - A null pattern in a cached
Contains/StartsWith/EndsWithno longer matches every row. The first translation rejected a null LIKE pattern, but a cached query shape replayed with a null value bypassed the guard and producedLIKE '%%'(every row). The guard now lives on the shared bind path, so translation and cache-replay behave identically. - An
ELSE-lessSelect.Case()...End()produces valid SQL. TheEnd()terminator emitted noEND, so anELSE-less projectedCASEwas malformed and failed at execution; it now closes theCASEblock. !=against a nullable column includes NULL rows, matching C# semantics.x => x.NullableValue != 5emittedcol <> @p, which SQL evaluates as NULL (not true) for a NULL row, so NULL rows were silently dropped — but in C#null != 5istrue. A!=over a nullable value-type column now emits(col <> @p OR col IS NULL)(as EF Core does), so those rows are included;==and non-nullable columns are unchanged.StartsWith/EndsWith/Containshonor aStringComparison.*IgnoreCaseargument. The case-insensitive overload silently emitted a case-sensitiveLIKE(dropping rows the author expected to match), while the parallelstring.Equalspath already honored it; these now emitILIKEfor the *IgnoreCase comparisons.- Capturing parameter values for diagnostics can no longer crash a query. With
CaptureParameterValueson, a parameter whoseToString()throws — or a customRedactParameterhook that throws — propagated out of the (otherwise successful) command, and on the failure path could even mask the original database exception. Rendering is now exception-safe (a failing value renders as<unrenderable>). - Diagnostics render array/collection parameters and timestamps usefully. An
= ANY(@p)array parameter was logged asSystem.Int32[]; it now shows its (bounded) contents.DateTime/DateTimeOffsetparameters now render in round-trip (o) format so theKind/offset is visible when debugging time-zone issues. - An inline constructor on the value side of a comparison binds as one value.
x.D > new DateOnly(2020, 1, 1)(ornew TimeOnly(...),new DateTimeOffset(...)) was shattered into one parameter per constructor argument —("D" > @p0@p1@p2)— a PostgreSQL syntax error;x.Gid == new Guid("...")bound the constructor'sstringinstead of aGuid(operator does not exist: uuid = text). TheWHEREvisitor now folds an inline constructor into a single normalized parameter, matching a captured local, and throws on a column-dependent constructor. - A column transform in single-table
ORDER BYfails fast instead of ordering by the bare column..OrderBy(x => new object[] { x.Name.ToUpper() })silently dropped theToUpper()and emittedORDER BY "Name", sorting by the raw value; an unsupported method-call transform now throws a clearNotSupportedException, matching how the operator path already rejects unsupported expressions. - A DTO with multiple constructors maps through the widest one. A procedure-return DTO that declares a convenience constructor alongside its primary one (e.g. a positional record plus a
(name)overload) boundInstanceConstructors[0](declaration order), which could pick the narrow overload and silently drop the unmapped members todefault. The generator now selects the highest-arity constructor (and reportsSCGDB021when two share the maximum arity rather than guessing). - An inline constructor in a projection binds as one value. The
SELECTvisitor had the same shatter as theWHEREvisitor: a projectednew DateOnly(...)/new Guid("...")(including in aCase().Then(...)/.Else(...)) emitted one parameter per constructor argument (@p0@p1@p2, invalid SQL) or bound a single-arg ctor'sstringinstead of theGuid. It now folds an inline constructor to a single normalized parameter. - An unsupported method call in a projection fails fast. A column-dependent method call the
SELECTvisitor doesn't translate (e.g.x.Created.AddDays(1)) fell through to the base visitor, which emitted only the bare column and silently dropped the call. It now throws a clearNotSupportedException, matching theWHERE/ORDER BYpaths. - JOIN
ON/WHEREparameters are normalized like single-table predicates. The multi-join visitor normalized only enums, so aDateTime(Kind=Utc), an offsetDateTimeOffset, or an unsigned value compared in a joinON/WHEREwas bound without the relabel/convert/widen the single-table path applies — silently shifting a UTC value by the session time zone, throwing on a non-UTC offset, or failing with no wire mapping for the unsigned type, while the identical single-table query was correct. The join path now binds through the sameNormalizeas the single-table path. - An inline constructor in a JOIN
ON/WHEREbinds as one value. The multi-join visitor had the same constructor shatter:a.Created > new DateTime(2020, 1, 1)emitted@p0@p1@p2(invalid SQL) andb.Gid == new Guid("...")bound thestringnot theGuid. It now folds an inline constructor to a single normalized parameter. DELETE-by-instance on a type-changing value-convertor primary key works. Delete forced the parameter's type from the declared PK type, so a PK whose[ValueConvertor]returns a different CLR type (e.g. anenumstored as itsstringname) had its convertedstringvalue forced toIntegerand Npgsql threw — while the identicalUPDATE-by-instance succeeded. Delete now mirrors update: it binds the converted value and lets Npgsql infer the type, forcing it only for a null or a real enum.DynamicTableaggregate / scalar reads no longer crash for enum andDateTimeOffsetresults.MinAsync/MaxAsync/ScalarAsyncon aDynamicTablecoerced the result withConvert.ChangeType, which throws for an enum target (the column is read as its underlyingint) and for aDateTimeOffset(notIConvertible). They now use the same width-tolerant converter as the join-aggregate path.- Migration DDL maps unsigned integer types to their widened runtime types. The CLI's CLR→PostgreSQL type map had no entry for
uint/ulong/ushort/sbyte, so it fell through to the raw .NET name (uint32, …) and emitted invalidCREATE TABLEDDL that fails to apply — and would have diverged from the type the runtime actually reads/writes. These now map tobigint/numeric/integer/smallint, matching the source generator's widening. - A captured value in an
ORDER BYCASEis normalized like the other paths. TheORDER BYvisitor bound a captured value raw, so an enum /Kind=UtcDateTime/ offsetDateTimeOffset/ unsigned value inside aSelect.Case()(When/Then/Else) was bound without the relabel/convert/widen the WHERE path applies — silently mis-ordering a UTC value by the session time zone, or throwing on an offset / unsigned value. It also lacked the inline-constructor fold, so anew DateOnly(...)/new Guid("...")shattered. Both are now handled, matching the WHERE / SELECT / JOIN visitors. - A
.Containsover a nullable-enum / nullable-unsigned collection works.roles.Contains(x.NullableRole)whererolesis aList<Role?>(orList<uint?>) threwInvalidCastException: the array element type stayedRole?while each element was normalized to its underlyingint. The element type is now unwrapped, widened, and re-wrapped asNullable<widened>, so the array binds (and anullelement is still representable). This is the nullable analog of the already-fixed non-nullable enum/unsigned array binding. - Database-first scaffolding strips schema-qualified casts from column defaults. Reading an existing schema, a column default with a schema-qualified or quoted type cast (
'x'::public.citext,'x'::"public"."citext") was only partially stripped — the type name's first segment was removed but the rest survived as'x'.citext, which scaffolded into a[Default("'x'.citext")]that forward-generates invalid DDL. The cast removal now consumes the whole qualified type name. - Vault envelope encryption no longer overwrites the keyring on a transient read error. Reading the envelope keyring caught every
VaultApiExceptionand treated it as "first run", so a non-404 failure (a 503 while Vault is sealed/standby, a 429, a network timeout, or a KV policy that grants write but not read) made the bootstrap path overwrite the existing keyring with a freshcurrent=1DEK — discarding every previously-wrapped key version and making all already-encrypted rows permanently undecryptable. Only a genuine 404 now triggers bootstrap; every other error propagates and the keyring is never replaced. - Non-enum
byte/sbytetable columns are readable. Such a column is stored assmallint, but the default (fast) read path calledGetFieldValue<byte>directly, for which Npgsql has noint2→bytehandler, so the row threwInvalidCastExceptionand never materialized — while the slow and procedure-DTO read paths narrowed correctly. The fast path now narrows fromshort, matching the others. The unsigned narrowings (ushort/uint/ulong) on all read paths also becamechecked, so an out-of-range stored value throws consistently instead of silently wrapping on one path and throwing on another. - A
[Table]maps columns inherited from a base class and declared across multiplepartialfiles. Column discovery read only the single class declaration that carried[Table], so a property inherited from a base class — or declared in a differentpartialof the same class — was silently dropped from the entire column set (never created, inserted, selected, or filterable), and was inconsistent with the procedure placeholder resolver and DTO mapper, which already walked the base chain. Discovery now walks the symbol's base chain and all partial declarations (deduping by name, most-derived first). A flat single-class model is unaffected — its generated code is identical. - Database-first scaffolding preserves single-column
UNIQUEconstraints. The C# class emitter wrote[ForeignKey]attributes but not[Unique], so scaffolding an existing schema dropped every unique constraint from the generated class — and the nextgeneratethen emitted aDROP CONSTRAINT, silently losing the uniqueness guarantee on a scaffold→migrate round-trip. A single-column unique now emits a property-level[Unique](the form the analyzer reads back). - Migration apply reads the current version under the advisory lock. The "already at this version" short-circuit and the apply-vs-rollback direction were decided from a version read taken before the migration advisory lock, so a startup racing a concurrent rollback on another replica could see "already current" and return while the schema was actually moved, with no self-correction. The version is now read under the lock. The cost is that an already-current startup briefly takes the lock too — negligible.
- Database-first scaffolding preserves composite-key column order. A composite primary key whose key order differed from the table's column declaration order was emitted in column order, silently changing the key (and its index prefix semantics).
[PrimaryKey]now takes an optional position ([PrimaryKey(order)]); the schema reader records each key column's ordinal, the generator emitsPRIMARY KEY (...)in that order, and the C# emitter writes the position so the order round-trips. Single-column keys and ordinary code-first models are unaffected. - Database-first scaffolding preserves composite
UNIQUEconstraints. A multi-column unique now emits a class-level[Unique(nameof(A), nameof(B))](and the analyzer reads class-level[Unique]back), so a composite unique survives the scaffold→migrate round-trip instead of being dropped. - A
Guid.Sequentialdefault no longer produces non-applyable DDL.[Default(DbDefaults.Guid.Sequential)]translates touuid_generate_v1mc(), which lives in theuuid-osspextension that is not installed by default — so the migration failed to apply with "function uuid_generate_v1mc() does not exist". The migration now emitsCREATE EXTENSION IF NOT EXISTS "uuid-ossp"ahead of any statement that uses it. (Guid.Random→gen_random_uuid()is built in and is unaffected.) [Encrypted]combined with[StringLength]is now a build error. An encrypted column is non-deterministicbytea; combining it with[StringLength]produced an order-dependentcharacter varying(n)DDL in the migration analyzer that contradicted thebyteathe runtime writes. The existingSCGDB002diagnostic now also covers[Encrypted]+[StringLength](alongside[JsonColumn]/[ValueConvertor]), failing the build instead of emitting an ambiguous column.- A baked
[TableType]CREATE TABLE maps anobjectcolumn tojsonb. The runtime baked DDL fell through totextfor anobject-typed column while the migration generator maps it tojsonb; they now agree, so a runtime-instantiated table and a migration-managed one don't diverge. - String concatenation in a predicate emits
||.x => x.Name + suffix == valuecompiles to aBinaryExpressionwithNodeType = Add, which the WHERE translator rendered as SQL+— and PostgreSQL has notext + textoperator, so the query failed with "operator does not exist: text + text". A stringAddnow emits||. - A
charcomparison binds the character, not its code point.x => x.Initial == 'A'(acharcolumn stored ascharacter(1)) is promoted by C# toint == int, so the translator bound the integer code point65, producingcharacter(1) = integer(operator does not exist). The char-promoted comparison now binds the value back as a one-character string that compares against the column — on both the first translation and the cached query-shape replay (the rebind lives on the shared parameter-binding path, so a repeated char predicate is not silently rebound to the raw integer). - A ternary in a predicate emits a SQL
CASE.x => (x.A > 0 ? x.B : x.C) == 5had noVisitConditionalhandling in the WHERE translator, so it emitted the branches with noCASE/WHEN/THENscaffolding (malformed SQL). It now emitsCASE WHEN ... THEN ... ELSE ... END(matching theSELECT/UPDATEtranslators); a constant test is folded to the chosen branch. - Diagnostics logger cache is published safely under concurrency. The double-checked-locking cache of the SQL logger read its two fields outside the lock without
volatile, so on a weak memory model a concurrent caller could observe the published factory while still seeing a stale/null logger and drop a log line. Both fields are nowvolatile. (Logging only — never affected query results.) - JOIN predicates handle string concatenation, char comparison, and ternaries. The multi-table JOIN translator had the same three gaps as the single-table
WHEREtranslator:a.Name + x == b.Labelemitted SQL+(text + texthas no operator),a.Initial == 'A'(acharcolumn) bound the integer code point againstcharacter(1), and a ternary(a.X > 0 ? a.Y : a.Z) == b.Wemitted malformed SQL with noCASE. All three now translate correctly in joinON/WHEREclauses, matching the single-table path. - A char comparison inside a projected or
ORDER BYCASEbinds the character. ASelect.Case().When(x.Initial == 'A')in a projection or anORDER BYbound the integer code point65against thecharacter(1)column (character = integer), like the originalWHEREbug. TheSELECTandORDER BYtranslators now bind the value back as a one-character string inside theirCASE WHENcomparisons. - Rolling back a dropped table restores its foreign keys. A
DROP TABLEmigration's DOWN re-created the table's columns, primary key, unique, and check constraints, but not its foreign keys (CREATE TABLEdeliberately defers FKs to a later pass that the removed-table path was missing), so a rollback silently left the table without its referential integrity. The DOWN now re-adds each dropped table's foreign keys after all removed tables are re-created. - Rolling back a dropped constraint + its column re-creates the column first. When a migration dropped a constraint and a column it references in one step, the DOWN re-added the constraint before the column (
column "..." does not exist), so the rollback failed to apply. Removed-constraint re-adds are now ordered after the column re-creations in the DOWN. - Seed values survive the schema-snapshot JSON round-trip. Restoring seed rows (a dropped table's DOWN, or a row add/remove) read them from the saved schema, whose values come back as
JsonElementafter JSON deserialization. The formatter decided quoting by re-parsing the text as a number, so a numeric-looking string (e.g. a[Description("404")]) lost its quotes and was emitted as an integer literal into atextcolumn — failing the migration.JsonElementvalues are now formatted by their JSON kind (a string stays quoted, a number stays bare, bool/null map directly), and the numeric fallback parses culture-invariantly. - Set-operation queries no longer leak a command. Each
UNION/INTERSECT/EXCEPTexecution (a.Union(b).ExecuteAsync()) created anNpgsqlCommandthat was never disposed — the enumerator disposed only the reader and the diagnostics scope. The command is now disposed when enumeration ends (completion, earlybreak, or exception), matching the join and insert/update/delete paths. - A
{{Type.Property}}procedure placeholder resolves consistently with the generated columns. For anew-shadowed property whose most-derived declaration is a non-column ([Ignore]/ get-only), the placeholder resolver fell through to the shadowed base declaration and emitted a column the table generator never creates. It now takes the most-derived declaration and accepts it only if it is a real column (mirroring the table generator's name-dedup), so the placeholder and the emitted columns agree. - A
[TableType]bakedCREATE TABLEemits the[Default]clause.DynamicTable.InstantiateAsyncbaked a[Default("...")]column asNOT NULLwith noDEFAULT, so the runtime-instantiated table diverged from the migration-generated one and an insert that omits the column (theServerDefaults/ExcludeAutoFieldspath) failed with a not-null violation (or, for a nullable column, silently storedNULLinstead of the default). The baked DDL now emitsDEFAULT <expr>, translatingDbDefaultstokens identically to the migration generator. - Database-first scaffolding round-trips fixed-length
character(n)columns. The schema reader returned a bare"character"for achar(n)column while the forward map emits"character(1)", so everyscaffold → generatereported a spuriousALTER COLUMN ... TYPEchange on those columns. The reader now preserves the length (character(n)), like it already does forvarchar(n)andnumeric(p,s). - Migration applied-state is resolved by insertion order, not the application clock. The applied set and the current version were computed by ordering the
_scg_migrationsrows byapplied_atonly, with no tiebreaker and noORDER BYon the read. Two rows can tie onapplied_at(microsecond truncation, a coarse/virtualized clock, a tight rollback-then-reapply) or even invert under NTP skew, which could mis-net an UP/DOWN pair — leaving a rolled-back migration in the applied set (so its re-apply is skipped) or dropping an applied one (triggering a destructive re-run). Resolution now orders by the monotonic auto-incrementid(the true apply order), which is immune to clock issues. - A scalar procedure returning
DateTimeOffset(orDateOnly/TimeOnly) materializes correctly.-- @returns scalar: DateTimeOffsetcast the boxed result directly, but atimestamptzis boxed by Npgsql as aDateTime, so(DateTimeOffset)__scalarthrewInvalidCastException. The scalar path now routes the non-IConvertibletypes through the same width-tolerant converter as the row and aggregate read paths (mappingDateTime→DateTimeOffset). - Two
[Table]classes with the same simple name in different namespaces no longer crash the generator. They produced identical generated-file hint names, so the source generator failed with an opaque "hintName already added" and emitted nothing. Hint names are now namespace-qualified, so e.g.Auth.UserandBilling.Userboth generate. - A generic or nested
[Table]reports a clear diagnostic instead of uncompilable code. The generator emits a non-generic, top-levelpartial class, which silently failed to compile (CS0264/CS0260) for a[Table] class Foo<T>or a nested[Table]class. These now reportSCGDB025("a[Table]type must be a top-level, non-generic class") and skip codegen. - A
[TableType]without a primary key no longer warns. TheSCGDB016"no primary key" warning fired on a pure[TableType](a runtime-named row shape used for projections that legitimately may have no key); it is now scoped to[Table]types, whose generated update/delete-by-key operations actually need one. - A
[ValueConvertor]column filtered in aWHEREpredicate binds the converted value.Table.Query(x => x.Label == "world")on a column with a[ValueConvertor]bound the raw CLR value while the column stores the convertor's output (e.g. an upper-cased"WORLD"), so the filter silently matched no rows. TheWHEREtranslator now runs the comparison value through the sameConvertToDbValuethe insert andSETpaths use (for==,!=, and the relational operators), including the nullable-!=OR col IS NULLform. Such a predicate is excluded from the query-shape cache (the cache-replay path rebinds from the source and would skip the convertor), and a table with no convertor columns translates exactly as before (a single null check, no allocation). Verified against a live database. - A
[FlaggedEnum]junction back to a composite-key table generates one multi-column foreign key. The auto-generated junction table emitted one single-columnFOREIGN KEYper primary-key column of the main table, but no individual key column is unique on its own, so the migration failed to apply (there is no unique constraint matching given keys). The per-key references are now aggregated into a single compositeFOREIGN KEY (a, b) REFERENCES main (x, y). - A wide-unsigned-backed enum column reads back correctly on every path. An enum whose underlying type is
ushort/uint/ulongis stored widened (integer/bigint/numeric), and two read paths cast it back wrong while the slow by-name path narrowed correctly. The fast ordinal row path passed anumeric-storedulong-backed enum (which Npgsql boxes asdecimal) straight toEnum.ToObject, which rejects adecimaland threw, so the whole row failed to materialize; and the procedure-DTO mapper read such a column withGetFieldValue<ushort>/<uint>/<ulong>, for which Npgsql has no handler, and threw. Both paths now narrow from the widened storage type before constructing the enum (matching the slow path,ApplyDbValue, and the non-enum unsigned columns). Verified against a live database. - A column that is both renamed and altered in one migration rolls back correctly. When a single model edit renamed a column and also changed its type (or nullability / default), the generated DOWN emitted the rename-back before the alteration-revert, but the revert still referenced the column's new name — which no longer existed after the rename-back — so the rollback failed at apply (
column "..." does not exist). The DOWN now reverts the alteration (by the new name) before renaming the column back, and still re-creates a changed primary key by the old name afterward. - Database-first scaffolding preserves single-column
UNIQUEconstraints (casing fix). The schema reader stores a constraint's columns in PascalCase (the property name) while the column's DB name is snake_case, and the emitter matched the single-column unique against the DB name only — so the match failed and the[Unique]was never emitted, dropping the constraint on the nextgenerate. The emitter now matches either casing, so a scaffolded single-column unique round-trips. - Database-first scaffolding preserves foreign-key
ON DELETE/ON UPDATEactions. The class emitter wrote the FK target and key columns but never the referential actions, so scaffolding aCASCADE/SET NULLFK regenerated it without the action (silently losing the cascade) and showed a spuriousDROP+ADDon every regenerate. The actions are now emitted asOnDelete/OnUpdateso they round-trip. - Database-first scaffolding sanitizes non-identifier column/table names. A DB name that is not a valid C# identifier (e.g.
2fa_enabled, which begins with a digit, or a quoted name containing punctuation) was emitted verbatim as a property/class name and did not compile. The reverse-naming now splits on any non-alphanumeric separator and prefixes an underscore when the result would start with a digit, with the real DB name preserved via a[Column]attribute. - Vault envelope encryption refuses to overwrite a keyring whose field is empty. The 404-only bootstrap guard covered the read-exception path, but a secret that exists yet whose configured keyring field is missing or empty still fell through to bootstrap-and-overwrite (a misconfigured
KeyringField, or a partial write), discarding the existing wrapped DEKs and making rows undecryptable. That case now fails loud instead of overwriting. DbCheck.Valuepreserves underscores in a property name. The snake-case converter (which must match the generator'sJsonNamingPolicy.SnakeCaseLower) stripped a leading underscore and collapsed a double underscore (_Leading→leading,a__b→a_b), so aCHECKon such a column referenced a name that does not exist and failed at apply. Underscores are now preserved verbatim, matching the policy exactly.- Multiple
CHECKconstraints on one column get distinct names. Two value checks on the same property (e.g.[Min(5)]and[Max(100)], or a[StringLength]length check beside a value check) were both namedCHCK_<table>_<column>, so theCREATE TABLEemitted two same-named constraints (apply failed withconstraint "..." already exists) and theALTER ... ADDpath collided with the existing one. A check's name now folds in its expression (stably), so each check over a column is uniquely named;UNIQUE/FOREIGN KEYnames are unchanged. - Database-first scaffolding: a NOT NULL column no longer reports a spurious change. The schema reader stored a non-nullable column as
Nullable=false, but the model analyzer represents it asNullable=null, so every required column compared unequal and emitted aSET NOT NULL(and aDROP NOT NULLDOWN) on the firstscaffold→generateround-trip. The reader now mirrors the analyzer (truewhen nullable, unset otherwise). - Database-first scaffolding:
jsonand unboundedvarcharcolumns round-trip without a spurious type change. Ajsoncolumn scaffolded asjsonwhile the[RawJsonColumn]analyzer reportsjsonb, and an unboundedcharacter varyingscaffolded ascharacter varyingwhile astringregenerates astext— each produced a spurious (and data-touching)ALTER COLUMN ... TYPEon every round-trip. The reader now canonicalizes a JSON column tojsonband an unbounded varchar totext. - An optional foreign key to an enum table no longer crashes migration generation. A nullable enum-table FK property (
public MyEnum? Role, whereMyEnumis a[Table]enum) passed the wrappedNullable<MyEnum>into the enum-table lookup, which looked for[Table]onNullable<T>(never present) and aborted the whole tool. The unwrapped enum type is now used, so an optional enum FK generates correctly. - Property-level
[ForeignKey(TargetKeys = [...])]is honored. The explicit target columns were read with a cast that is always null under the migration analyzer's reflection context, so they were silently dropped and the FK was auto-resolved to the target's primary key instead (or hard-failed for a composite-PK target). The property-level form now readsTargetKeysthe same way the class-level form does, so a FK to a non-PK unique column points where you asked. - An enum table with an undescribed member applies its first migration. The generated enum table's
descriptioncolumn was createdNOT NULL, but any enum member without a[Description]seeds that column withNULL, so the seedINSERTfailed at apply (null value in column "description" violates not-null constraint) and broke the very first migration. Thedescriptioncolumn is now nullable (it is genuinely optional);valuestays required. - A nullable
[FlaggedEnum]property no longer crashes migration generation. Apublic MyFlags? Rolesflagged-enum property passed the wrappedNullable<MyFlags>into the enum-table lookup, which aborted the whole tool (the same wrapped-type pitfall as the optional enum FK). The enum type is now unwrapped first, so an optional flagged-enum generates its junction table correctly. [Encrypted]is now authoritative for a column's type regardless of attribute order. An encrypted column storesbyteaciphertext, but[StringLength]and[Column(Type = "...")]also set the column type, and the migration analyzer applied them in source order, last-wins — so[Encrypted, StringLength(10)](a natural ordering) created avarchar(10)column and the encryptedbyte[]was then written into it. The encrypted type now wins unconditionally, so the column is alwaysbytea.[Encrypted]columns no longer emit a plaintextDEFAULT. A C# property initializer (or[Default]) on an encrypted column producedDEFAULT 'plaintext'on thebyteacolumn, which PostgreSQL rejects (invalid bytea literal) so the migration failed at apply. An encrypted column now carries no SQL default (a default on ciphertext is meaningless anyway).[TableType]InstantiateAsync()creates a non-nullable string/byte[] column asNOT NULL. The runtime-bakedCREATE TABLEforced every reference-type column nullable, so a non-nullablestring/byte[]property was createdNULL-able while the migration generator creates itNOT NULL— the two ways of creating the same table diverged, and aNULLrow could materialize a null into a non-nullable CLR property. The baked DDL now follows the declared nullability, matching the migration.- A joined column with a very long name round-trips instead of reading
NULL. The joinSELECTaliased each column as an unquotedaN_<column>; a column whose name pushed that alias past PostgreSQL's 63-byte identifier limit was truncated in the result label but not in the reader's lookup, so it silently read asNULL(and a mixed-case[Column("Name")]only survived via a case-insensitive fallback). The alias is now a short, quoted, positionalaN_cM, so every joined column reads back regardless of its name. - A procedure-DTO mapper no longer fails to compile on a rare name clash. The generated mapper method name was derived by replacing every non-alphanumeric character of the DTO's full name with
_, so two distinct DTO types whose full names differ only at a separator (e.g.A.B.Cvs aCin namespaceA_B) collapsed to the same name and produced a duplicate-method compile error. The name now includes a stable hash of the full type name, so the ids stay distinct. - A custom (undeclared)
timestamptzcolumn reads back asDateTimeOffset.TryGetCustomValue<DateTimeOffset>(...)usedConvert.ChangeType, which throws forDateTimeOffset(so the read returnedfalse), while a declared column read fine. Custom columns now use the same width-tolerant converter, so a capturedtimestamptzmaps onto aDateTimeOffsetlike a declared one. - Vault Transit (EaaS) re-encryption fails loud if run before priming.
NeedsUpgradereturnedfalsewhen the encryptor had not yet loaded the current key version (beforeRefreshAsync), so a re-encryption pass run too early silently reported "nothing to upgrade" while doing nothing. It now throws a clear "not primed" error instead of a silent no-op. - Clearer migration-analyzer errors for an incomplete
[ForeignKey]. A class-level[ForeignKey]missing itsKeysthrew a swallowedNullReferenceExceptionreported as an opaque "Unexpected error"; and two[FlaggedEnum]properties resolving to the same junction table produced a duplicateCREATE TABLE. Both now report a clear, actionable message naming what to fix.
v0.3.4 (26 June 2026)
Integration fixes found wiring the library into a modular monolith (model project + API + Host).
Added
contextNameconfig. A new optionalsocigy.jsonfield that decouples the generated C# identifiers from the physical database name, a lowercasedatabaseNamelikeidentitycan now produceIIdentityDb/AddIdentityDb()while the connection-string key and physical database stayidentity. Without it, the identifier is derived fromdatabaseName(the first letter is upper-cased, so a lowercase name no longer tripsCS8981).excludeDbDefaultson every insert path. The contextInsertAsync/InsertMultipleAsync, the staticTable.InsertMultipleAsync, andBulkCopy.InsertMultipleCopyAsyncnow takeexcludeDbDefaults: trueto omit[Default]columns so the server default applies, previously only the fluentInsert().ExcludeAutoFields()could do this, so a[Default]column left unset on those paths was silently written as the CLR default.
Changed
requiredmembers on[Table]models are supported. Generated constructors emit[SetsRequiredMembers](when the consumer targets a framework that has the attribute), sopublic required string Email { get; set; }no longer breaks the builders'new()constraint (CS9040).- Dependencies now flow to consumers. The package multi-targets
netstandard2.0;net8.0; onnet8.0+ it declaresNpgsqlandMicrosoft.Bcl.AsyncInterfacesas normal dependencies, so the generated code compiles and migrations run without adding either package by hand (previouslyCS0246forNpgsqlCommand/NpgsqlDbType, and a runtimeFileNotFoundExceptionforMicrosoft.Bcl.AsyncInterfaces).
Fixed
- Generator no longer crashes in projects without
socigy.json. The analyzer flows transitively to consumer projects (e.g. an API or Host that references a model project); without asocigy.jsonit now emits nothing instead of throwingCS8785(ArgumentNullException), so the modular-monolith layout works without stripping the generator. - Lowercase
databaseNameno longer produces invalid C# type names. A Postgres-conventionaldatabaseName: "identity"generated an all-lowercasepartial class identity, trippingCS8981underTreatWarningsAsErrors. Generated identifiers are now valid regardless of the database name's casing. - Multi-table migrations are named after all their tables. A single migration that created
usersandoutboxwas auto-named…_Addoutbox_…; it is now…_AddUsersAndOutbox_…(and…AndNMorebeyond two).
v0.3.3 (22 June 2026)
Added
- Binary COPY bulk insert.
BulkCopy.InsertMultipleCopyAsync(rows, conn)(andDynamicTable<T>.InsertMultipleCopyAsync) load large batches via PostgreSQL's binaryCOPY ... FROM STDIN (FORMAT BINARY), much faster than the parameterized multi-row insert and not bound by the 65,535-parameter limit. Values flow through the same per-column pipeline, so[Encrypted], JSON, and value-convertor columns are handled identically;NULLs are written as SQLNULL. COPY cannot useRETURNING, so database-generated values are not propagated back. See Bulk COPY. - Scalar procedure returns.
-- @returns scalar: Tgenerates aTask<T>for single-value queries (COUNT,MAX,EXISTS, …), supporting primitives,string,Guid, date/time types, and their nullable forms.NULL/empty results map todefault(T); numeric widening (e.g.COUNT'sbigint→int) is handled. See Procedure mapping. - Affected-row procedure returns.
-- @returns affectedgenerates aTask<int>returning the number of rows a write affected, instead of the defaultTask<bool>. - DTO procedure returns.
-- @returns:can now name a plain POCO or record (not just a[Table]type); the generator emits an AOT-safe, by-name materializer for it, ideal for projections and report shapes. - Database-first scaffolding. New CLI commands
scaffold schema(live database →structure.json) andscaffold classes(database orstructure.json→ annotated[Table]C# classes), reusing the existing schema model so the result round-trips withgenerate. See Database-first scaffolding. - Source-linked debug symbols. Packages ship with embedded PDBs and SourceLink, plus deterministic CI builds, so you can step into library source from your debugger.
- Public-API surface tracking. The Core project now tracks its public API (
PublicAPI.*.txt) to guard against accidental breaking changes ahead of 1.0.
Changed
- New generator diagnostics
SCGDB019–SCGDB022validate the new procedure return directives (unsupported scalar type, conflicting/malformed@returns, and DTOs that cannot be mapped). See Generator diagnostics.
Fixed
- Migration generation on Linux/headless builds. When no interactive name prompt is available, the generated migration's file and class name were derived from the multi-line schema-diff summary, producing an invalid name (embedded newlines and
:). Headless builds now use a clean, deterministic{timestamp}_{prefix}_{hash}identifier. - Package metadata now points at the correct repository (
github.com/Socigy-org/Socigy.OpenSource.DB).
Security
- Versioned keyring envelope encryption.
KeyringFieldEncryptorencrypts under a current key while still decrypting values written under earlier keys (the key id is embedded in the ciphertext), so keys can be rotated without rewriting existing rows. - Named encryption profiles. Register multiple encryptors and route individual columns with
[Encrypted(Profile = "…")]; reads are lock-free. Useful for mixing a fast local encryptor with a Vault-backed one for the most sensitive columns. - HashiCorp Vault / OpenBao Transit. New Transit-backed encryption modes (data-key envelope and direct EaaS) for the
Socigy.OpenSource.DB.HashiCorppackage, alongside the existing KV-v2 key and rotating database credentials. Verified against both HashiCorp Vault and the API-compatible OpenBao fork. - Offline re-encryption. A batched
FieldReencryptorupgrades existing rows (generated, dynamic, and[TableType]tables) to the current key version/profile.
v0.3.2 (6 June 2026)
Added
- Dynamic (runtime-named) tables. New
[TableType]attribute: declare a typed column shape once and bind the table name at runtime viaWithTableName(...), returning the typed entity (NativeAOT-safe). Full CRUD + aggregates, both standalone (WithConnection/WithTransaction) and through a context (db.DynamicTable<T>(name)). See Dynamic tables. - Custom (undeclared) columns.
WithCustomColumns(...)captures extra runtime columns into each row,TryGetCustomValue<T>(...)reads them, andDB.CustomField<T>("name")filters on them inside a normal predicate.MapTypeAsync(name, conn)auto-discovers a table's extra columns once and caches the schema. - Runtime table lifecycle.
InstantiateAsync()(CREATE TABLE from the declared shape),DeleteInstanceAsync()(DROP TABLE), andInstanceExistsAsync().[TableType]tables live outside the migration history, so the type manages its own DDL. - Extended join builders. Joins now support 3 and 4 tables (chain
.Join<T3>(…).Join<T4>(…)),OrderBy/OrderByDesc, client-side projection (.Select((a,b,…)=>…)→ a typed result), and aggregates (CountAsync/SumAsync/AvgAsync/MinAsync/MaxAsync). See Joins.
Fixed
- Outer joins now return
nullfor an unmatched side (was a zeroed default instance). Join tuple elements are nullable, so you can distinguish "no match" from a real row of defaults. Query(pred).Join<…>(…)now filters the driving table. The driving predicate was previously dropped.- Migrations apply atomically. Each migration's schema change and its
_scg_migrationsrow now commit (or roll back) in a single transaction, so a crash can no longer leave the schema changed-but-unrecorded or recorded-but-not-changed. See Applying migrations. - Migration order follows the
PreviousIdchain, not id sorting. Ids are minute-granularity timestamps; two migrations created in the same minute (or any non-sortable id) could previously apply out of order. A broken or forked chain now fails loudly. - Rollback-aware version detection. The current version is computed from the full history honoring
is_rollback, so a rolled-back migration is no longer reported as current. - Deterministic constraint names. Column-less constraints (e.g. raw CHECKs) no longer get a random
Guidname, so regenerated migrations are reproducible.
Changed
- Destructive and lossy migration statements are flagged. Generated migrations prefix data-losing operations with
-- [SOCIGY:DESTRUCTIVE](table/column drops) or-- [SOCIGY:LOSSY](narrowing/unsafe type casts), and the CLI lists them at generation time. See Schema generation.
Security
- Field encryption: associated-data binding (automatic). Generated code now binds every encrypted value to its
table:column(authenticated into the HMAC, not stored), so a value cannot be relocated to a different column/row and still decrypt.IFieldEncryptor/FieldCryptoalso expose the optionalassociatedDatafor custom use. See Encrypted columns. - Field encryption: key zeroing & portable format.
AesFieldEncryptorimplementsIDisposable(zeroes key material) and now encodes values in a fixed little-endian byte order so ciphertext is portable across architectures. - Vault: auth token is kept alive. A background service renews the Vault token (renew-self, or AppRole relogin at max TTL) so long-running apps no longer fail once the initial token expires.
- Vault: credential renewal tracks the real lease TTL (renews at ~2/3 of the lease) instead of a fixed interval that could outlast it.
- Vault: connection strings are built with
DbConnectionStringBuilder, so leased passwords containing;,=, quotes or spaces are escaped correctly. - Vault: a warning is logged when the Vault address uses plaintext HTTP to a non-loopback host.
v0.3.1 (5 June 2026)
Added
InsertMultipleAsyncon the database context.I{Table}Setnow exposesInsertMultipleAsync(entities, includeAutoFields, ct), batching a whole collection into multi-rowINSERTs within the unit-of-work scope. See Database context → Table-set methods.- Auto-field control on context inserts.
InsertAsyncandInsertMultipleAsynctakeincludeAutoFields(defaultfalse); passtrueto also write auto-increment columns (supply your own values), the context equivalent ofWithAllFields(). Backed by a newGetInsertPlan(bool includeAutoIncrement). - Projecting
ForEachAsync<TResult>. Streams matching rows, projects each through the callback, and returns the results (materialized inside the scope), so you can transform rows without a lazy enumerable escaping the connection. - Scalar & aggregate queries.
CountAsync(a realSELECT COUNT(*), replacing the previous client-side drain), plusSumAsync/AvgAsync/MinAsync/MaxAsyncand a single-valueScalarAsync<T>, on both the query builder and the database context, parameterized via the existing WHERE translation. See Aggregates & scalars.
Fixed
- CI now builds, packs, and publishes the optional
Socigy.OpenSource.DB.HashiCorppackage independently of the main package (each is version-checked separately, so a re-run still ships one when the other is already published).