Changelog
What changed in Socigy.OpenSource.DB. 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.
- `keep` selector on the convenience insert methods. Write 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. - `CancellationToken` on the generated query and write APIs. The streaming
ExecuteAsync,CountAsync, theSumAsync/AvgAsync/MinAsync/MaxAsyncaggregates,ScalarAsync, the staticInsertAsync/UpdateAsync, and the insert/update/delete command builders now accept an optional token and flow it toOpenAsyncand command execution, so a cancelled request stops the database work. The parameter defaults todefault, so existing call sites are unchanged.
- `InsertFields` enum replaces the per-call insert booleans. The per-call insert field control on the context, static, and bulk insert methods is now a single
InsertFieldsenum (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.
- Migration apply is idempotent across app restarts. The second and later startups no longer re-run already-applied migrations and crash with
42P07: relation already exists. The_scg_migrationsexistence probe read PostgreSQLto_regclassas a rawregclassthat Npgsql cannot materialize once the table exists; it now casts to a boolean (to_regclass(...) IS NOT NULL). - A failed migration-version read no longer causes a destructive re-apply.
GetCurrentMigrationVersion()now logs and rethrows instead of swallowing every exception into "nothing applied", and the forward apply loop skips any migration already recorded as applied (rollback-aware, so UP, DOWN, UP still re-applies). - Concurrent migration apply across replicas is serialized. Several instances starting at once (rolling deploy, scaled replica set) previously raced the same
CREATE TABLE/ DDL and crashed with42P07/42701/23505or half-applied schema. Migration apply now holds a PostgreSQL session-level advisory lock, so one instance migrates while the rest wait and then skip what was applied. The lock auto-releases if a migrator dies, and an already-current database short-circuits without locking. - Database creation no longer crashes on a concurrent first start.
CREATE DATABASEhas noIF NOT EXISTSand cannot run in a transaction, so two instances creating it at once raised42P04. The race is now treated as success. - Binary COPY no longer throws on a UTC `DateTime`.
InsertMultipleCopyAsyncwroteDateTimevalues with an explicittimestamptype that Npgsql rejects forKind=Utc, so bulk-insertingDateTime.UtcNowthrew. UTC values are now normalized so they round-trip. - `InsertReturningAsync<T>` works for `Guid` / `uuid` keys. It routed the result through
Convert.ChangeType, which throws for non-IConvertibletypes likeGuid,DateTimeOffset, andbyte[]; it now returns the value directly when it is already the requested type. - **
RETURNING *propagation no longer silently drops server-generated values.** A column mismatch was swallowed by a blanketcatchthat left a generated key unset while reporting success; the catch is narrowed so a real reader fault surfaces. - Single-table predicates quote column identifiers.
WHERE/SELECT/ORDER BYon a single table emitted bare column names (joins already quoted theirs), breaking columns whose name is a reserved word (order,user) or mixed-case. Identifiers are now quoted consistently. - Dynamic-table column cache is bounded. The per-table-name cache behind
DynamicTable.MapTypeAsyncgrew without limit; in a multi-tenant app with many runtime table names it now evicts to stay within a fixed cap. - Vault Transit decrypt cache no longer hands back a shared array. It returned the same
byte[]it cached, and abyte[]encrypted column passes that array straight to the caller, so mutating the buffer corrupted the cached value for later reads. The cache now keeps and returns private copies. - Offline re-encryption fails fast on a NULL primary key. Keyset pagination over the primary key would silently drop NULL-key rows from every batch after the first; it now throws a clear error naming the table and column.
- Aggregate overflow is an actionable error.
SumAsync/AvgAsyncwiden like PostgreSQL (SUMof abigintreturnsnumeric), so a too-narrow result type threw a bareOverflowException; the message now explains the widening and points to a wider type such asdecimal. - Vault keyring parsing is culture-invariant and overflow-safe. A corrupted keyring field now fails as a clear malformed-keyring error instead of an unscoped exception.
- Plain enum columns are readable again. Querying any table with a plain (non-
[FlaggedEnum]) enum column threwInvalidCastException, because the row materializer usedGetFieldValue<TEnum>, which Npgsql has no handler for. Enum columns now read through their underlying integer (verified against a live database). - Binary COPY and parameterized inserts handle `DateOnly` / `TimeOnly` / `TimeSpan` / `DateTimeOffset`. These valid column types were missing from the type maps and fell back to
text, so COPY (and a NULL bind on the parameterized path) threw or wrote the wrong type. They now map todate/time/interval/timestamptz. - `timestamptz` columns propagate onto a `DateTimeOffset` property.
WithValuePropagation()(RETURNING *) read such a column as theDateTimeNpgsql returns and could not convert it; it now maps correctly. - Stored-procedure `affected` / `void` methods flow a `CancellationToken`. The scalar, streaming, and DTO procedure methods already did; the non-query ones were missed.
- A `DateTime` (`Kind=Utc`) stored into a `timestamp` column is no longer shifted by the session time zone. It was bound without an explicit type, so Npgsql inferred
timestamptzfromKind=Utcand PostgreSQL converted it to local wall-clock on store; under a non-UTC sessionDateTime.UtcNowlanded hours off. All write paths now store the wall-clock verbatim. - `== null` / `!= null` against a captured variable matches the right rows. Only a literal
nullbecameIS NULL/IS NOT NULL; a null-valued variable becamecol = @p, which matched no rows (three-valued logic). Now both forms useIS NULL/IS NOT NULL. - `string.Equals(value, StringComparison.OrdinalIgnoreCase)` is case-insensitive. The trailing
StringComparisonwas ignored; it now emitsLOWER(col) = LOWER(@p). - `Contains` / `StartsWith` / `EndsWith` escape LIKE wildcards in projections. The SELECT/ORDER BY path did not escape
%_\, soContains("50%")matched any string containing50. Both paths now escape consistently. - A full (`WithAllFields`) update with a custom `WHERE` no longer clobbers primary keys. The
SETclause included the PK and serial columns, so a multi-row update wrote the instance key into every matched row (duplicate-key / data loss). PK and auto-increment are now excluded fromSET, and the PK-derivedWHEREis quoted. - `jsonb` columns update correctly via `WithFields`. A selective JSON update bound the serialized string as
text(jsonb = texterror); it is now cast tojsonb. - Instance `DELETE` handles enum, `DateTimeOffset`, and wider numeric primary keys. Its type map lacked the enum/temporal cases the insert path has, so such keys were sent as
textand threw or matched nothing. - JOIN multi-key descending sort applies `DESC` to every key (it sorted only the last key, paging the wrong rows). `Limit(0)` on a JOIN or set-operation query now returns zero rows instead of everything. An outer-join right side with no primary key now yields
nullrather than a zeroed instance. - `HasFlag` in a `WHERE` with a composite flag no longer silently returns nothing.
x.Role.HasFlag(A | B)bound the OR-ed integer and matched no junction row; it now throws a clear error to combine flags with&&. Single-flagHasFlagis unchanged (and is now excluded from the query-shape cache so a composite value can not reuse a single-flag plan). - Granting a flagged-enum value is idempotent. The junction insert was a bare
INSERT, so granting an already-present flag (or anEditRole/Syncbatch with a duplicate) threw23505and could half-apply; it now usesON CONFLICT DO NOTHING. - Migration generation fixes. Changing a
[Default]emitted the raw$socigy$token (migration failed at apply); a primary-key change had no real DOWN (rollback left the table with no primary key); a no-primary-key seedDELETEusedcol = NULL(a silent no-op) instead ofIS NULL; and a copy/paste guard skipped removed-constraint hashing in migration naming. All corrected. - The CLI returns a non-zero exit code on failure. Generate/scaffold error paths (missing assembly, missing
--connection/--from-schema) logged an error but exited0, so a failed build-time generation looked like success in CI; they now return1. A malformedsocigy.jsonalso surfaces the real parse error instead of an opaque message. - Vault renewal retries quickly after a failure. A failed token/credential renewal rescheduled at the long fallback (token: 30 minutes; credentials: 2/3 of a now-stale lease), which could fall after expiry; a failed attempt now retries at the ~30-second floor.
- Generated sequence accessor + scaffolding hardening.
GetNextValueAsyncnow quotes the sequence name to agree withPeekCurrentValueAsync(case-sensitive names); database-first scaffolding JSON-escapes[Default]literals containing control characters and tolerates foreign keys with NULL column arrays. - Database-first scaffolding preserves `numeric(precision, scale)`. A scaffolded
decimalcolumn collapsed to a barenumeric, dropping precision/scale; the reader now captures them so a regenerated column matches the source. - `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 the check referenced a non-existent column and failed at apply. It now 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 emitted the raw PascalCase property name (
UNIQUE ("Phone")) instead of the column; an unresolved constraint column now falls back to its snake_case name. - A `DateTimeOffset` with a non-zero offset no longer crashes every insert path. Npgsql only writes a
DateTimeOffsetat offset 0 totimestamptz, so aDateTimeOffset.NowthrewArgumentException. The COPY, single/multi-row, update, delete, dynamic-table, and procedure paths now normalize to the same UTC instant (ToUniversalTime()). 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; every write path now widens them to
int/long/numericbefore binding. Verified against a live database. - Stored-procedure parameters get the same value coercion as inserts. The generated binder set no explicit type and skipped the enum/
DateTime/DateTimeOffset/unsigned normalization, so such a procedure argument threw at execution or shifted by the session time zone. It now applies the same normalization. - `Limit(0)` on a single-table query returns zero rows. The single-table parser treated
limit == 0as the "no limit" sentinel and took the cached no-LIMITfast path on a filtered query, returning the whole table. It now emitsLIMIT 0. - `DynamicTable.MapTypeAsync` resolves columns for the right schema. A bare
WHERE table_name = @tmatched the name in every schema, so a same-named table in another schema merged foreign columns into the extra-column set and corrupted rows. It now resolves through the connectionsearch_pathwithto_regclassand reads exactly that table. - A database error mid-result is recorded as a failed span. A server error surfacing partway through row streaming would still let reader disposal mark the trace span successful; the instrumented reader now records the failure when a row read throws.
- A custom parameter-redaction hook output is length-capped.
RedactParameteroutput bypassedMaxParameterValueLength, so a hook echoing the value could bloat every span/log; the cap now applies to the hook output too. - A nested call to a different database no longer runs on the wrong connection. In a modular monolith with several contexts, calling one database from inside another’s unit of work joined the process-wide ambient scope unconditionally, so the inner database’s queries ran on the outer database’s connection/transaction. A nested call now joins only a same-database scope; a different database opens its own. A transactional call nested in a same-database non-transactional scope now throws instead of silently running untransacted.
- Unsigned columns (`ushort` / `uint` / `ulong`) round-trip correctly. The write side widens them to
integer/bigint/numeric, but the read side cast straight back to the unsigned type, which Npgsql cannot read, so querying threwInvalidCastException. The row, projection, publicReadValue/ConvertFrom, and aggregate/scalar reads now narrow from the widened storage; the baked[TableType]DDL also maps these (andsbyte) correctly. Verified against a live database. - `MinAsync`/`MaxAsync`/`ScalarAsync` of a `DateTimeOffset` column no longer throw. They used raw
Convert.ChangeType, which throws forDateTimeOffset; Npgsql returnstimestamptzas a UTCDateTime. They now use the same converter as the row path. Verified against a live database. - A `byte`-backed enum value convertor reads back correctly.
DbEnumValueConvertorrequired an exact underlying-type match, but abyte-backed enum is stored assmallint(returned asshort) and threw on read; it now converts to the underlying type first. - Compiled-query cache: a captured `null` operand no longer reuses the wrong SQL.
x.Name == valuebecomesIS NULLwhenvalueis null and= @potherwise, but the cache key didn’t distinguish them, returning wrong rows. An (in)equality whose value operand is a captured nullable type is no longer cached (literals and non-nullableint/Guidkeys still cache). - Compiled-query cache: case-insensitive string compares don’t collide with case-sensitive ones.
Equals(s, OrdinalIgnoreCase)emitsLOWER(…)=LOWER(…)while the case-sensitive overload emits= @p; both hashed the same. TheStringComparisonvalue is now folded into the key (or left uncacheable when it’s a runtime value). - Compiled-query cache: `DB.CustomField("col")` no longer reuses another column’s SQL. The column name is spliced into the SQL text but collapsed to a value token, so different columns shared one plan.
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 rolling back 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 UP apply loop skips already-applied migrations, but the DOWN loop did not, so a racing/stale rollback could re-run a migration’s
DownSqland write a duplicate rollback row. The DOWN loop now uses the same rollback-aware applied-set guard. - A value convertor on a primary key is honored in the `WHERE` clause. An instance
UPDATE/DELETEbuilt itsWHEREfrom the raw PK value while the column stores the convertor’s output, so it silently matched no rows. The PK value now goes through the same convertor as the insert/SET path. - Updating an enum column backed by a custom value convertor no longer throws. The
UPDATEbuilder coerced by the declared enum type and forced the integer DB type, so a convertor that stores an enum as text threwInvalidCastException. It now coerces and types the parameter by the runtime value, matching the insert path. - `ToLower()`/`ToUpper()` in a dynamic-table projection are no longer silently dropped. The
SELECTvisitor emitted only the bare column for any string method other thanContains/StartsWith/EndsWith(so a case-insensitive compare became case-sensitive). It now emitsLOWER(...)/UPPER(...)and throws on a genuinely unsupported method. - A null argument to `Contains`/`StartsWith`/`EndsWith`/`Equals` in a predicate fails fast. A null pattern became
LIKE '%%'(matched everything) andEquals(null)becamecol = NULL(matched nothing); both now throw a clearArgumentNullException, in theWHEREandSELECTvisitors. - Vault token renewal is serialized. A scheduled renewal racing a manual one could both re-login and overwrite the shared client (wasted logins, nondeterministic active token); renewal/relogin now runs under a lock.
- Each registered database keeps its own context options. In a modular monolith calling
Add{Db}Contextfor several databases, the options were a shared non-keyed singleton and every factory resolved that one instance, so each database after the first inherited the first’sConnectionKey/lifetime. Each factory now captures its own registration’s options. - Enum columns backed by a custom value convertor work on every write path. The runtime-vs-declared enum check is now also applied to binary COPY, the dynamic-table writer, and delete-by-key, so a convertor storing an enum as text no longer throws on those paths; COPY also derives the wire type from the value. Verified against a live database.
- A composite `[Flags]` value can’t be written as one unqueryable junction row. Granting a multi-bit value (e.g.
Reader | Writer) stored a single row with the combined integer that no single-flag query could match; the write side now rejects composites (grant flags individually or via the sync method), matching the read side. - Adding an `[AutoIncrement]` column in a migration generates a working sequence. The
ALTER ... ADD COLUMNdefault referenced an un-prefixed sequence name that was never created (relation "_id_seq" does not exist); it now references the created, column-typed sequence, and the rollback drops the column before the sequence. - A join aggregate over a `DateTimeOffset` column no longer throws.
MinAsync/MaxAsync<DateTimeOffset>on aJoin(...)used rawConvert.ChangeType, throwingInvalidCastException; it 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 baked
CREATE TABLEtyped an enum column astext, but the insert binds the underlying integer, so inserts failed. The baked DDL now types enum columns as their integral type. Verified against a live database. - `ForEachAsync` honors its `CancellationToken` during the read (it was only checked between rows), and the streaming reader disposes its
DbCommandon every exit path. - Database-first scaffolding maps `character(n)` (n>1) to `string` instead of a single C#
char(which truncated); onlycharacter(1)maps tochar. - Database-first scaffolding skips a cross-schema foreign key (target table in an un-scaffolded schema) with a warning, instead of emitting a
[ForeignKey(typeof(<MissingType>))]reference that does not compile. - A `byte`/`sbyte` DTO property (including a `byte`-backed enum) read from a procedure result no longer throws. Stored as
smallint(Npgsql returnsshort), they were read withGetFieldValue<byte>(throws); they now narrow fromshort, like the unsigned types. Verified against a live database. - Rotating an envelope-encryption keyring no longer fails concurrent reads. The previous keyring was disposed synchronously (zeroing its keys), so an in-flight
Encrypt/Decryptthat captured it could throwCryptographicExceptionon valid data; it is now disposed after a grace window so in-flight operations drain first. - A from-scratch `CREATE TABLE` migration emits `NOT NULL` for required columns. Non-nullable, non-primary-key columns were created NULLABLE (the analyzer marks required nullability as "unset", and the generator only emitted
NOT NULLfor an explicit false). Required columns are now createdNOT NULL. - A get-only / computed / init-only property on a `[Table]` model is ignored instead of breaking the build. It was treated as a column and the generated materializer assigned to it (
CS0200); a non-writable property is now skipped like[Ignore]. - A `{{Type.Property}}` SQL placeholder for a non-column property is reported, not silently mis-resolved. An
[Ignore]/[FlaggedEnum]/static/get-only property fabricated a quoted column name with no backing column; it now reports the unknown-property diagnostic. - Migration ids include seconds, so two migrations generated in the same minute don’t collide. The id (ordering prefix + filename) was minute-truncated, so same-minute runs could overwrite each other or sort ambiguously. Ids are now
yyyyMMddHHmmss; an old minute id stays a lexical prefix of a same-minute seconds id, so apply order is unchanged. - The schema snapshot is written atomically after a `generate`. It was moved aside then rewritten, leaving a window with no snapshot file — a crash there made the next run re-emit every migration. It is now written to a temp file and moved into place.
- `ExecuteReturningAsync<DateTimeOffset>` no longer throws. It used raw
Convert.ChangeType(throws forDateTimeOffset; atimestamptzreturns 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 the default 30s timeout, so a slow
ALTER ... TYPE/backfill could be aborted mid-apply; the migration command timeout is now disabled. - `WHERE` filters on `DateTime`/`DateTimeOffset`/unsigned values bind correctly. The predicate binding never got the write-path normalization, so
x => x.At == DateTime.UtcNow(atimestampcolumn) was inferred astimestamptzand shifted by the session time zone (wrong rows), a non-UTCDateTimeOffsetfilter threw, and an unsigned filter threw. The WHERE path now applies the sameKind=Utc → Unspecified,DateTimeOffset → UTC, and unsigned-widening normalization. Verified against a live database. - A selective (`WithFields`) update of a `DateTime`/`DateTimeOffset`/unsigned column binds correctly — the
WithFieldsSET path normalized only enums and had the same shift/throw; it now applies the full normalization. - An empty `WithFields(...)` selector fails with a clear error instead of malformed
SET WHERE; a duplicate member inWithFieldsis de-duplicated. - Vault credential/token renewal is never scheduled past a short lease's expiry. The 30s busy-loop floor was applied even when it exceeded the lease lifetime, so a lease shorter than ~45s could renew at/after expiry; the floor is now capped against the lease (a very short lease renews at 2/3 of its lifetime).
- A custom `[AutoIncrement("name")]` sequence works on a runtime-instantiated table.
DynamicTable.InstantiateAsync()baked the column asserial(which creates{table}_{column}_seq), so the runtime sequence accessor targeting the custom name threw; the baked DDL now creates the custom-named sequence and defaults the column to it. - A `Contains` filter (`= ANY(@array)`) normalizes each array element like a scalar `==` filter.
roles.Contains(x.Role)(enum), aDateTimeOffset[]/unsigned collection threw, and aKind=Utc DateTime[]silently matched the wrong rows; each element is now normalized as the scalar path is. Array literals (the span-basedContains) are supported too. Verified against a live database. - A null pattern in a cached `Contains`/`StartsWith`/`EndsWith` no longer matches every row. First translation rejected a null LIKE pattern but a cache-replay bypassed it (producing
LIKE '%%'); the guard now lives on the shared bind path. - An `ELSE`-less `Select.Case()...End()` produces valid SQL. The
End()terminator emitted noEND, so the projectedCASEwas malformed; it now closes the block. - `!=` against a nullable column includes NULL rows, matching C# semantics.
x.NullableValue != 5emittedcol <> @p(which excludes NULL rows), butnull != 5istruein C#; it now emits(col <> @p OR col IS NULL)like EF Core.==and non-nullable columns are unchanged. - **
StartsWith/EndsWith/Containshonor aStringComparison.*IgnoreCaseargument** (emitILIKE); they previously silently emitted case-sensitiveLIKE, unlikestring.Equals. - Capturing parameter values for diagnostics can no longer crash a query. A parameter whose
ToString()throws, or a throwingRedactParameterhook, propagated out of the command (and could mask the original error); rendering is now exception-safe (<unrenderable>). - Diagnostics render array/collection parameters and timestamps usefully — an
= ANY(@p)array now shows its bounded contents instead ofSystem.Int32[], andDateTime/DateTimeOffsetrender round-trip (o) so Kind/offset is visible. - An inline constructor on the value side of a comparison binds as one value.
x.D > new DateOnly(2020,1,1)was shattered into("D" > @p0@p1@p2)(a syntax error) andx.Gid == new Guid("...")bound thestringnot aGuid; theWHEREvisitor now folds an inline constructor into a single normalized parameter and throws on a column-dependent one. - A column transform in single-table `ORDER BY` fails fast.
.OrderBy(x => new object[] { x.Name.ToUpper() })silently dropped theToUpper()and emittedORDER BY "Name"(wrong order); an unsupported method-call transform now throwsNotSupportedException, like the operator path. - A DTO with multiple constructors maps through the widest one. A procedure-return DTO declaring a convenience constructor alongside its primary one bound
InstanceConstructors[0](declaration order), which could pick the narrow overload and drop members todefault; the generator now picks the highest-arity constructor (and reportsSCGDB021on a tie). - An inline constructor in a projection binds as one value. The
SELECTvisitor shared theWHEREshatter — a projectednew DateOnly(...)/new Guid("...")(incl. inCase().Then/.Else) emitted@p0@p1@p2(invalid SQL) or bound a Guid as itsstring; it now folds to a single normalized parameter. - An unsupported method call in a projection fails fast. A column-dependent method call the
SELECTvisitor cannot translate (e.g.x.Created.AddDays(1)) silently emitted the bare column; it now throwsNotSupportedExceptionlike theWHERE/ORDER BYpaths. - JOIN `ON`/`WHERE` parameters are normalized like single-table predicates. The multi-join visitor normalized only enums, so a UTC
DateTime, an offsetDateTimeOffset, or an unsigned value in a join condition was silently shifted by the session time zone, rejected, or unmappable; it now binds through the sameNormalizeas single-table. - An inline constructor in a JOIN `ON`/`WHERE` binds as one value.
a.Created > new DateTime(2020,1,1)emitted@p0@p1@p2andb.Gid == new Guid("...")bound thestring; the multi-join visitor 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 type from the declared PK type, so a convertor PK returning a different CLR type (e.g. enum→string) had its string forced to
Integerand Npgsql threw, whileUPDATEsucceeded; delete now binds the converted value and lets Npgsql infer, matching update. - `DynamicTable` aggregate/scalar reads no longer crash for enum / `DateTimeOffset` results.
MinAsync/MaxAsync/ScalarAsynccoerced withConvert.ChangeType, which throws for an enum target (read as its underlying int) and for aDateTimeOffset; 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 type map had no entry for
uint/ulong/ushort/sbyte, falling through to the raw .NET name (uint32, …) → invalidCREATE TABLEDDL; they now map tobigint/numeric/integer/smallint, matching the source generator. - A captured value in an `ORDER BY` `CASE` is normalized like the other paths. The
ORDER BYvisitor bound a captured enum / UTCDateTime/ offsetDateTimeOffset/ unsigned value inside aSelect.Case()raw — silently mis-ordering or throwing — and lacked the inline-constructor fold; both are now handled, matching the WHERE/SELECT/JOIN visitors. - A `.Contains` over a nullable-enum / nullable-unsigned collection works.
roles.Contains(x.NullableRole)with aList<Role?>(orList<uint?>) threwInvalidCastException(array stayedRole?while elements widened toint); the element type is now unwrapped, widened, and re-wrapped asNullable<widened>, the nullable analog of the existing enum/unsigned array fix. - Database-first scaffolding strips schema-qualified casts from column defaults. A default with a schema-qualified or quoted type cast (e.g. a
::public.citextcast) was only partially stripped, leaving a.citextfragment in the literal → a[Default]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 keyring caught every
VaultApiExceptionas "first run", so a non-404 failure (503/429/timeout, or a write-but-not-read KV policy) made bootstrap overwrite the existing keyring with a freshcurrent=1DEK — making all already-encrypted rows undecryptable. Only a genuine 404 now triggers bootstrap; every other error propagates. - Non-enum `byte` / `sbyte` table columns are readable. Stored as
smallint, they were read viaGetFieldValue<byte>on the default fast path (noint2→byteNpgsql handler), throwingInvalidCastExceptionso the row never materialized — while the slow and DTO paths narrowed correctly. The fast path now narrows fromshort; the unsigned narrowings also becamecheckedso an out-of-range value throws consistently instead of silently wrapping on one path. - A `[Table]` maps columns inherited from a base class and declared across multiple `partial` files. Discovery read only the single declaration carrying
[Table], so an inherited or other-partial property was silently dropped from the whole column set (never created/inserted/selected/filterable). It now walks the symbol base chain and all partials (dedup by name, most-derived first); a flat single-class model generates identically. - Database-first scaffolding preserves single-column `UNIQUE` constraints. The C# class emitter wrote
[ForeignKey]but not[Unique], so scaffolding dropped every unique constraint → the nextgenerateemitted aDROP CONSTRAINT, silently losing uniqueness on a scaffold→migrate round-trip. A single-column unique now emits a property-level[Unique]. - Migration apply reads the current version under the advisory lock. The "already current" short-circuit and the apply-vs-rollback direction were decided from a version read taken before the migration lock, so a startup racing a concurrent rollback on another replica could return "already current" while the schema was moved. The version is now read under the lock.
- Database-first scaffolding preserves composite-key column order. A composite PK whose key order differed from the column order was emitted in column order (changing the key);
[PrimaryKey]now takes an optional position ([PrimaryKey(order)]), the reader records each key ordinal, and the generator/emitter preserve it. Single-column and code-first models are unaffected. - Database-first scaffolding preserves composite `UNIQUE` constraints. A multi-column unique now emits a class-level
[Unique(nameof(A), nameof(B))](and the analyzer reads class-level[Unique]back), so it survives the scaffold→migrate round-trip instead of being dropped. - A `Guid.Sequential` default no longer produces non-applyable DDL. It translates to
uuid_generate_v1mc()(in theuuid-osspextension, not installed by default), so apply failed; the migration now emitsCREATE EXTENSION IF NOT EXISTS "uuid-ossp"first.Guid.Random(gen_random_uuid()) is built in and unaffected. - `[Encrypted]` combined with `[StringLength]` is now a build error. An encrypted column is
bytea; combining it with[StringLength]produced an order-dependentcharacter varying(n)DDL contradicting thebyteathe runtime writes.SCGDB002now also covers[Encrypted]+[StringLength], failing the build. - A baked `[TableType]` CREATE TABLE maps an `object` column to `jsonb`. It fell through to
textwhile the migration generator mapsobjecttojsonb; they now agree, so a runtime-instantiated and a migration-managed table do not diverge. - String concatenation in a predicate emits `||`.
x => x.Name + suffix == valuecompiles to aBinaryExpressionAdd, rendered as SQL+→ "operator does not exist: text + text"; a string Add now emits||. - A `char` comparison binds the character, not its code point. A
char-column equality is promoted by C# toint == int, so the translator bound the code point65→character(1) = integer; it now binds the value back as a one-character string, on both the first translation and the cached query-shape replay (the rebind lives on the shared parameter-binding path). - A ternary in a predicate emits a SQL `CASE`.
x => (x.A > 0 ? x.B : x.C) == 5had noVisitConditionalin the WHERE translator (malformed SQL); it now emitsCASE WHEN ... THEN ... ELSE ... END, matching the SELECT/UPDATE translators. - Diagnostics logger cache is published safely under concurrency. The double-checked-locking logger cache read its fields outside the lock without
volatile, so a concurrent caller could see the published factory but a stale/null logger and drop a log line; both fields are nowvolatile. (Logging only.) - JOIN predicates handle string concatenation, char comparison, and ternaries. The multi-table JOIN translator shared the three single-table
WHEREgaps:a.Name + xemitted SQL+,a.Initial == charbound the code point againstcharacter(1), and a ternary emitted malformed SQL with noCASE. All three now translate correctly in joinON/WHERE. - A char comparison inside a projected or `ORDER BY` `CASE` binds the character.
Select.Case().When(x.Initial == char)in a projection orORDER BYbound the integer code point against thecharacter(1)column; theSELECTandORDER BYtranslators now bind a one-character string inside theirCASE WHEN. - Rolling back a dropped table restores its foreign keys. A
DROP TABLEDOWN re-created columns/PK/unique/check but not foreign keys (deferred to a pass the removed-table path lacked), so rollback lost referential integrity; the DOWN now re-adds each dropped table\u2019s FKs after all tables are re-created. - Rolling back a dropped constraint + its column re-creates the column first. Dropping a constraint and a column it references in one step produced a DOWN that re-added the constraint before the column (
column does not exist); removed-constraint re-adds are now ordered after the column re-creations. - Seed values survive the schema-snapshot JSON round-trip. Restored seed rows come back as
JsonElement; the formatter decided quoting by re-parsing as a number, so a numeric-looking string (e.g.[Description("404")]) lost its quotes and became an integer literal into atextcolumn.JsonElementis now formatted by its JSON kind and the numeric fallback parses culture-invariantly. - Set-operation queries no longer leak a command. Each
UNION/INTERSECT/EXCEPTexecution created anNpgsqlCommandthe enumerator never disposed (only the reader and scope were); the command is now disposed when enumeration ends, matching the join and insert/update/delete paths. - A `{{Type.Property}}` procedure placeholder resolves consistently with the generated columns. For a
new-shadowed property whose most-derived declaration is a non-column, the resolver fell through to the shadowed base 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. - A `[TableType]` baked `CREATE TABLE` emits the `[Default]` clause.
InstantiateAsyncbaked a[Default("...")]column asNOT NULLwith noDEFAULT, so it diverged from the migration schema and an insert omitting the column failed (or silently storedNULL); the baked DDL now emitsDEFAULT <expr>, translatingDbDefaultstokens like the migration generator. - Database-first scaffolding round-trips fixed-length `character(n)` columns. The schema reader returned a bare
"character"forchar(n)while the forward map emits"character(1)", so every scaffold→generate reported a spurious type change; the reader now preserves the length, likevarchar(n)andnumeric(p,s). - Migration applied-state is resolved by insertion order, not the application clock. The applied set and current version ordered
_scg_migrationsrows byapplied_atonly (no tiebreaker, noORDER BY); a tie (microsecond truncation, coarse clock, tight rollback-then-reapply) or NTP skew could mis-net an UP/DOWN pair — leaving a rolled-back migration applied or dropping an applied one. Resolution now orders by the monotonic auto-incrementid. - A scalar procedure returning `DateTimeOffset`/`DateOnly`/`TimeOnly` materializes correctly.
-- @returns scalar: DateTimeOffsetcast the boxed result directly, but atimestamptzis boxed as aDateTime, so(DateTimeOffset)__scalarthrew; the scalar path now routes non-IConvertibletypes through the same width-tolerant converter as the row/aggregate reads. - Two `[Table]` classes with the same simple name in different namespaces no longer crash the generator. They produced identical generated-file hint names (opaque "hintName already added", nothing emitted); hint names are now namespace-qualified, so
Auth.UserandBilling.Userboth generate. - A generic or nested `[Table]` reports a clear diagnostic instead of uncompilable code. The generator emits a non-generic, top-level
partial class, which silently failed to compile for a[Table] class Foo<T>or a nested[Table]; these now reportSCGDB025and skip codegen. - A `[TableType]` without a primary key no longer warns.
SCGDB016(no primary key) fired on a pure[TableType](a runtime-named projection shape that may legitimately have no key); it is now scoped to[Table]types, whose update/delete-by-key operations need one. - A `[ValueConvertor]` column filtered in a `WHERE` predicate 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 converted form (e.g. an upper-casedWORLD), so the filter silently matched no rows. TheWHEREtranslator now runs the comparison value through the sameConvertToDbValueas the insert andSETpaths (for==,!=, and the relational operators), including the nullable-!=OR col IS NULLform. Such a predicate is excluded from the query-shape cache (the replay path rebinds from the source and would skip the convertor), and a table with no convertor columns translates exactly as before. Verified against a live database. - A `[FlaggedEnum]` junction back to a composite-key table generates one multi-column foreign key. The junction emitted one single-column
FOREIGN 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 backed by
ushort/uint/ulongis stored widened (integer/bigint/numeric); 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 (boxed by Npgsql 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 new name — gone 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 `UNIQUE` constraints (casing fix). The schema reader stores a constraint columns in PascalCase (the property name) while the column 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 UPDATE` actions. The class emitter wrote the FK target and key columns but never the referential actions, so scaffolding a
CASCADE/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.Value` preserves underscores in a property name. The snake-case converter (which must match the generator
JsonNamingPolicy.SnakeCaseLower) stripped a leading underscore and collapsed a double underscore (_Leadingtoleading,a__btoa_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 `CHECK` constraints 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 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 first scaffold-to-generate round-trip. The reader now mirrors the analyzer (true when nullable, unset otherwise). - Database-first scaffolding: `json` and unbounded `varchar` columns round-trip without a spurious type change. A
jsoncolumn 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 reflection context, so they were silently dropped and the FK was auto-resolved to the target primary key instead (or hard-failed for a composite-PK target). The property-level form now reads
TargetKeysthe 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
descriptioncolumn was createdNOT NULL, but any enum member without a[Description]seeds that column withNULL, so the seedINSERTfailed at apply (null value in column ... 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. A
public 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 type regardless of attribute order. An encrypted column stores
byteaciphertext, 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 plaintext `DEFAULT`. A C# property initializer (or
[Default]) on an encrypted column producedDEFAULT plaintexton 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 as `NOT NULL`. The runtime-baked
CREATE 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 join
SELECTaliased each column as an unquotedaN_<column>; a column whose name pushed that alias past PostgreSQL 63-byte identifier limit was truncated in the result label but not in the reader 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 replaced every non-alphanumeric character of the DTO 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) `timestamptz` column reads back as `DateTimeOffset`.
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.