Skip to content

Release 5.2.0 - #29

Merged
CaffeinatedCoder merged 6 commits into
mainfrom
release/5.2.0
Sep 5, 2026
Merged

Release 5.2.0#29
CaffeinatedCoder merged 6 commits into
mainfrom
release/5.2.0

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

The five features AuditOffice's review of its own workarounds asked for, one commit per concern, in the order they pay off.

  1. Identifier-length validation — an index or constraint name past the provider's limit (GetMaxIdentifierLength(): 63 bytes on PostgreSQL, 128 chars on SQL Server) is rejected at migrations add. PostgreSQL would truncate it with a NOTICE and apply cleanly, leaving the object under a name nothing reports. Default names are checked too; the package's own temporal-FK test model was the first to hit it (66 bytes).
  2. Read modelGetComplexIndexes() / FindComplexIndex(name) in core, GetExclusionConstraints() / FindExclusionConstraint(name) in PostgreSQL, on the mutable model inside OnModelCreating as well. The differs now build their descriptors from these readers, so there is one parser.
  3. Filter placeholders and converter membersfilter: "{RevokedAt} IS NULL" resolves at design time into the migration (no runtime seam), only for a dotted path in braces outside a single-quoted literal, so '{urgent}' and '{"a": 1}' stay verbatim. x => x.Email.Value on a converter-mapped value object resolves to its column when the member's type is the provider type; CreatedAt.Year still fails. Template resolution moved into core over a QuoteIdentifier seam (SQL Server brackets).
  4. Mutable APIAddComplexIndexFilter / AddExclusionConstraintFilter AND a predicate onto selected declarations, idempotently; AddComplexIndex adds one with the fluent API's identity rules. The withdrawable policy stays with the application via where.
  5. Typed filter predicates (PostgreSQL) — every filter: has a lambda form and the builders take HasFilter(x => …). Enums and DateTime/Guid literals are refused at the declaration; the latter is a behaviour change for typed expression indexes too, where they used to render as bare text.

Verification

  • 296 tests, live PostgreSQL 18 integration included.
  • Every new guard reverted-and-failed: 7/10 length tests, the converter type check and literal rule (3 direct + 4 sharing the context), the idempotence rule (3), the two typed-filter refusals (2).
  • dotnet pack -c Release with package validation against the 5.1.0 baseline, clean.
  • test/consumer-smoke-test.sh against that packed feed: passed for both satellites.

Deliberately left out

Resolved-name lookup (FindComplexIndex matches explicit names only — default names need the differ), AddExclusionConstraint on IMutableEntityType (needs the definition type public), typed filters on SQL Server (the translator is Npgsql-internal; the quoting seam now exists), and a temporal-constraint read model.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 6 commits September 5, 2026 10:07
A complex index, exclusion constraint, temporal constraint or temporal
foreign key whose resolved name exceeds the provider's identifier limit
now fails at `migrations add`. PostgreSQL truncates such a name to 63
bytes with a NOTICE and applies the migration cleanly, so the object
exists under a name that neither the declaration nor a later
constraint-violation error reports; a slice dispatching on the
constraint name falls through in silence. SQL Server rejects the
statement at apply time instead. Both are caught here.

The limit comes from EF's GetMaxIdentifierLength() (63 on PostgreSQL,
128 on SQL Server, none on SQLite), measured the way the provider
measures it: characters in the core, UTF-8 bytes in the PostgreSQL
satellite (MeasureIdentifier). Default names are checked too — this
package never truncates them, unlike EF's own — and only the target
model is validated, so a snapshot carrying such a name stays diffable.

The test model for temporal foreign keys was the first to hit it: its
default FK name, built from two table names, is 66 bytes. Shortened the
dependent table; the changelog says to name such constraints, since a
name-only change renames in place.

Also bumps the version to 5.2.0, the package-validation baseline to
5.1.0 and SECURITY.md's supported line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GetComplexIndexes() / GetDeclaredComplexIndexes() on IReadOnlyEntityType
and GetComplexIndexes() / FindComplexIndex(name) on IReadOnlyModel read
every declaration this package holds back as ComplexIndexDeclaration:
property-level, entity-level, composite and expression indexes unified,
with parts as property paths, IsUnique, Filter, the explicit Name and
the provider options of entity-level declarations. The PostgreSQL
package adds the same surface for exclusion constraints
(ExclusionConstraintDeclaration, GetExclusionConstraints,
FindExclusionConstraint), which makes ExclusionPartDefinition and
NpgsqlExclusionAnnotations public.

Both work on the mutable model inside OnModelCreating, which is where an
application checks a convention like "every unique index and exclusion
constraint on a withdrawable aggregate is filtered to live rows" — the
obligation nothing enforced, and one that could only be checked for the
index half while the exclusion side was internal.

The differs now build their descriptors from these readers and resolve
columns on top, so there is one parser: a read model that disagreed with
the differ would check something other than what the migration enforces.
The JSON normalization moved to AnnotationValues for the same reason.
Declarations are reported unresolved — Name is null for a default-named
one, so FindComplexIndex matches explicit names only — because resolution
needs the relational model and, for JSON members and templates, the
satellite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a converter

Filters were spliced verbatim while expression parts already resolved
{Property.Path} templates, so a filter had to repeat a column name that
HasColumnName decides elsewhere. ResolveFilter now substitutes
placeholders — quoted columns, or JSON extractions via
ResolveUnmappedPart — while descriptors are built, for indexes in the
core and for exclusion constraints in the Npgsql differ. The resolved
text rides on the operation, so the stock generator renders it with no
runtime wiring, and both sides of the diff compare on it, so a
placeholder filter never churns.

Filters are pre-existing SQL, so the rule is narrower than for
templates: only a brace pair holding a dotted identifier path outside a
single-quoted literal is a placeholder. '{urgent}' is an array literal,
'{"a": 1}' a JSON document, '{{1,2},{3,4}}' a two-dimensional array —
all untouched, which is also why there is no {{ escape. An
unresolvable placeholder throws; outside a literal braces are never
valid SQL.

Template resolution moves from the Npgsql differ into the core over a
new QuoteIdentifier virtual (ANSI by default, brackets in the SQL Server
satellite), which is what lets every provider resolve filters. The path
walk is shared as ResolveProperty and unwraps one member of a
converter-mapped value object: Email.Value resolves to the Email column
only when the property has a converter and the member's type equals the
converter's provider type. Without that check CreatedAt.Year would
silently index the whole column; it still fails, and says so.

Reverting the type check and the literal tracking makes the mismatched-
member, unconverted-member and literals-stay-verbatim tests fail (and
the four tests sharing the literal context with them).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AddComplexIndexFilter(predicate, where) on IMutableEntityType ANDs a
predicate onto the filter of every selected complex index — property-
level and entity-level alike — so a shared convention can install a
live-rows filter the way it installs the query filter, instead of every
configuration repeating it. AddComplexIndex(definition) adds an entity-
level declaration; it is ComplexIndexStorage.AddOrReplace retyped to
IMutableEntityType, so it carries the fluent API's identity and name
rules. The PostgreSQL package mirrors the filter half with
AddExclusionConstraintFilter.

An unfiltered declaration gets the predicate, a filtered one
(existing) AND (predicate), and one that already carries it — as the
whole filter or as the exact conjunct this appends — is left alone, so
the calls are safe to repeat. The policy itself (which types are
withdrawable, the opt-out for "unique across withdrawn rows too") stays
with the application: it passes a where predicate. The calls amend what
is declared at the time of the call, so they belong at the end of
OnModelCreating, never in a model-finalizing convention, whose
convention-source write could not overwrite the explicit annotation.

Removing the idempotence rule makes the double-application test and the
Conjoin rules test fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every filter: string has a lambda form — HasComplexIndex(x => x.Email,
x => x.RevokedAt == null), HasComplexCompositeIndex, HasExpressionIndex,
HasExclusionConstraint — and the builders take HasFilter(x => …)
(HasFilter<TEntity> on the non-generic index builders, which have no
entity type to infer). AddComplexIndexFilter and
AddExclusionConstraintFilter get typed forms too.

NpgsqlLinqIndexTranslator.TranslatePredicate turns the predicate into a
placeholder filter at the declaration, so nothing downstream knows the
filter was typed: the differ resolves {RevokedAt} like any placeholder,
the resolved SQL is baked into the migration, and the model does not
churn. The subset is boolean structure over the expression translator's
operands: ==/!= (IS [NOT] NULL against null), <, <=, >, >=, &&, ||, !,
boolean properties, captured booleans.

Two refusals are deliberate and throw at the declaration. Enums: how one
is stored depends on the property's value conversion, which the
translator cannot see, so Status == Status.Active would compare a text
column against 0 and fail at apply time. And literals without a portable
SQL spelling — DateTime, Guid — which the IFormattable arm used to
render as bare text in typed expression indexes as well; that arm now
accepts numbers only.

Removing the enum guard and the literal tightening makes the two
refusal tests fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder merged commit b2aca4f into main Sep 5, 2026
9 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the release/5.2.0 branch September 5, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant