From 3fd2508a9366bc5a8b32c81685c95797c74fa8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 17 Aug 2026 00:39:21 +0200 Subject: [PATCH] docs: split the README into topic pages and move the changelog out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root README was 584 lines, 350 of them the PostgreSQL section alone, so the landing page was mostly a provider manual for one of three packages. Provider reference moves to docs/, split along the seam that already exists: PostgreSQL indexes go through the differ plus the custom generator, while temporal and exclusion constraints are rendered as design-time SqlOperations. The changelog moves to CHANGELOG.md at the root and one per package. It was the only part of the README that grew unboundedly, and it had two consumers pinned to its old home: release.yml's awk and PackagingConventionTests' baseline lookup. Both now read CHANGELOG.md, and ChangelogConsistencyTests asserts the "## x.y.z" heading style rather than merely parsing it — release.yml matches that heading literally, so a section demoted to ### would still read as documented while the release job published a blank release. Two guards for what the split newly depends on. DocumentationLinkTests: relative links resolve to real files, #anchors to real headings, and the packed READMEs under src/ carry no relative links at all — nuget.org renders PackageReadmeFile with no base URL, so a relative link is dead exactly where most consumers arrive, with nothing at pack time to notice. DocumentationApiTests: every method the docs name exists, and no provider page names another provider's exclusive API. Package validation already fails the pack on a removed public member, so what this adds is the rename fixed in source and forgotten in prose. Changelogs are out of scope — an entry saying 5.0.0 shipped HasExclusionConstraint stays true after a rename, and asserting over them would turn every rename into pressure to rewrite history. That last test went in green, and the vacuity check is why it is worth anything: emptying its allowlist showed AddDbContext missing from the result, because \b[A-Z][A-Za-z0-9]*\( never matches a generic invocation. HasTemporalForeignKey ( is how every generic API in these docs is written, so the whole generic surface was unchecked while the test reported green. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 22 +- CHANGELOG.md | 59 +++ CLAUDE.md | 24 +- CONTRIBUTING.md | 13 +- README.md | 442 +----------------- docs/postgresql-constraints.md | 212 +++++++++ docs/postgresql-indexes.md | 148 ++++++ docs/sqlserver.md | 47 ++ .../CHANGELOG.md | 61 +++ .../README.md | 68 +-- .../CHANGELOG.md | 37 ++ src/EFCore.ComplexIndexes.SqlServer/README.md | 42 +- src/EFCore.ComplexIndexes/CHANGELOG.md | 67 +++ src/EFCore.ComplexIndexes/README.md | 72 +-- .../ChangelogConsistencyTests.cs | 105 +++-- .../DocumentationApiTests.cs | 238 ++++++++++ .../DocumentationLinkTests.cs | 165 +++++++ .../Harness/RepositoryLayout.cs | 5 + .../PackagingConventionTests.cs | 4 +- 19 files changed, 1202 insertions(+), 629 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/postgresql-constraints.md create mode 100644 docs/postgresql-indexes.md create mode 100644 docs/sqlserver.md create mode 100644 src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md create mode 100644 src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md create mode 100644 src/EFCore.ComplexIndexes/CHANGELOG.md create mode 100644 test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs create mode 100644 test/EFCore.ComplexIndexes.Tests/DocumentationLinkTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 343fc66..80566e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: run: dotnet build -c Release # The full suite gates the release, which also means ChangelogConsistencyTests runs: - # a version with no changelog entry in the root or package READMEs fails here. + # a version with no changelog entry in the root or package CHANGELOG.md fails here. # CI=true keeps the integration layer mandatory. - name: Test env: @@ -259,12 +259,12 @@ jobs: # attaching ours would invite a hash comparison that fails for a benign reason. nuget.org is the # immutable store for the packages; the SBOM has no other home. # - # The release itself is created here when it does not exist yet, with the README's "What changed" - # section as the notes — the same text ChangelogConsistencyTests already requires for the shipped - # version, so an empty extraction is a workflow bug and fails the job rather than publishing a - # blank release. A release created by hand before the tag was pushed is left as written; only the - # assets are added. Either way the packages are already on nuget.org: a failure here is loud - # and costs nothing but a manual upload. + # The release itself is created here when it does not exist yet, with the root CHANGELOG.md's + # section for this version as the notes — the same text ChangelogConsistencyTests already requires + # for the shipped version, down to the heading style it asserts, so an empty extraction is a + # workflow bug and fails the job rather than publishing a blank release. A release created by hand + # before the tag was pushed is left as written; only the assets are added. Either way the packages + # are already on nuget.org: a failure here is loud and costs nothing but a manual upload. # # Separate from `publish` on purpose: that job holds id-token: write, this one holds # contents: write, and no job holds both. @@ -280,7 +280,7 @@ jobs: contents: write # create the release and upload assets; deliberately no id-token steps: - # The checkout is for README.md, the source of the release notes. + # The checkout is for CHANGELOG.md, the source of the release notes. - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Download packages @@ -310,14 +310,14 @@ jobs: fi # The section for this version: from its heading up to, not including, the next H2. - notes="$(awk -v heading="## What changed in $VERSION" ' + notes="$(awk -v heading="## $VERSION" ' $0 == heading { found = 1; print; next } found && /^## / { exit } found { print } - ' README.md)" + ' CHANGELOG.md)" if [[ -z "$notes" ]]; then - echo "::error::README.md has no '## What changed in $VERSION' section to use as release notes." + echo "::error::CHANGELOG.md has no '## $VERSION' section to use as release notes." exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5b44b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All releases of the three packages, newest first. Each package also carries its own changelog, +covering only what changed for that package: +[core](src/EFCore.ComplexIndexes/CHANGELOG.md), +[PostgreSQL](src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md), +[SQL Server](src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md). + +## 5.0.3 + +A packaging and documentation release. No behaviour changes to the differ or the generated SQL. + +- **Changed:** the EF Core dependency now declares an exclusive upper bound — `[10.0.0, 11.0.0)` on `Microsoft.EntityFrameworkCore.Abstractions` for the core package, and on the provider package for each satellite. This package subclasses `MigrationsModelDiffer` and calls internals EF marks as changeable without notice in any release, so an open-ended `>= 10.0.0` let NuGet resolve a future major where the differ can break — surfacing as a confusing `dotnet ef` failure in your project rather than anywhere visible from here. **Nothing changes for existing consumers:** NuGet resolves the lowest version in a range, so restore still picks 10.0.0. Adopting EF Core 11 will need a release that lifts the ceiling deliberately, once the differ has been tested against it. +- **New:** the public API is now fully documented, so IntelliSense no longer comes up empty on the fluent API, the annotation keys, `CompositeIndexDefinition`, or `IndexPartDefinition`. The shipped `.xml` had 64 holes in it; `TreatWarningsAsErrors` now keeps it complete. +- **Tests:** a consumer smoke test runs on every PR and on release. It packs the packages, installs them into a throwaway project created outside this repository, and runs a real `dotnet ef migrations add` — then asserts on the scaffolded content, because the failure it guards against is a migration that succeeds while silently omitting every index. Nothing previously exercised the delivery chain end to end: NuGet restore, the packaged `.targets` injecting the design-time attribute, EF's host discovering it, and the right differ winning. + +## 5.0.2 + +A review of the 5.0.1 tree turned up eleven issues. The first three produced migrations that +scaffolded *and applied* cleanly while being silently wrong; the rest turn late, obscure, or silent +failures into errors raised at the declaration or during `dotnet ef migrations add`. + +- **Fixed:** the design-time differ is now selected deterministically. A satellite package's `DesignTimeServicesReferenceAttribute` is scoped to its provider (`ForProvider`), and the core registration backs off when a satellite is present — previously, because the core package's attribute rides along transitively and EF resolves last-registration-wins, NuGet's restore order decided which differ ran. A solution referencing two satellites could hand one provider's model to the other provider's differ, silently dropping its index options. +- **Fixed:** temporal `UNIQUE … WITHOUT OVERLAPS` constraints and temporal foreign keys are now rendered at design time, like exclusion constraints, and no longer need `UseNpgsqlComplexIndexes()`. Previously a consumer without that wiring got a plain `UNIQUE (key, period)` — valid DDL that applied cleanly and silently dropped the entire non-overlap guarantee. Migrations scaffolded before this change keep working: the SQL generator still renders the old stamped operations. +- **Fixed:** exclusion-constraint identity now includes the filter, so two `EXCLUDE` constraints over the same columns with different predicates coexist (both must be named) instead of the second silently replacing the first — the filtered-overlap case the API exists for. Re-declaring with the same filter still updates in place. +- **Fixed:** duplicate index and exclusion-constraint names are now rejected instead of producing a migration that fails at apply time (42P07) — or, for exclusion constraints, one that applies silently and leaves only the last constraint standing. Reusing an explicit name throws at the declaration; collisions between default names, or between a property-level and an entity-level declaration, throw during `migrations add`. +- **Fixed:** `CompositeIndexDefinition` equality compares array-valued provider annotations (operator classes, INCLUDE lists) by content instead of by reference. +- **Fixed:** index, temporal-constraint, and exclusion-constraint selectors that read a captured variable or static member instead of the lambda parameter (`x => captured.Name`) now throw at the declaration, naming the offending selector — previously they produced an unmatchable property path that failed much later with an opaque resolution error. +- **Fixed:** provider validation no longer inspects index operations this package did not create. The satellites previously swept every `CreateIndexOperation` in the migration, so a plain native `HasIndex` carrying a provider option outside the satellite's whitelist would have failed the entire `migrations add` — harmless with today's providers, but it tied your migrations to the exact index-option set each satellite knows about. +- **Fixed:** `DbOrder.Asc` now marks a column ascending, and combining it with `DbOrder.Desc` (or `NullsFirst` with `NullsLast`) throws instead of silently picking one. Repeating the same marker is still fine. +- **Fixed:** `Npgsql:IndexSortOrder`/`IndexNullSortOrder` are no longer forwarded onto complex indexes, and setting either now throws with a pointer to `DbOrder`. They duplicated what `DbOrder.Asc`/`Desc`/`NullsFirst`/`NullsLast` already express per column, giving one index two sources of truth for its sort options — with the annotation's half silently losing whenever the index rendered through this package's generator. +- **Fixed:** clustered-index combinations SQL Server rejects are now caught at `migrations add` rather than at apply time: a clustered index with `INCLUDE` columns, a clustered filtered index, two clustered complex indexes on one table, and — the common one — a clustered complex index on a table whose primary key already holds the clustered slot, which is the SQL Server default. +- **New:** `UseDataCompression(DataCompressionType)` on SQL Server complex indexes — the annotation was already forwarded but had no way to set it. + +## 5.0.1 + +- **Changed:** exclusion-constraint `ADD CONSTRAINT` DDL is now preceded by `DROP CONSTRAINT IF EXISTS`, so adopting a pre-existing hand-written constraint of the same name applies cleanly instead of failing with `42P07`. The standalone drop path also uses `IF EXISTS`. +- **Fixed:** renaming a table no longer drops and recreates the exclusion and temporal constraints it carries (the same normalization complex indexes already had). +- **Changed:** a name-only change to an exclusion constraint, temporal constraint, or temporal foreign key — including the implicit one when a table rename changes a default-derived name — now emits `ALTER TABLE … RENAME CONSTRAINT` instead of dropping and rebuilding. Dependent temporal foreign keys survive such renames untouched. +- **Tests:** the differ is now exercised against *real* model snapshots — generated as C#, compiled in-memory, and rebuilt exactly as `dotnet ef migrations add` does — guarding the whole feature set against snapshot round-trip churn. + +## 5.0.0 + +- **Fixed:** custom `DROP INDEX` operations are now ordered *before* the base migration operations. Previously, moving an index between a native `HasIndex` and a complex-index declaration scaffolded a migration that created the new index before dropping the same-named old one — colliding at apply time. +- **Fixed:** descending parts of expression indexes now render `DESC` (declarable via `ExpressionIndexBuilder.Descending()`). +- **Fixed:** integral provider-annotation values (e.g. fill factor) survive snapshot round-trips as `int` instead of degrading to `double`, which made generators drop them. +- **Changed:** property annotations are forwarded onto index operations through a provider **whitelist** instead of a blacklist. Column facets such as `Relational:ColumnName` no longer leak into scaffolded migrations, and the class of phantom drop/create churn caused by snapshot/code-model annotation asymmetries is closed for good. +- **Changed:** an indexed property that resolves to no column now throws at `migrations add` instead of silently dropping the index — unless it is a `ToJson()` member, which now resolves to a JSON expression index (PostgreSQL). +- **Changed:** two indexes over the same columns may now coexist when their filters differ (both must be named); re-declaring with the same filter still updates in place. +- **New:** entity-level `HasComplexIndex(x => x.Complex.Prop, …)` for single-column indexes, enabling multiple filtered indexes per column. +- **New:** `HasExclusionConstraint` — `EXCLUDE` constraints with `WHERE` predicates. +- **New:** typed LINQ expression indexes — `HasExpressionIndex(x => x.Email.ToLower())`. +- **New:** JSON member indexes for `ToJson()` complex properties. +- **New:** `NULLS FIRST`/`NULLS LAST` via `DbOrder.NullsFirst/NullsLast` and `ExpressionIndexBuilder.NullsFirst()/NullsLast()` (PostgreSQL). +- **New:** the **EFCore.ComplexIndexes.SqlServer** satellite — clustered, covering, online, fill-factor, and sort-in-tempdb options. +- **Changed:** `IncludeProperties(...)` entries are now resolved as property paths (complex members included) with verbatim column-name fallback — `IncludeProperties("Email.Value")` finds the real column. +- **Changed:** a name-only index change now emits `RenameIndexOperation` (PostgreSQL, SQL Server) instead of dropping and rebuilding the index; the core default remains drop + create for providers that cannot rename standalone. +- **Changed:** renaming a table no longer drops and recreates the complex indexes it carries. +- **Changed:** indexes requiring the custom PostgreSQL generator carry a loud sentinel column, so a missing `UseNpgsqlComplexIndexes()` fails at apply time with an actionable error instead of applying a silently wrong index. diff --git a/CLAUDE.md b/CLAUDE.md index fafff60..a51119b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ packs with no `id-token` permission at all — nothing in it can mint a token. ` already produced. So a reviewer is asked after the suite has passed rather than before, no token exists until they approve, and the artifact published is the one that was tested. `release` runs last with `contents: write` and no `id-token`: it creates the GitHub release if none exists (notes -taken from the README's `## What changed in ` section, so an empty extraction fails the +taken from the root `CHANGELOG.md`'s `## ` section, so an empty extraction fails the job instead of publishing a blank release) and attaches the SBOMs — their only durable home, since workflow artifacts expire after 90 days and nuget.org has no slot for them. Only the SBOMs are attached: nuget.org repository-signs packages on ingestion, so a `.nupkg` from there never matches @@ -116,7 +116,9 @@ below exist because ordinary review does not catch it. | Test class | Guards | |---|---| -| `ChangelogConsistencyTests` | The changelog lives in four files (root README + one per package). Asserts the shipped version is documented, no README runs ahead of `Directory.Build.props`, package changelogs are a subset of the root's, and sections are newest-first. | +| `ChangelogConsistencyTests` | The changelog lives in four files (root `CHANGELOG.md` + one per package). Asserts the shipped version is documented, no changelog runs ahead of `Directory.Build.props`, package changelogs are a subset of the root's, sections are newest-first, and no README has grown a duplicate copy. The `## ` heading style is asserted too, not merely parsed: `release.yml` matches it literally to extract the release notes, so a section demoted to `###` would read as documented here while the release job published a blank release. | +| `DocumentationLinkTests` | Relative markdown links resolve to real files and `#anchors` to real headings, and the packed READMEs under `src/` carry **no** relative links at all. nuget.org renders those READMEs with nothing to resolve a relative path against, so `[docs](docs/postgresql-indexes.md)` renders as a live link that 404s for every consumer arriving from the package page — while looking correct in the repository. | +| `DocumentationApiTests` | Every method name the user-facing docs cite exists in the public surface (calls into EF Core, Npgsql, DI and the BCL are an explicit allowlist, so everything else has to be ours), and no provider page cites another provider's *exclusive* API — exclusive meaning after subtracting what core and the other satellite also declare, since `IsUnique`/`HasName`/`IncludeProperties` exist on all three builders. Package validation already fails the pack on a removed public member, so what this adds is the rename fixed in source and forgotten in prose, and the name simply typed wrong. Changelogs are deliberately out of scope: an entry saying 5.0.0 shipped `HasExclusionConstraint` stays true after a later rename, and asserting over them would turn every rename into pressure to rewrite history. | | `PackagingConventionTests` | Every package ships its own README as `PackageReadmeFile`; `.targets` ship to both `build/` and `buildTransitive/`, reference a real `IDesignTimeServices` in their own assembly, and set `ForProvider` on satellites but not on core. Package validation is enabled and its baseline is the shipped version or the release before it, never older — the baseline is what `dotnet pack` diffs the public surface against (CP0002 on a removed member), and one left behind stops seeing API added since it. | | `ClaudeMdConsistencyTests` | This file. Prose cannot be asserted, so it checks the falsifiable parts: cited paths and file names exist, annotation keys under a prefix this repo owns are declared somewhere, `Type.Member` references resolve, and the stated size of the Npgsql whitelist matches it. Those are what a rename rots silently — and the count claim had already gone stale by two. | | `BuilderApiParityTests` | Every key in a satellite's annotation whitelist is reachable from a builder method. `SqlServer:DataCompression` sat whitelisted with no API for a full release; this catches that class of drift by invoking every builder extension and diffing the keys it sets. | @@ -189,8 +191,22 @@ This library fills a gap in EF Core 10.0 migrations: EF Core can model complex p Shipping projects live under `src/`, the test project under `test/`; the `.slnx` groups them into matching solution folders. Shared NuGet metadata and the package version live in the root `Directory.Build.props`, which still applies to every project beneath it. Each shipping project -carries its own `README.md`, packed as that package's NuGet landing page — keep the per-package -changelogs in sync with the root `README.md` when releasing. +carries its own `README.md`, packed as that package's NuGet landing page, and its own +`CHANGELOG.md`, which is not packed — keep those in sync with the root `CHANGELOG.md` when +releasing. + +### Documentation layout + +The root `README.md` is the landing page: what the package is, install, runtime wiring, the +provider-agnostic core API, and a table pointing at the rest. Provider-specific reference lives +under `docs/` (`postgresql-indexes.md`, `postgresql-constraints.md`, `sqlserver.md`) — the split +follows the seam, since PostgreSQL indexes go through the differ plus the custom generator while the +temporal and exclusion constraints are rendered as design-time `SqlOperation`s. + +The packed READMEs under `src/` are a separate audience and a separate constraint: nuget.org renders +them with no base to resolve against, so **every link in them must be an absolute GitHub URL**. They +are condensed on purpose and will overlap `docs/` — that duplication is the price of a package page +that stands alone, and `DocumentationLinkTests` guards only the part that fails silently. ### How it works end-to-end diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b37efd5..ecd3f75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,9 +59,16 @@ bug should be three lines, not a re-derived setup. - One concern per PR, with the reasoning in the description rather than only the diff. - Full suite green, including integration if you have Docker. -- User-visible changes get a changelog entry in the root [README.md](README.md) *and* in the - affected package READMEs under `src/`. `ChangelogConsistencyTests` enforces that the version being - shipped is documented. +- User-visible changes get a changelog entry in the root [CHANGELOG.md](CHANGELOG.md) *and* in the + affected packages' `CHANGELOG.md` under `src/`. `ChangelogConsistencyTests` enforces that the + version being shipped is documented, and that the heading style stays the one `release.yml` reads + to build the release notes. +- Provider-specific documentation belongs under [docs/](docs), not in the root README — it is the + landing page. The packed READMEs under `src/` must link out with **absolute** GitHub URLs, since + nuget.org cannot resolve a relative path; `DocumentationLinkTests` enforces both. +- Renaming a public API means updating the prose that names it, and a provider page documents only + what that provider can actually do. `DocumentationApiTests` enforces both; an example that calls + into EF Core, Npgsql or the BCL adds the method to its `ExternalApi` allowlist. - Match the surrounding style. Comments explain *why*, especially where behaviour is load-bearing and non-obvious. diff --git a/README.md b/README.md index 1eaaca8..d2917ec 100644 --- a/README.md +++ b/README.md @@ -131,441 +131,33 @@ builder.HasComplexCompositeIndex( Direction maps to EF Core's native `CreateIndexOperation.IsDescending`, so it is rendered by **every relational provider** (SQL Server, SQLite, PostgreSQL) — no extra wiring required. Re-declaring an index over the same columns updates its direction. -Markers of different kinds compose in any order; markers of the same kind do not — `DbOrder.Asc(DbOrder.Desc(x.A))` is a contradiction and throws. To control where nulls sort, see [null ordering](#per-column-null-ordering) (PostgreSQL only). +Markers of different kinds compose in any order; markers of the same kind do not — `DbOrder.Asc(DbOrder.Desc(x.A))` is a contradiction and throws. To control where nulls sort, see [null ordering](docs/postgresql-indexes.md#per-column-null-ordering) (PostgreSQL only). --- -## PostgreSQL +## Documentation -### Per-column null ordering +Provider-specific features live in their own pages: -`DbOrder.NullsFirst(...)` / `DbOrder.NullsLast(...)` control where nulls sort; the markers compose with `Desc`: - -```csharp -builder.HasComplexCompositeIndex( - x => new { x.Name, Reviewed = DbOrder.NullsLast(DbOrder.Desc(x.ReviewedAt)) }); -// CREATE INDEX ... ON ... (name, reviewed_at DESC NULLS LAST); -``` - -Null ordering has no slot on EF's native index operation, so these indexes render through the package's PostgreSQL SQL generator — they require the one-time [`UseNpgsqlComplexIndexes()`](#runtime-wiring--the-two-features-that-need-it) wiring, and the SQL Server differ rejects the markers (SQL Server has no `NULLS FIRST/LAST` syntax). - -### Index methods on a complex property - -Use the builder-callback overload to reach the PostgreSQL-specific options (GIN, GiST, BRIN, SP-GiST, Hash, operator classes, `INCLUDE`, concurrent creation, nulls-distinct): - -```csharp -builder.ComplexProperty(x => x.Payload, c => - c.Property(x => x.Json) - .HasComplexIndex(idx => idx - .UseGin() - .HasOperators("jsonb_path_ops")) -); -``` - -### Expression (functional) indexes - -> Requires [`UseNpgsqlComplexIndexes()`](#runtime-wiring--the-two-features-that-need-it). -> Available as an extension on `EntityTypeBuilder`, so it works on any entity — complex or not. - -**Each string is emitted verbatim** — there is no property-to-column resolution and no automatic quoting. Write the final SQL exactly as it should appear inside the index, referencing real column names. - -**Single expression:** - -```csharp -// CREATE INDEX "IX_person_lowerlastname" ON person ((lower(last_name))); -builder.HasExpressionIndex("lower(last_name)"); -``` - -**With unique / filter / explicit name:** - -```csharp -builder.HasExpressionIndex( - "lower(email)", - isUnique: true, - filter: "deleted_at IS NULL", - indexName: "ix_person_email_ci"); -``` - -**Multiple ordered parts + provider options (builder callback):** - -```csharp -builder.HasExpressionIndex(idx => idx - .Expression("country") // a plain column, written as raw SQL - .Expression("lower(email)") // a SQL expression - .IsUnique() - .HasFilter("deleted_at IS NULL") - .HasName("ix_person_country_email_ci")); -// CREATE UNIQUE INDEX "ix_person_country_email_ci" -// ON person ((country), (lower(email))) -// WHERE deleted_at IS NULL; -``` - -**Descending parts:** call `.Descending()` after any part to sort it descending: - -```csharp -builder.HasExpressionIndex(idx => idx - .Expression("created_at").Descending() - .Expression("lower(email)")); -// CREATE INDEX ... ON person ((created_at) DESC, (lower(email))); -``` - -**Full-text / JSONB with a GIN index:** - -```csharp -builder.HasExpressionIndex(idx => idx - .Expression("to_tsvector('english', body)") - .UseGin()); -// CREATE INDEX ... ON articles USING gin ((to_tsvector('english', body))); -``` - -**Covering expression index (`INCLUDE`):** - -```csharp -builder.HasExpressionIndex(idx => idx - .Expression("lower(email)") - .IsUnique() - .IncludeProperties("display_name")); -``` - -#### Quoting tip - -Strings are passed through untouched, so identifiers that need PostgreSQL quoting (e.g. PascalCase columns) must include the quotes yourself. C# raw string literals keep this readable: - -```csharp -// CREATE INDEX ... ON "People" ((lower("Email"))); -builder.HasExpressionIndex(""" lower("Email") """.Trim()); -``` - -### Typed (LINQ) expression indexes - -> Requires [`UseNpgsqlComplexIndexes()`](#runtime-wiring--the-two-features-that-need-it), like all expression indexes. - -Instead of raw SQL, pass a lambda — property paths stay symbolic and are resolved against the -finalized model at `migrations add` time, so `HasColumnName`, complex-property columns, and even -`ToJson()` members are honored automatically: - -```csharp -builder.HasExpressionIndex(x => x.Email.Value.ToLower(), isUnique: true); -// CREATE UNIQUE INDEX ... ON people ((lower("email"))); - -builder.HasExpressionIndex(x => (x.Nickname ?? x.FirstName) + " " + x.LastName); -// CREATE INDEX ... ON people (((coalesce("nickname", "first_name") || ' ') || "last_name")); -``` - -The supported subset is deliberately small and fails loudly: `ToLower`/`ToUpper`, `Trim`/`TrimStart`/`TrimEnd`, `Substring` (1-based conversion handled), `Replace`, `string.Length`, string concatenation (`+`), null coalescing (`??`), and constants (captured variables are evaluated and inlined invariant-culture). Anything else throws `NotSupportedException` **at declaration time** with a pointer to the raw-SQL overload. - -### JSON member indexes - -> Requires [`UseNpgsqlComplexIndexes()`](#runtime-wiring--the-two-features-that-need-it) — JSON member indexes are expression indexes under the hood. - -When a complex property is mapped to JSON with `ToJson()`, its members have no table columns — yet -the **same index declarations keep working**: the differ resolves them to `->>` -extraction expressions instead. Moving a value object between scalar columns and a JSON document -does not force you to rewrite its indexes: - -```csharp -builder.ComplexProperty(x => x.Name, c => c.ToJson("name")); - -// Entity level … -builder.HasComplexIndex(x => x.Name.ShortName, isUnique: true, indexName: "ux_employer_short_name"); -// … or property level, inside the complex property: -// c.Property(x => x.ShortName).HasComplexIndex(isUnique: true); - -// ALTER: CREATE UNIQUE INDEX "ux_employer_short_name" ON employers (("name" ->> 'ShortName')); -``` - -Nested complex types become `->` segments (`("profile" -> 'Address' ->> 'City')`), and -`HasJsonPropertyName` is honored. Members are extracted as **text**; for typed comparisons or -ordering semantics use `HasExpressionIndex` with an explicit cast. - -### Temporal `UNIQUE` constraints (`WITHOUT OVERLAPS`) — requires PostgreSQL 18 - -> No runtime wiring required — the DDL is rendered at design time into the migration itself. -> Available as an extension on `EntityTypeBuilder`, so it works on any entity — complex or not. - -PostgreSQL 18 introduced `WITHOUT OVERLAPS` for unique constraints — a long-requested feature for scheduling, booking, and versioning scenarios. Instead of only checking *"is this exact value already present?"*, the database enforces *"no two rows for the same key have overlapping time periods"*. - -```sql -ALTER TABLE bookings - ADD CONSTRAINT ak_bookings_room_period - UNIQUE (room_id, period WITHOUT OVERLAPS); -``` - -`HasTemporalConstraint` exposes this as a first-class EF Core API. You supply scalar key columns (the "group" — e.g. a room, a resource, an employee) and a period column (a [PostgreSQL range type](https://www.postgresql.org/docs/current/rangetypes.html) such as `daterange`, `tstzrange`, or `NpgsqlRange`): - -**Single key column:** - -```csharp -builder.HasTemporalConstraint( - keyColumns: b => b.RoomId, - period: b => b.ValidPeriod); -// ALTER TABLE "Bookings" ADD CONSTRAINT "AK_Bookings__RoomId_ValidPeriod" -// UNIQUE ("RoomId", "ValidPeriod" WITHOUT OVERLAPS); -``` - -**Composite key columns:** - -```csharp -builder.HasTemporalConstraint( - keyColumns: b => new { b.Facility, b.RoomId }, - period: b => b.ValidPeriod); -// UNIQUE ("Facility", "RoomId", "ValidPeriod" WITHOUT OVERLAPS) -``` - -**Explicit constraint name:** - -```csharp -builder.HasTemporalConstraint( - keyColumns: b => b.RoomId, - period: b => b.ValidPeriod, - name: "uk_room_no_overlap"); -``` - -#### How the period column is validated - -The migration differ validates the period property at migration-generation time (`dotnet ef migrations add`). It must be mapped to a PostgreSQL range or multirange store type (anything ending in `range` — e.g. `daterange`, `tstzrange`, `int4multirange`) or have a CLR type of `NpgsqlRange` / a multirange struct from `NpgsqlTypes`. Using an incompatible type such as `string`, `int`, or `DateOnly` throws an `InvalidOperationException` *before* any SQL is generated: - -``` -The temporal constraint period property 'Start' on entity 'Booking' does not appear to be a range or multirange type. Found CLR type 'DateTime' (store type: 'timestamp with time zone'). Expected NpgsqlRange, a PostgreSQL range/multirange column type, or a store type ending in 'range' (e.g., daterange, int4multirange). -``` - -The period column stays a plain mapped column — it is deliberately **not** part of an EF key, because EF Core forbids non-comparable range types in primary keys. Use a surrogate or scalar EF primary key for change tracking; the temporal constraint handles the non-overlap guarantee independently. - -#### `btree_gist` extension - -Temporal constraints over scalar key columns require the `btree_gist` PostgreSQL extension. The differ injects `CREATE EXTENSION IF NOT EXISTS btree_gist;` automatically when a temporal constraint is first added. You can take explicit control or opt out: - -```csharp -// Explicit: declare the extension yourself (Npgsql's own differ handles it) -modelBuilder.UseBtreeGist(); - -// Opt out: e.g. if the extension is provisioned out-of-band by your DBA -modelBuilder.SuppressTemporalExtensionAutoInjection(); -``` - -When `UseBtreeGist()` is present, automatic injection backs off to avoid a duplicate `CREATE EXTENSION` statement. - -#### Idempotency and renames - -Re-declaring a temporal constraint on the same key + period replaces the previous one. Removing `HasTemporalConstraint` from the model causes the differ to emit a `DROP CONSTRAINT` in the next migration (unless the table itself is being dropped). - -A change that only affects the **name** — whether you pass a new `name:` or rename the table, which -changes the default-derived name — emits `ALTER TABLE … RENAME CONSTRAINT` rather than dropping and -rebuilding the constraint, so dependent temporal foreign keys survive untouched. - -### Temporal foreign keys (`PERIOD`) — requires PostgreSQL 18 - -> No runtime wiring required — the `PERIOD` DDL is rendered at design time into the migration itself. - -`HasTemporalForeignKey` adds PostgreSQL 18 temporal referential integrity. The scalar key columns are matched by equality, and the dependent period must be fully covered by matching principal periods. - -A typical subscription/add-on model looks like this: - -```csharp -modelBuilder.Entity(b => -{ - // Principal side: PostgreSQL requires the referenced columns to have - // a temporal UNIQUE/PRIMARY KEY constraint with WITHOUT OVERLAPS. - b.HasTemporalConstraint( - keyColumns: x => x.SubscriptionId, - period: x => x.ValidDuring); -}); - -modelBuilder.Entity(b => -{ - b.HasTemporalForeignKey( - dependentKeyColumns: x => x.SubscriptionId, - dependentPeriod: x => x.ActiveDuring, - principalKeyColumns: x => x.SubscriptionId, - principalPeriod: x => x.ValidDuring, - name: "fk_addons_subscriptions_temporal" - ); -}); -``` - -Generated SQL: - -```sql -ALTER TABLE subscription_addons - ADD CONSTRAINT fk_addons_subscriptions_temporal - FOREIGN KEY (subscription_id, PERIOD active_during) - REFERENCES subscriptions (subscription_id, PERIOD valid_during); -``` - -Composite keys use anonymous types on both sides: - -```csharp -b.HasTemporalForeignKey( - dependentKeyColumns: x => new { x.TenantId, x.SubscriptionId }, - dependentPeriod: x => x.ActiveDuring, - principalKeyColumns: x => new { x.TenantId, x.SubscriptionId }, - principalPeriod: x => x.ValidDuring -); -``` - -#### Restrictions and validation - -- PostgreSQL 18+ only. -- Period columns must be PostgreSQL range or multirange columns (`daterange`, `tstzrange`, `NpgsqlRange`, etc.). -- The referenced principal columns must have a matching `HasTemporalConstraint` in the model. PostgreSQL requires a referenced temporal `UNIQUE`/`PRIMARY KEY` constraint with `WITHOUT OVERLAPS`. -- Temporal foreign keys emit `NO ACTION` referential actions. PostgreSQL does not support temporal FK `CASCADE`, `RESTRICT`, `SET NULL`, or `SET DEFAULT` actions. -- This API emits standalone database constraints; it does not try to model the temporal relationship as an EF navigation/relationship key. - -The standalone design is intentional. The period column remains a normal mapped property, not an EF key member. EF keys require key values suitable for change tracking, while Npgsql range values are not suitable EF key members; PostgreSQL enforces the temporal relationship independently at the database level. - -### Exclusion constraints (`EXCLUDE`) - -> No runtime wiring required — the DDL is rendered at design time into the migration itself. - -An exclusion constraint generalizes uniqueness: no two rows may satisfy all the per-element -comparisons at once. Its killer feature over `UNIQUE … WITHOUT OVERLAPS`: it accepts a **`WHERE` -predicate**. PostgreSQL's `ADD CONSTRAINT UNIQUE`/`PRIMARY KEY` grammar has never allowed one, so a -*filtered* overlap guarantee — "no overlapping periods per key, but ignore revoked/soft-deleted -rows" — can **only** be expressed as an EXCLUDE constraint. It also works on every supported -PostgreSQL version, not just 18+. - -**The scheduling shape** (equality keys + overlap column + predicate): - -```csharp -builder.HasExclusionConstraint( - equalityColumns: x => new { x.GranteeId, x.RoleId }, - overlapsColumn: x => x.Period, - filter: "revoked_at IS NULL", - name: "ex_role_grant_active_period"); -// ALTER TABLE role_grants ADD CONSTRAINT "ex_role_grant_active_period" -// EXCLUDE USING gist (grantee_id WITH =, role_id WITH =, period WITH &&) -// WHERE (revoked_at IS NULL); -``` - -**Full control** (arbitrary operators, expressions, method, deferrability): - -```csharp -builder.HasExclusionConstraint(ex => ex - .WithEquality(x => x.Slot.Resource) // complex-property members resolve to columns - .WithOverlaps(x => x.Slot.Period) - .WithExpression("lower(code)", "=") // verbatim SQL element - .UseMethod("gist") // the default - .HasFilter("deleted_at IS NULL") - .HasName("ex_booking_slot") - .IsDeferrable(initiallyDeferred: true)); -``` - -Selectors resolve complex-property members to their mapped columns, exactly like complex indexes. -Scalar equality elements under `gist` need the `btree_gist` extension — the differ injects -`CREATE EXTENSION IF NOT EXISTS btree_gist` automatically, shared with temporal constraints and -governed by the same `UseBtreeGist()` / `SuppressTemporalExtensionAutoInjection()` switches. -Constraint identity is the ordered elements **plus the filter** (operators are ignored, so -re-declaring updates them). Re-declaring the same elements with the same filter replaces the -constraint; the same elements with a *different* filter give you two coexisting partial -constraints — which is the point of the feature: - -```csharp -b.HasExclusionConstraint(x => x.GranteeId, x => x.Period, - filter: "revoked_at IS NULL", name: "ex_grant_active"); -b.HasExclusionConstraint(x => x.GranteeId, x => x.Period, - filter: "revoked_at IS NOT NULL", name: "ex_grant_revoked"); -``` - -Coexisting constraints must both be named: the default `EX_{table}_{columns}` name is derived from -the elements alone, so the two would collide in the database. Removing a declaration emits a -`DROP CONSTRAINT` in the next migration. - -**Adopting hand-written constraints:** the generated `ADD CONSTRAINT` is preceded by -`DROP CONSTRAINT IF EXISTS`, so declaring a constraint that already exists in the database under -the same name — e.g. raw `migrationBuilder.Sql(...)` DDL from an earlier migration — applies -cleanly on both fresh and existing databases. No hand-editing of the scaffolded migration needed; -just make sure the declared name matches the existing one. +| Page | Covers | +|---|---| +| **[PostgreSQL — indexes](docs/postgresql-indexes.md)** | Index methods (GIN, GiST, BRIN, SP-GiST, Hash), operator classes, `INCLUDE`, expression (functional) indexes in raw SQL and typed LINQ, JSON member indexes, `NULLS FIRST/LAST` | +| **[PostgreSQL — temporal and exclusion constraints](docs/postgresql-constraints.md)** | `UNIQUE … WITHOUT OVERLAPS`, temporal foreign keys (`PERIOD`), `EXCLUDE` constraints with `WHERE` predicates, the `btree_gist` extension | +| **[SQL Server](docs/sqlserver.md)** | Clustered/nonclustered, covering (`INCLUDE`), online builds, fill factor, sort-in-tempdb, data compression — and the declarations SQL Server rejects outright | -> **If a constraint re-appears in every scaffolded migration:** the differ compares the model -> against the *compiled* model snapshot, not the `…ModelSnapshot.cs` file. A constraint that is -> re-emitted on every `dotnet ef migrations add` even though the snapshot file contains its -> `CustomExclusion:Constraints` annotation means the compiled snapshot is stale — typically -> scaffolding with `--no-build`, or a migrations assembly (`MigrationsAssembly(...)`) resolved from -> an out-of-date build output. Rebuild the project that hosts the snapshot and re-scaffold. +Working on the package itself: [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and the quality +bar, and [CLAUDE.md](CLAUDE.md) is the architectural record — which seam a feature must use, why the +annotation flow is a whitelist, why operation ordering is load-bearing. --- -## SQL Server - -### Index options - -The **EFCore.ComplexIndexes.SqlServer** package brings the SQL Server option set to complex-property -indexes. Like the PostgreSQL GIN/GiST options, everything flows as native provider annotations that -SQL Server's own migrations SQL generator renders — **no runtime wiring at all**: - -```csharp -builder.ComplexProperty(x => x.Email, c => - c.Property(x => x.Value).HasColumnName("email")); - -builder.HasComplexIndex(x => x.Email.Value, ix => ix - .IsUnique() - .HasName("ux_person_email") - .IncludeProperties("name") // covering index - .IsCreatedOnline() // ONLINE = ON - .HasFillFactor(80)); -// CREATE UNIQUE INDEX [ux_person_email] ON [person] ([email]) -// INCLUDE ([name]) WITH (FILLFACTOR = 80, ONLINE = ON); -``` - -`IsClustered()`, `SortInTempDb()`, and `UseDataCompression(DataCompressionType.Page)` are also available. Filtered indexes (`filter:`) and -`DbOrder.Desc` work out of the box, since both ride on EF's native operation. Two deliberate -rejections with clear errors at `migrations add`: expression parts (SQL Server has no -expression-index DDL — model a persisted computed column and index that) and -`DbOrder.NullsFirst/NullsLast` (no such T-SQL syntax). - ---- +## Changelog -## What changed in 5.0.3 - -A packaging and documentation release. No behaviour changes to the differ or the generated SQL. - -- **Changed:** the EF Core dependency now declares an exclusive upper bound — `[10.0.0, 11.0.0)` on `Microsoft.EntityFrameworkCore.Abstractions` for the core package, and on the provider package for each satellite. This package subclasses `MigrationsModelDiffer` and calls internals EF marks as changeable without notice in any release, so an open-ended `>= 10.0.0` let NuGet resolve a future major where the differ can break — surfacing as a confusing `dotnet ef` failure in your project rather than anywhere visible from here. **Nothing changes for existing consumers:** NuGet resolves the lowest version in a range, so restore still picks 10.0.0. Adopting EF Core 11 will need a release that lifts the ceiling deliberately, once the differ has been tested against it. -- **New:** the public API is now fully documented, so IntelliSense no longer comes up empty on the fluent API, the annotation keys, `CompositeIndexDefinition`, or `IndexPartDefinition`. The shipped `.xml` had 64 holes in it; `TreatWarningsAsErrors` now keeps it complete. -- **Tests:** a consumer smoke test runs on every PR and on release. It packs the packages, installs them into a throwaway project created outside this repository, and runs a real `dotnet ef migrations add` — then asserts on the scaffolded content, because the failure it guards against is a migration that succeeds while silently omitting every index. Nothing previously exercised the delivery chain end to end: NuGet restore, the packaged `.targets` injecting the design-time attribute, EF's host discovering it, and the right differ winning. - -## What changed in 5.0.2 - -A review of the 5.0.1 tree turned up eleven issues. The first three produced migrations that -scaffolded *and applied* cleanly while being silently wrong; the rest turn late, obscure, or silent -failures into errors raised at the declaration or during `dotnet ef migrations add`. - -- **Fixed:** the design-time differ is now selected deterministically. A satellite package's `DesignTimeServicesReferenceAttribute` is scoped to its provider (`ForProvider`), and the core registration backs off when a satellite is present — previously, because the core package's attribute rides along transitively and EF resolves last-registration-wins, NuGet's restore order decided which differ ran. A solution referencing two satellites could hand one provider's model to the other provider's differ, silently dropping its index options. -- **Fixed:** temporal `UNIQUE … WITHOUT OVERLAPS` constraints and temporal foreign keys are now rendered at design time, like exclusion constraints, and no longer need `UseNpgsqlComplexIndexes()`. Previously a consumer without that wiring got a plain `UNIQUE (key, period)` — valid DDL that applied cleanly and silently dropped the entire non-overlap guarantee. Migrations scaffolded before this change keep working: the SQL generator still renders the old stamped operations. -- **Fixed:** exclusion-constraint identity now includes the filter, so two `EXCLUDE` constraints over the same columns with different predicates coexist (both must be named) instead of the second silently replacing the first — the filtered-overlap case the API exists for. Re-declaring with the same filter still updates in place. -- **Fixed:** duplicate index and exclusion-constraint names are now rejected instead of producing a migration that fails at apply time (42P07) — or, for exclusion constraints, one that applies silently and leaves only the last constraint standing. Reusing an explicit name throws at the declaration; collisions between default names, or between a property-level and an entity-level declaration, throw during `migrations add`. -- **Fixed:** `CompositeIndexDefinition` equality compares array-valued provider annotations (operator classes, INCLUDE lists) by content instead of by reference. -- **Fixed:** index, temporal-constraint, and exclusion-constraint selectors that read a captured variable or static member instead of the lambda parameter (`x => captured.Name`) now throw at the declaration, naming the offending selector — previously they produced an unmatchable property path that failed much later with an opaque resolution error. -- **Fixed:** provider validation no longer inspects index operations this package did not create. The satellites previously swept every `CreateIndexOperation` in the migration, so a plain native `HasIndex` carrying a provider option outside the satellite's whitelist would have failed the entire `migrations add` — harmless with today's providers, but it tied your migrations to the exact index-option set each satellite knows about. -- **Fixed:** `DbOrder.Asc` now marks a column ascending, and combining it with `DbOrder.Desc` (or `NullsFirst` with `NullsLast`) throws instead of silently picking one. Repeating the same marker is still fine. -- **Fixed:** `Npgsql:IndexSortOrder`/`IndexNullSortOrder` are no longer forwarded onto complex indexes, and setting either now throws with a pointer to `DbOrder`. They duplicated what `DbOrder.Asc`/`Desc`/`NullsFirst`/`NullsLast` already express per column, giving one index two sources of truth for its sort options — with the annotation's half silently losing whenever the index rendered through this package's generator. -- **Fixed:** clustered-index combinations SQL Server rejects are now caught at `migrations add` rather than at apply time: a clustered index with `INCLUDE` columns, a clustered filtered index, two clustered complex indexes on one table, and — the common one — a clustered complex index on a table whose primary key already holds the clustered slot, which is the SQL Server default. -- **New:** `UseDataCompression(DataCompressionType)` on SQL Server complex indexes — the annotation was already forwarded but had no way to set it. - -## What changed in 5.0.1 - -- **Changed:** exclusion-constraint `ADD CONSTRAINT` DDL is now preceded by `DROP CONSTRAINT IF EXISTS`, so adopting a pre-existing hand-written constraint of the same name applies cleanly instead of failing with `42P07`. The standalone drop path also uses `IF EXISTS`. -- **Fixed:** renaming a table no longer drops and recreates the exclusion and temporal constraints it carries (the same normalization complex indexes already had). -- **Changed:** a name-only change to an exclusion constraint, temporal constraint, or temporal foreign key — including the implicit one when a table rename changes a default-derived name — now emits `ALTER TABLE … RENAME CONSTRAINT` instead of dropping and rebuilding. Dependent temporal foreign keys survive such renames untouched. -- **Tests:** the differ is now exercised against *real* model snapshots — generated as C#, compiled in-memory, and rebuilt exactly as `dotnet ef migrations add` does — guarding the whole feature set against snapshot round-trip churn. - -## What changed in 5.0.0 - -- **Fixed:** custom `DROP INDEX` operations are now ordered *before* the base migration operations. Previously, moving an index between a native `HasIndex` and a complex-index declaration scaffolded a migration that created the new index before dropping the same-named old one — colliding at apply time. -- **Fixed:** descending parts of expression indexes now render `DESC` (declarable via `ExpressionIndexBuilder.Descending()`). -- **Fixed:** integral provider-annotation values (e.g. fill factor) survive snapshot round-trips as `int` instead of degrading to `double`, which made generators drop them. -- **Changed:** property annotations are forwarded onto index operations through a provider **whitelist** instead of a blacklist. Column facets such as `Relational:ColumnName` no longer leak into scaffolded migrations, and the class of phantom drop/create churn caused by snapshot/code-model annotation asymmetries is closed for good. -- **Changed:** an indexed property that resolves to no column now throws at `migrations add` instead of silently dropping the index — unless it is a `ToJson()` member, which now resolves to a JSON expression index (PostgreSQL). -- **Changed:** two indexes over the same columns may now coexist when their filters differ (both must be named); re-declaring with the same filter still updates in place. -- **New:** entity-level `HasComplexIndex(x => x.Complex.Prop, …)` for single-column indexes, enabling multiple filtered indexes per column. -- **New:** `HasExclusionConstraint` — `EXCLUDE` constraints with `WHERE` predicates (see above). -- **New:** typed LINQ expression indexes — `HasExpressionIndex(x => x.Email.ToLower())`. -- **New:** JSON member indexes for `ToJson()` complex properties. -- **New:** `NULLS FIRST`/`NULLS LAST` via `DbOrder.NullsFirst/NullsLast` and `ExpressionIndexBuilder.NullsFirst()/NullsLast()` (PostgreSQL). -- **New:** the **EFCore.ComplexIndexes.SqlServer** satellite — clustered, covering, online, fill-factor, and sort-in-tempdb options. -- **Changed:** `IncludeProperties(...)` entries are now resolved as property paths (complex members included) with verbatim column-name fallback — `IncludeProperties("Email.Value")` finds the real column. -- **Changed:** a name-only index change now emits `RenameIndexOperation` (PostgreSQL, SQL Server) instead of dropping and rebuilding the index; the core default remains drop + create for providers that cannot rename standalone. -- **Changed:** renaming a table no longer drops and recreates the complex indexes it carries. -- **Changed:** indexes requiring the custom PostgreSQL generator carry a loud sentinel column, so a missing `UseNpgsqlComplexIndexes()` fails at apply time with an actionable error instead of applying a silently wrong index. +[CHANGELOG.md](CHANGELOG.md) covers all three packages. Each package also carries its own, so NuGet +shows package-specific history: +[core](src/EFCore.ComplexIndexes/CHANGELOG.md), +[PostgreSQL](src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md), +[SQL Server](src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md). --- diff --git a/docs/postgresql-constraints.md b/docs/postgresql-constraints.md new file mode 100644 index 0000000..2f56991 --- /dev/null +++ b/docs/postgresql-constraints.md @@ -0,0 +1,212 @@ +# PostgreSQL — temporal and exclusion constraints + +Provided by the **EFCore.ComplexIndexes.PostgreSQL** package. None of these features need runtime +wiring: the DDL is rendered at design time into the migration itself. + +For index methods, expression indexes and JSON member indexes, see +[PostgreSQL — indexes](postgresql-indexes.md). + +## Temporal `UNIQUE` constraints (`WITHOUT OVERLAPS`) — requires PostgreSQL 18 + +> No runtime wiring required — the DDL is rendered at design time into the migration itself. +> Available as an extension on `EntityTypeBuilder`, so it works on any entity — complex or not. + +PostgreSQL 18 introduced `WITHOUT OVERLAPS` for unique constraints — a long-requested feature for scheduling, booking, and versioning scenarios. Instead of only checking *"is this exact value already present?"*, the database enforces *"no two rows for the same key have overlapping time periods"*. + +```sql +ALTER TABLE bookings + ADD CONSTRAINT ak_bookings_room_period + UNIQUE (room_id, period WITHOUT OVERLAPS); +``` + +`HasTemporalConstraint` exposes this as a first-class EF Core API. You supply scalar key columns (the "group" — e.g. a room, a resource, an employee) and a period column (a [PostgreSQL range type](https://www.postgresql.org/docs/current/rangetypes.html) such as `daterange`, `tstzrange`, or `NpgsqlRange`): + +**Single key column:** + +```csharp +builder.HasTemporalConstraint( + keyColumns: b => b.RoomId, + period: b => b.ValidPeriod); +// ALTER TABLE "Bookings" ADD CONSTRAINT "AK_Bookings__RoomId_ValidPeriod" +// UNIQUE ("RoomId", "ValidPeriod" WITHOUT OVERLAPS); +``` + +**Composite key columns:** + +```csharp +builder.HasTemporalConstraint( + keyColumns: b => new { b.Facility, b.RoomId }, + period: b => b.ValidPeriod); +// UNIQUE ("Facility", "RoomId", "ValidPeriod" WITHOUT OVERLAPS) +``` + +**Explicit constraint name:** + +```csharp +builder.HasTemporalConstraint( + keyColumns: b => b.RoomId, + period: b => b.ValidPeriod, + name: "uk_room_no_overlap"); +``` + +### How the period column is validated + +The migration differ validates the period property at migration-generation time (`dotnet ef migrations add`). It must be mapped to a PostgreSQL range or multirange store type (anything ending in `range` — e.g. `daterange`, `tstzrange`, `int4multirange`) or have a CLR type of `NpgsqlRange` / a multirange struct from `NpgsqlTypes`. Using an incompatible type such as `string`, `int`, or `DateOnly` throws an `InvalidOperationException` *before* any SQL is generated: + +``` +The temporal constraint period property 'Start' on entity 'Booking' does not appear to be a range or multirange type. Found CLR type 'DateTime' (store type: 'timestamp with time zone'). Expected NpgsqlRange, a PostgreSQL range/multirange column type, or a store type ending in 'range' (e.g., daterange, int4multirange). +``` + +The period column stays a plain mapped column — it is deliberately **not** part of an EF key, because EF Core forbids non-comparable range types in primary keys. Use a surrogate or scalar EF primary key for change tracking; the temporal constraint handles the non-overlap guarantee independently. + +### `btree_gist` extension + +Temporal constraints over scalar key columns require the `btree_gist` PostgreSQL extension. The differ injects `CREATE EXTENSION IF NOT EXISTS btree_gist;` automatically when a temporal constraint is first added. You can take explicit control or opt out: + +```csharp +// Explicit: declare the extension yourself (Npgsql's own differ handles it) +modelBuilder.UseBtreeGist(); + +// Opt out: e.g. if the extension is provisioned out-of-band by your DBA +modelBuilder.SuppressTemporalExtensionAutoInjection(); +``` + +When `UseBtreeGist()` is present, automatic injection backs off to avoid a duplicate `CREATE EXTENSION` statement. + +### Idempotency and renames + +Re-declaring a temporal constraint on the same key + period replaces the previous one. Removing `HasTemporalConstraint` from the model causes the differ to emit a `DROP CONSTRAINT` in the next migration (unless the table itself is being dropped). + +A change that only affects the **name** — whether you pass a new `name:` or rename the table, which +changes the default-derived name — emits `ALTER TABLE … RENAME CONSTRAINT` rather than dropping and +rebuilding the constraint, so dependent temporal foreign keys survive untouched. + +## Temporal foreign keys (`PERIOD`) — requires PostgreSQL 18 + +> No runtime wiring required — the `PERIOD` DDL is rendered at design time into the migration itself. + +`HasTemporalForeignKey` adds PostgreSQL 18 temporal referential integrity. The scalar key columns are matched by equality, and the dependent period must be fully covered by matching principal periods. + +A typical subscription/add-on model looks like this: + +```csharp +modelBuilder.Entity(b => +{ + // Principal side: PostgreSQL requires the referenced columns to have + // a temporal UNIQUE/PRIMARY KEY constraint with WITHOUT OVERLAPS. + b.HasTemporalConstraint( + keyColumns: x => x.SubscriptionId, + period: x => x.ValidDuring); +}); + +modelBuilder.Entity(b => +{ + b.HasTemporalForeignKey( + dependentKeyColumns: x => x.SubscriptionId, + dependentPeriod: x => x.ActiveDuring, + principalKeyColumns: x => x.SubscriptionId, + principalPeriod: x => x.ValidDuring, + name: "fk_addons_subscriptions_temporal" + ); +}); +``` + +Generated SQL: + +```sql +ALTER TABLE subscription_addons + ADD CONSTRAINT fk_addons_subscriptions_temporal + FOREIGN KEY (subscription_id, PERIOD active_during) + REFERENCES subscriptions (subscription_id, PERIOD valid_during); +``` + +Composite keys use anonymous types on both sides: + +```csharp +b.HasTemporalForeignKey( + dependentKeyColumns: x => new { x.TenantId, x.SubscriptionId }, + dependentPeriod: x => x.ActiveDuring, + principalKeyColumns: x => new { x.TenantId, x.SubscriptionId }, + principalPeriod: x => x.ValidDuring +); +``` + +### Restrictions and validation + +- PostgreSQL 18+ only. +- Period columns must be PostgreSQL range or multirange columns (`daterange`, `tstzrange`, `NpgsqlRange`, etc.). +- The referenced principal columns must have a matching `HasTemporalConstraint` in the model. PostgreSQL requires a referenced temporal `UNIQUE`/`PRIMARY KEY` constraint with `WITHOUT OVERLAPS`. +- Temporal foreign keys emit `NO ACTION` referential actions. PostgreSQL does not support temporal FK `CASCADE`, `RESTRICT`, `SET NULL`, or `SET DEFAULT` actions. +- This API emits standalone database constraints; it does not try to model the temporal relationship as an EF navigation/relationship key. + +The standalone design is intentional. The period column remains a normal mapped property, not an EF key member. EF keys require key values suitable for change tracking, while Npgsql range values are not suitable EF key members; PostgreSQL enforces the temporal relationship independently at the database level. + +## Exclusion constraints (`EXCLUDE`) + +> No runtime wiring required — the DDL is rendered at design time into the migration itself. + +An exclusion constraint generalizes uniqueness: no two rows may satisfy all the per-element +comparisons at once. Its killer feature over `UNIQUE … WITHOUT OVERLAPS`: it accepts a **`WHERE` +predicate**. PostgreSQL's `ADD CONSTRAINT UNIQUE`/`PRIMARY KEY` grammar has never allowed one, so a +*filtered* overlap guarantee — "no overlapping periods per key, but ignore revoked/soft-deleted +rows" — can **only** be expressed as an EXCLUDE constraint. It also works on every supported +PostgreSQL version, not just 18+. + +**The scheduling shape** (equality keys + overlap column + predicate): + +```csharp +builder.HasExclusionConstraint( + equalityColumns: x => new { x.GranteeId, x.RoleId }, + overlapsColumn: x => x.Period, + filter: "revoked_at IS NULL", + name: "ex_role_grant_active_period"); +// ALTER TABLE role_grants ADD CONSTRAINT "ex_role_grant_active_period" +// EXCLUDE USING gist (grantee_id WITH =, role_id WITH =, period WITH &&) +// WHERE (revoked_at IS NULL); +``` + +**Full control** (arbitrary operators, expressions, method, deferrability): + +```csharp +builder.HasExclusionConstraint(ex => ex + .WithEquality(x => x.Slot.Resource) // complex-property members resolve to columns + .WithOverlaps(x => x.Slot.Period) + .WithExpression("lower(code)", "=") // verbatim SQL element + .UseMethod("gist") // the default + .HasFilter("deleted_at IS NULL") + .HasName("ex_booking_slot") + .IsDeferrable(initiallyDeferred: true)); +``` + +Selectors resolve complex-property members to their mapped columns, exactly like complex indexes. +Scalar equality elements under `gist` need the `btree_gist` extension — the differ injects +`CREATE EXTENSION IF NOT EXISTS btree_gist` automatically, shared with temporal constraints and +governed by the same `UseBtreeGist()` / `SuppressTemporalExtensionAutoInjection()` switches. +Constraint identity is the ordered elements **plus the filter** (operators are ignored, so +re-declaring updates them). Re-declaring the same elements with the same filter replaces the +constraint; the same elements with a *different* filter give you two coexisting partial +constraints — which is the point of the feature: + +```csharp +b.HasExclusionConstraint(x => x.GranteeId, x => x.Period, + filter: "revoked_at IS NULL", name: "ex_grant_active"); +b.HasExclusionConstraint(x => x.GranteeId, x => x.Period, + filter: "revoked_at IS NOT NULL", name: "ex_grant_revoked"); +``` + +Coexisting constraints must both be named: the default `EX_{table}_{columns}` name is derived from +the elements alone, so the two would collide in the database. Removing a declaration emits a +`DROP CONSTRAINT` in the next migration. + +**Adopting hand-written constraints:** the generated `ADD CONSTRAINT` is preceded by +`DROP CONSTRAINT IF EXISTS`, so declaring a constraint that already exists in the database under +the same name — e.g. raw `migrationBuilder.Sql(...)` DDL from an earlier migration — applies +cleanly on both fresh and existing databases. No hand-editing of the scaffolded migration needed; +just make sure the declared name matches the existing one. + +> **If a constraint re-appears in every scaffolded migration:** the differ compares the model +> against the *compiled* model snapshot, not the `…ModelSnapshot.cs` file. A constraint that is +> re-emitted on every `dotnet ef migrations add` even though the snapshot file contains its +> `CustomExclusion:Constraints` annotation means the compiled snapshot is stale — typically +> scaffolding with `--no-build`, or a migrations assembly (`MigrationsAssembly(...)`) resolved from +> an out-of-date build output. Rebuild the project that hosts the snapshot and re-scaffold. diff --git a/docs/postgresql-indexes.md b/docs/postgresql-indexes.md new file mode 100644 index 0000000..b2f24c6 --- /dev/null +++ b/docs/postgresql-indexes.md @@ -0,0 +1,148 @@ +# PostgreSQL — indexes + +Provided by the **EFCore.ComplexIndexes.PostgreSQL** package, via +[Npgsql](https://www.npgsql.org/efcore/). The core package is included automatically. + +For temporal `UNIQUE` and `EXCLUDE` constraints, see +[PostgreSQL — temporal and exclusion constraints](postgresql-constraints.md). + +## Per-column null ordering + +`DbOrder.NullsFirst(...)` / `DbOrder.NullsLast(...)` control where nulls sort; the markers compose with `Desc`: + +```csharp +builder.HasComplexCompositeIndex( + x => new { x.Name, Reviewed = DbOrder.NullsLast(DbOrder.Desc(x.ReviewedAt)) }); +// CREATE INDEX ... ON ... (name, reviewed_at DESC NULLS LAST); +``` + +Null ordering has no slot on EF's native index operation, so these indexes render through the package's PostgreSQL SQL generator — they require the one-time [`UseNpgsqlComplexIndexes()`](../README.md#runtime-wiring--the-two-features-that-need-it) wiring, and the SQL Server differ rejects the markers (SQL Server has no `NULLS FIRST/LAST` syntax). + +## Index methods on a complex property + +Use the builder-callback overload to reach the PostgreSQL-specific options (GIN, GiST, BRIN, SP-GiST, Hash, operator classes, `INCLUDE`, concurrent creation, nulls-distinct): + +```csharp +builder.ComplexProperty(x => x.Payload, c => + c.Property(x => x.Json) + .HasComplexIndex(idx => idx + .UseGin() + .HasOperators("jsonb_path_ops")) +); +``` + +## Expression (functional) indexes + +> Requires [`UseNpgsqlComplexIndexes()`](../README.md#runtime-wiring--the-two-features-that-need-it). +> Available as an extension on `EntityTypeBuilder`, so it works on any entity — complex or not. + +**Each string is emitted verbatim** — there is no property-to-column resolution and no automatic quoting. Write the final SQL exactly as it should appear inside the index, referencing real column names. + +**Single expression:** + +```csharp +// CREATE INDEX "IX_person_lowerlastname" ON person ((lower(last_name))); +builder.HasExpressionIndex("lower(last_name)"); +``` + +**With unique / filter / explicit name:** + +```csharp +builder.HasExpressionIndex( + "lower(email)", + isUnique: true, + filter: "deleted_at IS NULL", + indexName: "ix_person_email_ci"); +``` + +**Multiple ordered parts + provider options (builder callback):** + +```csharp +builder.HasExpressionIndex(idx => idx + .Expression("country") // a plain column, written as raw SQL + .Expression("lower(email)") // a SQL expression + .IsUnique() + .HasFilter("deleted_at IS NULL") + .HasName("ix_person_country_email_ci")); +// CREATE UNIQUE INDEX "ix_person_country_email_ci" +// ON person ((country), (lower(email))) +// WHERE deleted_at IS NULL; +``` + +**Descending parts:** call `.Descending()` after any part to sort it descending: + +```csharp +builder.HasExpressionIndex(idx => idx + .Expression("created_at").Descending() + .Expression("lower(email)")); +// CREATE INDEX ... ON person ((created_at) DESC, (lower(email))); +``` + +**Full-text / JSONB with a GIN index:** + +```csharp +builder.HasExpressionIndex(idx => idx + .Expression("to_tsvector('english', body)") + .UseGin()); +// CREATE INDEX ... ON articles USING gin ((to_tsvector('english', body))); +``` + +**Covering expression index (`INCLUDE`):** + +```csharp +builder.HasExpressionIndex(idx => idx + .Expression("lower(email)") + .IsUnique() + .IncludeProperties("display_name")); +``` + +### Quoting tip + +Strings are passed through untouched, so identifiers that need PostgreSQL quoting (e.g. PascalCase columns) must include the quotes yourself. C# raw string literals keep this readable: + +```csharp +// CREATE INDEX ... ON "People" ((lower("Email"))); +builder.HasExpressionIndex(""" lower("Email") """.Trim()); +``` + +## Typed (LINQ) expression indexes + +> Requires [`UseNpgsqlComplexIndexes()`](../README.md#runtime-wiring--the-two-features-that-need-it), like all expression indexes. + +Instead of raw SQL, pass a lambda — property paths stay symbolic and are resolved against the +finalized model at `migrations add` time, so `HasColumnName`, complex-property columns, and even +`ToJson()` members are honored automatically: + +```csharp +builder.HasExpressionIndex(x => x.Email.Value.ToLower(), isUnique: true); +// CREATE UNIQUE INDEX ... ON people ((lower("email"))); + +builder.HasExpressionIndex(x => (x.Nickname ?? x.FirstName) + " " + x.LastName); +// CREATE INDEX ... ON people (((coalesce("nickname", "first_name") || ' ') || "last_name")); +``` + +The supported subset is deliberately small and fails loudly: `ToLower`/`ToUpper`, `Trim`/`TrimStart`/`TrimEnd`, `Substring` (1-based conversion handled), `Replace`, `string.Length`, string concatenation (`+`), null coalescing (`??`), and constants (captured variables are evaluated and inlined invariant-culture). Anything else throws `NotSupportedException` **at declaration time** with a pointer to the raw-SQL overload. + +## JSON member indexes + +> Requires [`UseNpgsqlComplexIndexes()`](../README.md#runtime-wiring--the-two-features-that-need-it) — JSON member indexes are expression indexes under the hood. + +When a complex property is mapped to JSON with `ToJson()`, its members have no table columns — yet +the **same index declarations keep working**: the differ resolves them to `->>` +extraction expressions instead. Moving a value object between scalar columns and a JSON document +does not force you to rewrite its indexes: + +```csharp +builder.ComplexProperty(x => x.Name, c => c.ToJson("name")); + +// Entity level … +builder.HasComplexIndex(x => x.Name.ShortName, isUnique: true, indexName: "ux_employer_short_name"); +// … or property level, inside the complex property: +// c.Property(x => x.ShortName).HasComplexIndex(isUnique: true); + +// ALTER: CREATE UNIQUE INDEX "ux_employer_short_name" ON employers (("name" ->> 'ShortName')); +``` + +Nested complex types become `->` segments (`("profile" -> 'Address' ->> 'City')`), and +`HasJsonPropertyName` is honored. Members are extracted as **text**; for typed comparisons or +ordering semantics use `HasExpressionIndex` with an explicit cast. diff --git a/docs/sqlserver.md b/docs/sqlserver.md new file mode 100644 index 0000000..ca0dcc3 --- /dev/null +++ b/docs/sqlserver.md @@ -0,0 +1,47 @@ +# SQL Server + +Provided by the **EFCore.ComplexIndexes.SqlServer** package. The core package is included +automatically, and there is **no runtime wiring at all** — every option flows as a native SQL Server +annotation that the provider's own migrations SQL generator renders. + +## Index options + +The **EFCore.ComplexIndexes.SqlServer** package brings the SQL Server option set to complex-property +indexes. Like the PostgreSQL GIN/GiST options, everything flows as native provider annotations that +SQL Server's own migrations SQL generator renders: + +```csharp +builder.ComplexProperty(x => x.Email, c => + c.Property(x => x.Value).HasColumnName("email")); + +builder.HasComplexIndex(x => x.Email.Value, ix => ix + .IsUnique() + .HasName("ux_person_email") + .IncludeProperties("name") // covering index + .IsCreatedOnline() // ONLINE = ON + .HasFillFactor(80)); +// CREATE UNIQUE INDEX [ux_person_email] ON [person] ([email]) +// INCLUDE ([name]) WITH (FILLFACTOR = 80, ONLINE = ON); +``` + +`IsClustered()`, `SortInTempDb()`, and `UseDataCompression(DataCompressionType.Page)` are also +available. Filtered indexes (`filter:`) and `DbOrder.Desc` work out of the box, since both ride on +EF's native operation. + +`IncludeProperties(...)` entries are resolved as property paths — complex members included — with a +verbatim column-name fallback, so `IncludeProperties("Email.Value")` finds the real column. + +## Deliberate rejections + +Declarations SQL Server cannot express fail at `dotnet ef migrations add` with a targeted error +rather than producing DDL that cannot apply: + +- **Expression parts** — SQL Server has no functional-index DDL. Model the expression as a persisted + computed column and index that column instead. +- **`DbOrder.NullsFirst` / `NullsLast`** — there is no `NULLS FIRST`/`NULLS LAST` in T-SQL. +- **Clustered index with `INCLUDE` columns** — included columns are a nonclustered-index feature; a + clustered index already stores every column. +- **Clustered filtered index** — filtered indexes must be nonclustered. +- **A second clustered index on a table** — including the usual case, where the primary key already + holds the clustered slot. SQL Server clusters the primary key unless you declare + `HasKey(...).IsClustered(false)`, so that is normally what a clustered complex index collides with. diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md b/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md new file mode 100644 index 0000000..40dfb4c --- /dev/null +++ b/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md @@ -0,0 +1,61 @@ +# EFCore.ComplexIndexes.PostgreSQL — changelog + +Changes to the PostgreSQL satellite, newest first. The +[root changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/CHANGELOG.md) +covers all three packages. + +## 5.0.3 + +- **Changed:** the `Npgsql.EntityFrameworkCore.PostgreSQL` dependency is now `[10.0.0, 11.0.0)`. This + differ extends Npgsql's own diff and generator internals, which carry no cross-major compatibility + promise. Nothing changes if you are on Npgsql 10: NuGet resolves the lowest version in a range. +- **New:** the public API is fully documented, including the differ and the custom SQL generator. +- **Tests:** the consumer smoke test scaffolds a real migration from this package as installed from a + NuGet feed, which is what verifies that the packaged `.targets` still registers the Npgsql differ. + +## 5.0.2 + +- **Fixed:** temporal `UNIQUE … WITHOUT OVERLAPS` constraints and temporal foreign keys are rendered + at design time and **no longer need `UseNpgsqlComplexIndexes()`**. Without that wiring the stock + Npgsql generator emitted a plain `UNIQUE (key, period)` — valid DDL that applied cleanly and + silently dropped the entire non-overlap guarantee. Migrations scaffolded before this change keep + working. +- **Fixed:** exclusion-constraint identity now includes the filter. Two `EXCLUDE` constraints over + the same columns with different predicates coexist instead of the second silently replacing the + first — the filtered-overlap case the API exists for. +- **Fixed:** duplicate exclusion-constraint names are rejected. Because every `ADD CONSTRAINT` is + preceded by `DROP CONSTRAINT IF EXISTS`, a reused name did not fail — the migration applied and + the second constraint quietly replaced the first. +- **Fixed:** the design-time differ is scoped to the Npgsql provider, so a solution that also + references another satellite can no longer hand a PostgreSQL model to the wrong differ. +- **Fixed:** `Npgsql:IndexSortOrder`/`IndexNullSortOrder` are no longer forwarded, and setting + either now throws with a pointer to `DbOrder`. They duplicated what `DbOrder.Asc`/`Desc`/ + `NullsFirst`/`NullsLast` already express per column, so an index could carry two conflicting + descriptions of its sort order with the annotation's half silently losing. +- **Fixed:** validation no longer inspects index operations this package did not create, so a plain + native `HasIndex` carrying provider options is left alone. + +## 5.0.1 + +- **Changed:** exclusion-constraint `ADD CONSTRAINT` DDL is preceded by `DROP CONSTRAINT IF EXISTS`, + so adopting a pre-existing hand-written constraint of the same name applies cleanly instead of + failing with `42P07`. +- **Fixed:** renaming a table no longer drops and recreates the exclusion and temporal constraints + it carries. +- **Changed:** a name-only change to an exclusion constraint, temporal constraint, or temporal + foreign key emits `ALTER TABLE … RENAME CONSTRAINT` instead of rebuilding. Dependent temporal + foreign keys survive untouched. + +## 5.0.0 + +- **New:** `HasExclusionConstraint` — `EXCLUDE` constraints with `WHERE` predicates. +- **New:** typed LINQ expression indexes — `HasExpressionIndex(x => x.Email.ToLower())`. +- **New:** JSON member indexes for `ToJson()` complex properties. +- **New:** `NULLS FIRST`/`NULLS LAST` via `DbOrder.NullsFirst/NullsLast` and + `ExpressionIndexBuilder.NullsFirst()/NullsLast()`. +- **Fixed:** descending parts of expression indexes render `DESC`. +- **Changed:** `IncludeProperties(...)` entries resolve as property paths (complex members included) + with verbatim column-name fallback. +- **Changed:** indexes requiring the custom generator carry a loud sentinel column, so a missing + `UseNpgsqlComplexIndexes()` fails at apply time with an actionable error instead of applying a + silently wrong index. diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/README.md b/src/EFCore.ComplexIndexes.PostgreSQL/README.md index 773c5b8..1da1af5 100644 --- a/src/EFCore.ComplexIndexes.PostgreSQL/README.md +++ b/src/EFCore.ComplexIndexes.PostgreSQL/README.md @@ -147,66 +147,18 @@ explicit control or `SuppressTemporalExtensionAutoInjection()` to opt out. --- -## Changelog +## Documentation -### 5.0.3 - -- **Changed:** the `Npgsql.EntityFrameworkCore.PostgreSQL` dependency is now `[10.0.0, 11.0.0)`. This - differ extends Npgsql's own diff and generator internals, which carry no cross-major compatibility - promise. Nothing changes if you are on Npgsql 10: NuGet resolves the lowest version in a range. -- **New:** the public API is fully documented, including the differ and the custom SQL generator. -- **Tests:** the consumer smoke test scaffolds a real migration from this package as installed from a - NuGet feed, which is what verifies that the packaged `.targets` still registers the Npgsql differ. - -### 5.0.2 - -- **Fixed:** temporal `UNIQUE … WITHOUT OVERLAPS` constraints and temporal foreign keys are rendered - at design time and **no longer need `UseNpgsqlComplexIndexes()`**. Without that wiring the stock - Npgsql generator emitted a plain `UNIQUE (key, period)` — valid DDL that applied cleanly and - silently dropped the entire non-overlap guarantee. Migrations scaffolded before this change keep - working. -- **Fixed:** exclusion-constraint identity now includes the filter. Two `EXCLUDE` constraints over - the same columns with different predicates coexist instead of the second silently replacing the - first — the filtered-overlap case the API exists for. -- **Fixed:** duplicate exclusion-constraint names are rejected. Because every `ADD CONSTRAINT` is - preceded by `DROP CONSTRAINT IF EXISTS`, a reused name did not fail — the migration applied and - the second constraint quietly replaced the first. -- **Fixed:** the design-time differ is scoped to the Npgsql provider, so a solution that also - references another satellite can no longer hand a PostgreSQL model to the wrong differ. -- **Fixed:** `Npgsql:IndexSortOrder`/`IndexNullSortOrder` are no longer forwarded, and setting - either now throws with a pointer to `DbOrder`. They duplicated what `DbOrder.Asc`/`Desc`/ - `NullsFirst`/`NullsLast` already express per column, so an index could carry two conflicting - descriptions of its sort order with the annotation's half silently losing. -- **Fixed:** validation no longer inspects index operations this package did not create, so a plain - native `HasIndex` carrying provider options is left alone. - -### 5.0.1 - -- **Changed:** exclusion-constraint `ADD CONSTRAINT` DDL is preceded by `DROP CONSTRAINT IF EXISTS`, - so adopting a pre-existing hand-written constraint of the same name applies cleanly instead of - failing with `42P07`. -- **Fixed:** renaming a table no longer drops and recreates the exclusion and temporal constraints - it carries. -- **Changed:** a name-only change to an exclusion constraint, temporal constraint, or temporal - foreign key emits `ALTER TABLE … RENAME CONSTRAINT` instead of rebuilding. Dependent temporal - foreign keys survive untouched. - -### 5.0.0 - -- **New:** `HasExclusionConstraint` — `EXCLUDE` constraints with `WHERE` predicates. -- **New:** typed LINQ expression indexes — `HasExpressionIndex(x => x.Email.ToLower())`. -- **New:** JSON member indexes for `ToJson()` complex properties. -- **New:** `NULLS FIRST`/`NULLS LAST` via `DbOrder.NullsFirst/NullsLast` and - `ExpressionIndexBuilder.NullsFirst()/NullsLast()`. -- **Fixed:** descending parts of expression indexes render `DESC`. -- **Changed:** `IncludeProperties(...)` entries resolve as property paths (complex members included) - with verbatim column-name fallback. -- **Changed:** indexes requiring the custom generator carry a loud sentinel column, so a missing - `UseNpgsqlComplexIndexes()` fails at apply time with an actionable error instead of applying a - silently wrong index. +- [PostgreSQL — indexes](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/postgresql-indexes.md) + — index methods, expression and typed LINQ indexes, JSON member indexes, null ordering +- [PostgreSQL — temporal and exclusion constraints](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/postgresql-constraints.md) + — `WITHOUT OVERLAPS`, temporal foreign keys, `EXCLUDE`, `btree_gist` +- [Full documentation](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes) ---- +## Changelog -Full documentation: **https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes** +[This package's changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md), +or the [root changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/CHANGELOG.md) +covering all three packages. MIT licensed. diff --git a/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md b/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md new file mode 100644 index 0000000..4297869 --- /dev/null +++ b/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md @@ -0,0 +1,37 @@ +# EFCore.ComplexIndexes.SqlServer — changelog + +Changes to the SQL Server satellite, newest first. The +[root changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/CHANGELOG.md) +covers all three packages. + +## 5.0.3 + +- **Changed:** the `Microsoft.EntityFrameworkCore.SqlServer` dependency is now `[10.0.0, 11.0.0)`. + This differ extends EF internals that carry no cross-major compatibility promise. Nothing changes + if you are on EF Core 10: NuGet resolves the lowest version in a range. +- **New:** the public API is fully documented. + +## 5.0.2 + +- **Fixed:** clustered-index combinations SQL Server rejects are caught at `migrations add` + instead of at apply time — clustered + `INCLUDE`, clustered + filter, and a second clustered + index on a table, which by default is any clustered complex index, since the primary key + holds the clustered slot unless declared otherwise. +- **New:** `UseDataCompression(DataCompressionType)`. The annotation was already forwarded but had + no way to set it. +- **Fixed:** the data-compression value survives the model-snapshot round trip. Stored as JSON the + enum flattened to a number, which SQL Server's generator reads back as null through + `DataCompressionType?`, dropping the option from the generated DDL. +- **Fixed:** the design-time differ is scoped to the SQL Server provider. Previously, in a solution + that also referenced the PostgreSQL satellite, NuGet's restore order decided which differ ran — and + the wrong one silently dropped every `SqlServer:*` index option. +- **Fixed:** validation no longer inspects index operations this package did not create, so a plain + native `HasIndex` carrying provider options is left alone. +- **Fixed:** duplicate index names are rejected at the declaration or during `migrations add` + instead of producing a migration that fails when applied. + +## 5.0.0 + +- **New:** the package — clustered, covering (`INCLUDE`), online-built, fill-factor, and + sort-in-tempdb options on complex-property indexes, plus clear errors for expression parts and + `NULLS FIRST`/`LAST`. diff --git a/src/EFCore.ComplexIndexes.SqlServer/README.md b/src/EFCore.ComplexIndexes.SqlServer/README.md index 4bdeb9c..df7c636 100644 --- a/src/EFCore.ComplexIndexes.SqlServer/README.md +++ b/src/EFCore.ComplexIndexes.SqlServer/README.md @@ -60,42 +60,16 @@ rather than producing DDL that cannot apply: --- -## Changelog +## Documentation -### 5.0.3 - -- **Changed:** the `Microsoft.EntityFrameworkCore.SqlServer` dependency is now `[10.0.0, 11.0.0)`. - This differ extends EF internals that carry no cross-major compatibility promise. Nothing changes - if you are on EF Core 10: NuGet resolves the lowest version in a range. -- **New:** the public API is fully documented. - -### 5.0.2 - -- **Fixed:** clustered-index combinations SQL Server rejects are caught at `migrations add` - instead of at apply time — clustered + `INCLUDE`, clustered + filter, and a second clustered - index on a table, which by default is any clustered complex index, since the primary key - holds the clustered slot unless declared otherwise. -- **New:** `UseDataCompression(DataCompressionType)`. The annotation was already forwarded but had - no way to set it. -- **Fixed:** the data-compression value survives the model-snapshot round trip. Stored as JSON the - enum flattened to a number, which SQL Server's generator reads back as null through - `DataCompressionType?`, dropping the option from the generated DDL. -- **Fixed:** the design-time differ is scoped to the SQL Server provider. Previously, in a solution - that also referenced the PostgreSQL satellite, NuGet's restore order decided which differ ran — and - the wrong one silently dropped every `SqlServer:*` index option. -- **Fixed:** validation no longer inspects index operations this package did not create, so a plain - native `HasIndex` carrying provider options is left alone. -- **Fixed:** duplicate index names are rejected at the declaration or during `migrations add` - instead of producing a migration that fails when applied. - -### 5.0.0 - -- **New:** the package — clustered, covering (`INCLUDE`), online-built, fill-factor, and - sort-in-tempdb options on complex-property indexes, plus clear errors for expression parts and - `NULLS FIRST`/`LAST`. +- [SQL Server](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/sqlserver.md) + — the full index-option reference and every deliberate rejection +- [Full documentation](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes) ---- +## Changelog -Full documentation: **https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes** +[This package's changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md), +or the [root changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/CHANGELOG.md) +covering all three packages. MIT licensed. diff --git a/src/EFCore.ComplexIndexes/CHANGELOG.md b/src/EFCore.ComplexIndexes/CHANGELOG.md new file mode 100644 index 0000000..17bbb7a --- /dev/null +++ b/src/EFCore.ComplexIndexes/CHANGELOG.md @@ -0,0 +1,67 @@ +# EFCore.ComplexIndexes — changelog + +Changes to the core package, newest first. The +[root changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/CHANGELOG.md) +covers all three packages. + +## 5.0.3 + +- **Changed:** the `Microsoft.EntityFrameworkCore.Abstractions` dependency is now `[10.0.0, 11.0.0)`. + This package subclasses `MigrationsModelDiffer` and calls internals EF marks as changeable without + notice in any release, so an open-ended floor let NuGet resolve a future major where the differ can + break — in your `dotnet ef` run, not anywhere visible from here. Nothing changes if you are on EF + Core 10: NuGet resolves the lowest version in a range, so restore still picks 10.0.0. +- **New:** the public API is fully documented. The shipped `.xml` had 64 gaps, so IntelliSense came up + empty on parts of the fluent API, the annotation keys, `CompositeIndexDefinition` and + `IndexPartDefinition`. +- **Tests:** a consumer smoke test packs the packages, installs them into a throwaway project outside + the repository, and runs a real `dotnet ef migrations add`, asserting on the scaffolded content — + the delivery chain (restore, `.targets` injection, design-time discovery, differ selection) was + previously only ever verified in pieces. + +## 5.0.2 + +- **Fixed:** the design-time migration differ is now selected deterministically when a provider + satellite is installed. This package's design-time attribute rides along transitively next to the + satellite's, and EF Core resolves last-registration-wins, so NuGet's restore order decided which + differ ran — and this one winning silently drops every provider-specific feature. +- **Fixed:** duplicate index names are rejected instead of producing a migration that fails at apply + time. Reusing an explicit name throws at the declaration; collisions between default names — + including a property-level and an entity-level index over the same column — throw during + `dotnet ef migrations add`. +- **Fixed:** selectors that read a captured variable or static member instead of the lambda + parameter (`x => captured.Name`) throw at the declaration, naming the offending selector. + Previously they produced an unmatchable property path that failed much later with an opaque + resolution error. +- **Fixed:** `DbOrder.Asc` now marks a column ascending, and combining it with `DbOrder.Desc` (or + `NullsFirst` with `NullsLast`) throws rather than silently picking one. Repeating a marker is fine. +- **Fixed:** provider validation runs through a scoped extension point instead of sweeping the + finished operation list, so satellites can no longer reject index operations this package did not + create. +- **Fixed:** array-valued provider annotations (operator classes, `INCLUDE` lists) compare by + content rather than by reference in `CompositeIndexDefinition`. + +## 5.0.1 + +- **Tests:** the differ is now exercised against *real* model snapshots — generated as C#, compiled + in-memory, and rebuilt exactly as `dotnet ef migrations add` does — guarding the whole feature set + against snapshot round-trip churn. + +## 5.0.0 + +- **Fixed:** custom `DROP INDEX` operations are ordered *before* the base migration operations. + Moving an index between a native `HasIndex` and a complex-index declaration previously scaffolded + a migration that created the new index before dropping the same-named old one. +- **Fixed:** integral provider-annotation values (e.g. fill factor) survive snapshot round-trips as + `int` instead of degrading to `double`, which made generators drop them. +- **Changed:** property annotations reach index operations through a provider **whitelist** instead + of a blacklist. Column facets such as `Relational:ColumnName` no longer leak into scaffolded + migrations, closing a class of phantom drop/create churn. +- **Changed:** an indexed property that resolves to no column throws at `migrations add` instead of + silently dropping the index. +- **Changed:** two indexes over the same columns may coexist when their filters differ (both must be + named); re-declaring with the same filter updates in place. +- **Changed:** a name-only index change emits `RenameIndexOperation` on providers that can rename + standalone; renaming a table no longer drops and recreates the complex indexes it carries. +- **New:** entity-level `HasComplexIndex(x => x.Complex.Prop, …)` for single-column indexes. +- **New:** per-column `ASC`/`DESC` via `DbOrder.Asc`/`DbOrder.Desc`. diff --git a/src/EFCore.ComplexIndexes/README.md b/src/EFCore.ComplexIndexes/README.md index f7c3395..129f9cf 100644 --- a/src/EFCore.ComplexIndexes/README.md +++ b/src/EFCore.ComplexIndexes/README.md @@ -79,73 +79,15 @@ see the PostgreSQL package. --- -## Changelog - -### 5.0.3 - -- **Changed:** the `Microsoft.EntityFrameworkCore.Abstractions` dependency is now `[10.0.0, 11.0.0)`. - This package subclasses `MigrationsModelDiffer` and calls internals EF marks as changeable without - notice in any release, so an open-ended floor let NuGet resolve a future major where the differ can - break — in your `dotnet ef` run, not anywhere visible from here. Nothing changes if you are on EF - Core 10: NuGet resolves the lowest version in a range, so restore still picks 10.0.0. -- **New:** the public API is fully documented. The shipped `.xml` had 64 gaps, so IntelliSense came up - empty on parts of the fluent API, the annotation keys, `CompositeIndexDefinition` and - `IndexPartDefinition`. -- **Tests:** a consumer smoke test packs the packages, installs them into a throwaway project outside - the repository, and runs a real `dotnet ef migrations add`, asserting on the scaffolded content — - the delivery chain (restore, `.targets` injection, design-time discovery, differ selection) was - previously only ever verified in pieces. - -### 5.0.2 - -- **Fixed:** the design-time migration differ is now selected deterministically when a provider - satellite is installed. This package's design-time attribute rides along transitively next to the - satellite's, and EF Core resolves last-registration-wins, so NuGet's restore order decided which - differ ran — and this one winning silently drops every provider-specific feature. -- **Fixed:** duplicate index names are rejected instead of producing a migration that fails at apply - time. Reusing an explicit name throws at the declaration; collisions between default names — - including a property-level and an entity-level index over the same column — throw during - `dotnet ef migrations add`. -- **Fixed:** selectors that read a captured variable or static member instead of the lambda - parameter (`x => captured.Name`) throw at the declaration, naming the offending selector. - Previously they produced an unmatchable property path that failed much later with an opaque - resolution error. -- **Fixed:** `DbOrder.Asc` now marks a column ascending, and combining it with `DbOrder.Desc` (or - `NullsFirst` with `NullsLast`) throws rather than silently picking one. Repeating a marker is fine. -- **Fixed:** provider validation runs through a scoped extension point instead of sweeping the - finished operation list, so satellites can no longer reject index operations this package did not - create. -- **Fixed:** array-valued provider annotations (operator classes, `INCLUDE` lists) compare by - content rather than by reference in `CompositeIndexDefinition`. - -### 5.0.1 - -- **Tests:** the differ is now exercised against *real* model snapshots — generated as C#, compiled - in-memory, and rebuilt exactly as `dotnet ef migrations add` does — guarding the whole feature set - against snapshot round-trip churn. - -### 5.0.0 - -- **Fixed:** custom `DROP INDEX` operations are ordered *before* the base migration operations. - Moving an index between a native `HasIndex` and a complex-index declaration previously scaffolded - a migration that created the new index before dropping the same-named old one. -- **Fixed:** integral provider-annotation values (e.g. fill factor) survive snapshot round-trips as - `int` instead of degrading to `double`, which made generators drop them. -- **Changed:** property annotations reach index operations through a provider **whitelist** instead - of a blacklist. Column facets such as `Relational:ColumnName` no longer leak into scaffolded - migrations, closing a class of phantom drop/create churn. -- **Changed:** an indexed property that resolves to no column throws at `migrations add` instead of - silently dropping the index. -- **Changed:** two indexes over the same columns may coexist when their filters differ (both must be - named); re-declaring with the same filter updates in place. -- **Changed:** a name-only index change emits `RenameIndexOperation` on providers that can rename - standalone; renaming a table no longer drops and recreates the complex indexes it carries. -- **New:** entity-level `HasComplexIndex(x => x.Complex.Prop, …)` for single-column indexes. -- **New:** per-column `ASC`/`DESC` via `DbOrder.Asc`/`DbOrder.Desc`. - ---- +## Documentation Full documentation, including every provider-specific feature: **https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes** +## Changelog + +[This package's changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/src/EFCore.ComplexIndexes/CHANGELOG.md), +or the [root changelog](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/CHANGELOG.md) +covering all three packages. + MIT licensed. diff --git a/test/EFCore.ComplexIndexes.Tests/ChangelogConsistencyTests.cs b/test/EFCore.ComplexIndexes.Tests/ChangelogConsistencyTests.cs index 11422d9..903b6b3 100644 --- a/test/EFCore.ComplexIndexes.Tests/ChangelogConsistencyTests.cs +++ b/test/EFCore.ComplexIndexes.Tests/ChangelogConsistencyTests.cs @@ -4,16 +4,22 @@ namespace EFCore.ComplexIndexes.Tests; /// -/// The changelog lives in four files — the root README plus one per shipping package — so that -/// NuGet shows package-specific history. Nothing about that arrangement keeps them in step, and a -/// release that updates three of the four is invisible until a user reads the stale one. +/// The changelog lives in four files — the root CHANGELOG.md plus one per shipping package — +/// so that NuGet shows package-specific history. Nothing about that arrangement keeps them in step, +/// and a release that updates three of the four is invisible until a user reads the stale one. /// +/// +/// The heading style is asserted, not merely parsed. release.yml extracts the release notes by +/// matching ## <version> literally in the root changelog and reading to the next +/// ##; a section demoted to ### would still read as documented here while the release +/// job published a blank release. Keeping the pattern strict is what ties the two together. +/// [TestClass] public class ChangelogConsistencyTests { - // Matches both changelog heading styles in use: "## What changed in 5.0.2" and "### 5.0.2". + // The one heading style: "## 5.0.2". Deliberately strict — see the remarks above. private static readonly Regex VersionHeading = - new(@"^\#{2,4} (?:.*\s)?(\d+\.\d+\.\d+)\s*$", RegexOptions.Multiline | RegexOptions.Compiled); + new(@"^\#\# (\d+\.\d+\.\d+)\s*$", RegexOptions.Multiline | RegexOptions.Compiled); private static Version PackageVersion => Version.Parse(XDocument.Load(RepositoryLayout.BuildProps) @@ -21,49 +27,64 @@ public class ChangelogConsistencyTests .Single() .Value); - private static List DocumentedVersions(string readme) => - [.. VersionHeading.Matches(File.ReadAllText(readme)).Select(m => Version.Parse(m.Groups[1].Value))]; + private static List DocumentedVersions(string changelog) => + [.. VersionHeading.Matches(File.ReadAllText(changelog)).Select(m => Version.Parse(m.Groups[1].Value))]; - [TestMethod(DisplayName = "The root README documents the version being shipped")] - public void Root_readme_documents_current_version() + [TestMethod(DisplayName = "Every shipping package carries its own changelog")] + public void Every_package_has_a_changelog() + { + var missing = RepositoryLayout.ShippingProjects + .Where(project => !File.Exists(project.Changelog)) + .Select(project => project.PackageId) + .ToList(); + + Assert.IsEmpty( + missing, + $"{string.Join(", ", missing)} has no CHANGELOG.md. Its README links to one on GitHub, so " + + "the link 404s for anyone arriving from nuget.org."); + } + + [TestMethod(DisplayName = "The root changelog documents the version being shipped")] + public void Root_changelog_documents_current_version() { var version = PackageVersion; Assert.Contains( version, - DocumentedVersions(RepositoryLayout.RootReadme), - $"Directory.Build.props ships {version}, but README.md has no '## What changed in {version}' section."); + DocumentedVersions(RepositoryLayout.RootChangelog), + $"Directory.Build.props ships {version}, but CHANGELOG.md has no '## {version}' section — " + + "which is also the text release.yml publishes as the release notes."); } - [TestMethod(DisplayName = "No README documents a version newer than the one being shipped")] - public void No_readme_runs_ahead_of_the_package_version() + [TestMethod(DisplayName = "No changelog documents a version newer than the one being shipped")] + public void No_changelog_runs_ahead_of_the_package_version() { var version = PackageVersion; - foreach (var readme in AllReadmes()) + foreach (var changelog in AllChangelogs()) { - var ahead = DocumentedVersions(readme).Where(v => v > version).ToList(); + var ahead = DocumentedVersions(changelog).Where(v => v > version).ToList(); Assert.IsEmpty( ahead, - $"{Describe(readme)} documents {string.Join(", ", ahead)}, which is newer than the " + $"{Describe(changelog)} documents {string.Join(", ", ahead)}, which is newer than the " + $"{version} in Directory.Build.props — the version bump was probably forgotten."); } } - [TestMethod(DisplayName = "Package changelogs only mention versions the root README also covers")] + [TestMethod(DisplayName = "Package changelogs only mention versions the root changelog also covers")] public void Package_changelogs_are_a_subset_of_the_root_changelog() { - var root = DocumentedVersions(RepositoryLayout.RootReadme).ToHashSet(); + var root = DocumentedVersions(RepositoryLayout.RootChangelog).ToHashSet(); - foreach (var project in RepositoryLayout.ShippingProjects) + foreach (var project in RepositoryLayout.ShippingProjects.Where(p => File.Exists(p.Changelog))) { - var unknown = DocumentedVersions(project.Readme).Where(v => !root.Contains(v)).ToList(); + var unknown = DocumentedVersions(project.Changelog).Where(v => !root.Contains(v)).ToList(); Assert.IsEmpty( unknown, - $"{project.PackageId}'s README documents {string.Join(", ", unknown)}, which the root " - + "README does not cover. Either the root changelog is missing an entry or the version is a typo. " + $"{project.PackageId}'s changelog documents {string.Join(", ", unknown)}, which the root " + + "CHANGELOG.md does not cover. Either the root changelog is missing an entry or the version is a typo. " + "(A package needing no entry for a release is fine — it just omits the section.)"); } } @@ -71,20 +92,50 @@ public void Package_changelogs_are_a_subset_of_the_root_changelog() [TestMethod(DisplayName = "Changelog sections are ordered newest first")] public void Changelog_sections_are_descending() { - foreach (var readme in AllReadmes()) + foreach (var changelog in AllChangelogs()) { - var documented = DocumentedVersions(readme); + var documented = DocumentedVersions(changelog); CollectionAssert.AreEqual( documented.OrderByDescending(v => v).ToList(), documented, - $"{Describe(readme)} lists changelog sections out of order: {string.Join(", ", documented)}."); + $"{Describe(changelog)} lists changelog sections out of order: {string.Join(", ", documented)}."); } } + /// + /// The READMEs are the landing pages now, not the changelog. A version section left behind in one + /// is a second copy nothing keeps in step — and the packed READMEs are what nuget.org renders, so + /// the stale copy is the one most consumers would read. + /// + [TestMethod(DisplayName = "No README carries a changelog of its own")] + public void Readmes_do_not_duplicate_the_changelog() + { + var offenders = AllReadmes() + .Where(readme => VersionHeading.IsMatch(File.ReadAllText(readme))) + .Select(Describe) + .ToList(); + + Assert.IsEmpty( + offenders, + $"{string.Join(", ", offenders)} contains '## ' changelog sections. The changelog " + + "moved to CHANGELOG.md; a copy left in a README drifts silently and, for the packed ones, " + + "drifts where consumers read it."); + } + + // A package changelog that is missing entirely is Every_package_has_a_changelog's finding, and its + // message is the actionable one. Reading it here too would bury that under three file-not-found + // crashes in tests that are asking a different question. + private static IEnumerable AllChangelogs() => + RepositoryLayout.ShippingProjects.Select(p => p.Changelog) + .Where(File.Exists) + .Prepend(RepositoryLayout.RootChangelog); + private static IEnumerable AllReadmes() => RepositoryLayout.ShippingProjects.Select(p => p.Readme).Prepend(RepositoryLayout.RootReadme); - private static string Describe(string readme) => - readme == RepositoryLayout.RootReadme ? "The root README" : $"{Path.GetFileName(Path.GetDirectoryName(readme))}'s README"; + private static string Describe(string path) => + Path.GetDirectoryName(path) == RepositoryLayout.Root + ? $"The root {Path.GetFileName(path)}" + : $"{Path.GetFileName(Path.GetDirectoryName(path))}'s {Path.GetFileName(path)}"; } diff --git a/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs b/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs new file mode 100644 index 0000000..8dd3c21 --- /dev/null +++ b/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs @@ -0,0 +1,238 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +namespace EFCore.ComplexIndexes.Tests; + +/// +/// The documentation is a set of promises about an API surface, and a promise about a method that +/// does not exist fails only in the reader's editor. Package validation already fails the pack when +/// a public member is removed (CP0002), so a deletion surfaces at release — but it surfaces as an +/// API-surface decision, and nothing then walks the prose. A rename fixed in the source and forgotten +/// in the docs, or a method name simply typed wrong, has nothing at all catching it. +/// +/// +/// +/// The second test guards a specific false promise. Expression indexes live in the PostgreSQL +/// satellite because SQL Server has no functional-index DDL — documenting `HasExpressionIndex` on a +/// SQL Server page would advertise something the provider cannot do, and the differ's rejection +/// (correct, deliberate) would read as a bug. Provider-exclusive means exclusive after subtracting +/// what core and the other satellite also declare: `IsUnique`, `HasName` and `IncludeProperties` +/// exist on all three builders and say nothing about scope. +/// +/// +/// The changelogs are deliberately out of scope. They are a historical record: an entry that says +/// 5.0.0 shipped `HasExclusionConstraint` stays true after a later rename, and asserting over them +/// would turn every rename into pressure to rewrite history. +/// +/// +[TestClass] +public class DocumentationApiTests +{ + /// + /// A method-call-shaped mention: HasComplexIndex(, in prose or in a snippet. The optional + /// type-argument list is not decoration — HasTemporalForeignKey<Subscription>(…) is + /// how every generic API in these docs is written, and without it the whole generic surface went + /// unchecked while the test reported green. + /// + private static readonly Regex Invocation = new(@"\b([A-Z][A-Za-z0-9]*)(?:<[^<>()]*>)?\(", RegexOptions.Compiled); + + private static readonly Regex InlineCode = new(@"`([^`\n]+)`", RegexOptions.Compiled); + + private static readonly Assembly Core = typeof(CustomMigrationsModelDiffer).Assembly; + private static readonly Assembly PostgreSql = typeof(PostgreSQL.NpgsqlComplexIndexMigrationsModelDiffer).Assembly; + private static readonly Assembly SqlServer = typeof(SqlServer.SqlServerComplexIndexMigrationsModelDiffer).Assembly; + + /// + /// Calls into EF Core, Npgsql, the BCL and DI that the examples legitimately make. Everything not + /// listed here has to be ours and has to exist — which is what makes the assertion mean anything. + /// A new external call in an example belongs here, and the failure names the exact token to add. + /// + private static readonly HashSet ExternalApi = new(StringComparer.Ordinal) + { + // EF Core + "ComplexProperty", "Property", "HasColumnName", "HasKey", "ToJson", "Entity", + "MigrationsAssembly", "UseInternalServiceProvider", + // Npgsql + "UseNpgsql", "AddEntityFrameworkNpgsql", + // Dependency injection + "ServiceCollection", "BuildServiceProvider", "AddDbContext", + // BCL + "ToLower", "Trim" + }; + + [TestMethod(DisplayName = "Every API the documentation names exists")] + public void Documented_api_exists() + { + var own = OwnMembers(Core, PostgreSql, SqlServer); + var missing = new List(); + + foreach (var page in DocumentationPages()) + foreach (var mention in ApiMentions(page).Where(name => !ExternalApi.Contains(name) && !own.Contains(name))) + missing.Add($"{Relative(page)} → {mention}()"); + + Assert.IsEmpty( + missing.Distinct(), + $"The documentation names API that does not exist: {string.Join(", ", missing.Distinct())}. " + + "Either it was renamed and the prose was not updated, or the name is a typo — both read as " + + "a working example until someone types it. If the call belongs to EF Core, Npgsql or the " + + "BCL, add it to ExternalApi instead."); + } + + [TestMethod(DisplayName = "A provider's pages name no other provider's exclusive API")] + public void Provider_pages_document_only_their_own_api() + { + var postgresOnly = Exclusive(PostgreSql, Core, SqlServer); + var sqlServerOnly = Exclusive(SqlServer, Core, PostgreSql); + + var misplaced = new List(); + + foreach (var page in DocumentationPages()) + { + var (foreign, provider) = ScopeOf(page) switch + { + Provider.PostgreSql => (sqlServerOnly, "SQL Server"), + Provider.SqlServer => (postgresOnly, "PostgreSQL"), + + // The core package's README documents the provider-agnostic surface only: a satellite + // API there promises something the package a reader installed does not contain. + Provider.Core => ([.. postgresOnly.Concat(sqlServerOnly)], "a satellite"), + + _ => (null, string.Empty) + }; + + if (foreign is null) + continue; + + foreach (var mention in ApiMentions(page).Where(foreign.Contains)) + misplaced.Add($"{Relative(page)} → {mention}() ({provider})"); + } + + Assert.IsEmpty( + misplaced.Distinct(), + $"Pages document another provider's API: {string.Join(", ", misplaced.Distinct())}. A reader " + + "on that page cannot call it, and the differ's refusal to render it will read as a bug " + + "rather than as the deliberate scoping it is."); + } + + private enum Provider { Unscoped, Core, PostgreSql, SqlServer } + + /// + /// The package READMEs are discovered, so a new satellite is scoped without touching this test. + /// Pages under docs/ are scoped by the provider token in their file name — the convention + /// the split established; a page named after neither documents both and is left unscoped. + /// + private static Provider ScopeOf(string page) + { + foreach (var project in RepositoryLayout.ShippingProjects) + if (page == project.Readme) + return project.PackageId switch + { + var id when id.EndsWith(".PostgreSQL", StringComparison.Ordinal) => Provider.PostgreSql, + var id when id.EndsWith(".SqlServer", StringComparison.Ordinal) => Provider.SqlServer, + _ => Provider.Core + }; + + var name = Path.GetFileNameWithoutExtension(page).ToLowerInvariant(); + + if (Path.GetDirectoryName(page) == RepositoryLayout.DocsDirectory) + return name switch + { + var n when n.Contains("postgresql") => Provider.PostgreSql, + var n when n.Contains("sqlserver") => Provider.SqlServer, + _ => Provider.Unscoped + }; + + return Provider.Unscoped; // the root README documents everything + } + + /// The user-facing documentation set. Changelogs excluded — see the remarks above. + private static IEnumerable DocumentationPages() + { + yield return RepositoryLayout.RootReadme; + + if (Directory.Exists(RepositoryLayout.DocsDirectory)) + foreach (var page in Directory.EnumerateFiles(RepositoryLayout.DocsDirectory, "*.md", SearchOption.AllDirectories)) + yield return page; + + foreach (var project in RepositoryLayout.ShippingProjects) + yield return project.Readme; + } + + /// + /// Names read from C# contexts only: inline code spans in prose, and the code lines of a + /// csharp fence with any trailing // comment cut off. The comments hold the + /// generated SQL and the sql fences are SQL outright — reading either would file + /// UNIQUE( and friends as missing API. + /// + private static IEnumerable ApiMentions(string page) + { + var fenceLanguage = (string?)null; + + foreach (var line in File.ReadLines(page)) + { + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) + { + fenceLanguage = fenceLanguage is null ? line.Trim().TrimStart('`').Trim().ToLowerInvariant() : null; + continue; + } + + if (fenceLanguage is null) + { + foreach (Match span in InlineCode.Matches(line)) + foreach (Match invocation in Invocation.Matches(span.Groups[1].Value)) + yield return invocation.Groups[1].Value; + + continue; + } + + if (fenceLanguage is not "csharp") + continue; + + var comment = line.IndexOf("//", StringComparison.Ordinal); + var code = comment >= 0 ? line[..comment] : line; + + foreach (Match invocation in Invocation.Matches(code)) + yield return invocation.Groups[1].Value; + } + } + + private static HashSet OwnMembers(params Assembly[] assemblies) + { + var names = new HashSet(StringComparer.Ordinal); + + foreach (var assembly in assemblies) + names.UnionWith(PublicNames(assembly)); + + return names; + } + + /// What only declares, once the others are subtracted. + private static HashSet Exclusive(Assembly assembly, params Assembly[] others) + { + var names = PublicNames(assembly); + names.ExceptWith(OwnMembers(others)); + + return names; + } + + private static HashSet PublicNames(Assembly assembly) + { + var names = new HashSet(StringComparer.Ordinal); + + foreach (var type in assembly.GetExportedTypes()) + { + names.Add(type.Name); + + // DeclaredOnly: an inherited member belongs to the assembly that declares it, or every + // satellite would "declare" the core builder's IsUnique and nothing would be exclusive. + foreach (var member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.DeclaredOnly)) + names.Add(member.Name); + } + + return names; + } + + private static string Relative(string path) => + Path.GetRelativePath(RepositoryLayout.Root, path).Replace(Path.DirectorySeparatorChar, '/'); +} diff --git a/test/EFCore.ComplexIndexes.Tests/DocumentationLinkTests.cs b/test/EFCore.ComplexIndexes.Tests/DocumentationLinkTests.cs new file mode 100644 index 0000000..168028f --- /dev/null +++ b/test/EFCore.ComplexIndexes.Tests/DocumentationLinkTests.cs @@ -0,0 +1,165 @@ +using System.Text.RegularExpressions; + +namespace EFCore.ComplexIndexes.Tests; + +/// +/// The documentation is split across the root README, docs/, and one packed README per +/// package, which means it is now held together by links — and a broken link is the one defect that +/// renders perfectly. Markdown does not resolve anything at write time, so a page renamed or a +/// section re-levelled leaves a live link that 404s, or an anchor that silently scrolls nowhere. +/// +/// +/// The packed READMEs carry a second, worse failure mode, which is why they are checked separately. +/// nuget.org renders PackageReadmeFile with no base URL to resolve against, so a relative +/// link that works in the repository is dead on the package page — for every consumer arriving the +/// way most consumers arrive. There is no warning at pack time and no way to see it without +/// publishing, so the rule here is absolute: those files link out with full URLs or not at all. +/// +[TestClass] +public class DocumentationLinkTests +{ + // [text](target) — inline links only. Reference-style definitions are not used in this repository. + private static readonly Regex MarkdownLink = new(@"\[[^\]]*\]\(([^)\s]+)(?:\s+""[^""]*"")?\)", RegexOptions.Compiled); + + private static readonly Regex Heading = new(@"^(#{1,6})\s+(.*?)\s*#*$", RegexOptions.Multiline | RegexOptions.Compiled); + + /// Markdown this repository authors: the root pages, docs/, and the packed READMEs. + private static IEnumerable AuthoredMarkdown() + { + foreach (var file in Directory.EnumerateFiles(RepositoryLayout.Root, "*.md", SearchOption.TopDirectoryOnly)) + yield return file; + + if (Directory.Exists(RepositoryLayout.DocsDirectory)) + foreach (var file in Directory.EnumerateFiles(RepositoryLayout.DocsDirectory, "*.md", SearchOption.AllDirectories)) + yield return file; + + // A missing file is ChangelogConsistencyTests' finding to report, with a message that says so. + // Reading it here would bury that behind a FileNotFoundException in four unrelated tests. + foreach (var project in RepositoryLayout.ShippingProjects) + { + yield return project.Readme; + + if (File.Exists(project.Changelog)) + yield return project.Changelog; + } + } + + [TestMethod(DisplayName = "Relative links point at files that exist")] + public void Relative_links_resolve() + { + var broken = new List(); + + foreach (var file in AuthoredMarkdown()) + { + foreach (var target in LinkTargets(file)) + { + var path = target.Split('#')[0]; + if (path.Length == 0) + continue; + + var resolved = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, Uri.UnescapeDataString(path))); + + if (!File.Exists(resolved) && !Directory.Exists(resolved)) + broken.Add($"{Relative(file)} → {target}"); + } + } + + Assert.IsEmpty( + broken, + $"Links point at files that do not exist: {string.Join(", ", broken)}. " + + "A page was moved or renamed and the links to it were not updated."); + } + + [TestMethod(DisplayName = "Anchors point at headings that exist")] + public void Anchors_resolve() + { + var broken = new List(); + + foreach (var file in AuthoredMarkdown()) + { + foreach (var target in LinkTargets(file)) + { + var parts = target.Split('#', 2); + if (parts.Length != 2 || parts[1].Length == 0) + continue; + + // An anchor into a file this repository does not author cannot be checked here. + var page = parts[0].Length == 0 + ? file + : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, Uri.UnescapeDataString(parts[0]))); + + if (!File.Exists(page) || !page.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!Anchors(page).Contains(parts[1])) + broken.Add($"{Relative(file)} → {target}"); + } + } + + Assert.IsEmpty( + broken, + $"Links point at anchors no heading produces: {string.Join(", ", broken)}. " + + "A heading was reworded or re-levelled; the link still renders, it just goes nowhere."); + } + + /// + /// The packed READMEs are rendered by nuget.org, which has no base to resolve a relative path + /// against. Nothing about packing or restoring notices, so the link is dead only where it + /// matters most. + /// + [TestMethod(DisplayName = "Packed READMEs link out with absolute URLs only")] + public void Packed_readmes_have_no_relative_links() + { + var relative = new List(); + + foreach (var project in RepositoryLayout.ShippingProjects) + foreach (var target in AllLinkTargets(project.Readme)) + if (!target.StartsWith('#') && !Uri.IsWellFormedUriString(target, UriKind.Absolute)) + relative.Add($"{project.PackageId}'s README → {target}"); + + Assert.IsEmpty( + relative, + $"Packed READMEs carry relative links: {string.Join(", ", relative)}. nuget.org renders " + + "PackageReadmeFile with no base URL, so these resolve to nothing for anyone arriving from " + + "the package page. Use the full https://github.com/... URL instead."); + } + + /// Every inline link target in the file, absolute ones included. + private static IEnumerable AllLinkTargets(string file) => + MarkdownLink.Matches(File.ReadAllText(file)).Select(match => match.Groups[1].Value); + + /// The subset this repository is responsible for resolving. + private static IEnumerable LinkTargets(string file) => + AllLinkTargets(file).Where(target => !Uri.IsWellFormedUriString(target, UriKind.Absolute)); + + /// + /// GitHub's heading slugs: lowercased, inline markdown stripped, everything but letters, digits, + /// spaces, hyphens and underscores removed, spaces to hyphens. Repeats get a numeric suffix, + /// which this repository has no need for and this deliberately does not model — a duplicate + /// heading would show up here as an unresolvable anchor rather than silently passing. + /// + private static HashSet Anchors(string file) + { + var anchors = new HashSet(StringComparer.Ordinal); + + foreach (Match heading in Heading.Matches(File.ReadAllText(file))) + { + var text = heading.Groups[2].Value; + + text = Regex.Replace(text, @"\[([^\]]*)\]\([^)]*\)", "$1"); // links keep their text + text = text.Replace("`", string.Empty).Replace("*", string.Empty); + + var slug = new string(text.ToLowerInvariant() + .Where(c => char.IsLetterOrDigit(c) || c is ' ' or '-' or '_') + .Select(c => c == ' ' ? '-' : c) + .ToArray()); + + anchors.Add(slug); + } + + return anchors; + } + + private static string Relative(string path) => + Path.GetRelativePath(RepositoryLayout.Root, path).Replace(Path.DirectorySeparatorChar, '/'); +} diff --git a/test/EFCore.ComplexIndexes.Tests/Harness/RepositoryLayout.cs b/test/EFCore.ComplexIndexes.Tests/Harness/RepositoryLayout.cs index b4d28be..6ae6b9b 100644 --- a/test/EFCore.ComplexIndexes.Tests/Harness/RepositoryLayout.cs +++ b/test/EFCore.ComplexIndexes.Tests/Harness/RepositoryLayout.cs @@ -21,6 +21,10 @@ internal static class RepositoryLayout public static string RootReadme => Path.Combine(Root, "README.md"); + public static string RootChangelog => Path.Combine(Root, "CHANGELOG.md"); + + public static string DocsDirectory => Path.Combine(Root, "docs"); + public static string BuildProps => Path.Combine(Root, "Directory.Build.props"); /// Every packable project under src/, discovered rather than hard-coded. @@ -36,6 +40,7 @@ public sealed record ShippingProject(string PackageId, string Directory) { public string ProjectFile => Path.Combine(Directory, $"{PackageId}.csproj"); public string Readme => Path.Combine(Directory, "README.md"); + public string Changelog => Path.Combine(Directory, "CHANGELOG.md"); public string Targets => Path.Combine(Directory, "build", $"{PackageId}.targets"); /// True for the provider satellites, false for the provider-agnostic core. diff --git a/test/EFCore.ComplexIndexes.Tests/PackagingConventionTests.cs b/test/EFCore.ComplexIndexes.Tests/PackagingConventionTests.cs index 36cad91..28c711a 100644 --- a/test/EFCore.ComplexIndexes.Tests/PackagingConventionTests.cs +++ b/test/EFCore.ComplexIndexes.Tests/PackagingConventionTests.cs @@ -210,9 +210,9 @@ public void Package_validation_baseline_is_the_shipped_version_or_the_release_be + "since the old baseline goes unvalidated."); } - // The root README's "## What changed in x.y.z" headings — the same source ChangelogConsistencyTests reads. + // The root CHANGELOG.md's "## x.y.z" headings — the same source ChangelogConsistencyTests reads. private static IEnumerable ChangelogVersions() => - Regex.Matches(File.ReadAllText(RepositoryLayout.RootReadme), @"^## What changed in (\d+\.\d+\.\d+)\s*$", RegexOptions.Multiline) + Regex.Matches(File.ReadAllText(RepositoryLayout.RootChangelog), @"^## (\d+\.\d+\.\d+)\s*$", RegexOptions.Multiline) .Select(match => Version.Parse(match.Groups[1].Value)); private static string ReleaseWorkflow =>