Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ covering only what changed for that package:
[PostgreSQL](src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md),
[SQL Server](src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md).

## 5.1.0

Small enhancements around the two seams, plus the silent failures found while planning the next
major: a pending-changes check that never saw this package, and two ways a declaration could vanish
from a migration without a word.

- **Fixed:** `HasDifferences` now reports changes to complex indexes, exclusion constraints and temporal constraints. EF Core's base implementation runs its own `Diff` rather than the `GetDifferences` this package overrides, so every check built on it reported "no changes" when only a declaration from this package had changed: `dotnet ef migrations has-pending-model-changes`, the pending-model-changes warning `Migrate()` raises since EF Core 9, and the snapshot check in `migrations remove`. A CI gate built on `has-pending-model-changes` may now fail where it previously passed — that is the gate working.
- **Fixed:** a complex index whose name matches a native `HasIndex` on the same table is now rejected at `dotnet ef migrations add`. The base differ emitted one and this package the other, neither seeing the other, so the migration scaffolded two `CREATE INDEX` statements under one name and failed when applied (42P07). Only the target model's native indexes are consulted, so an index *moving* between a native declaration and a complex one under the same name still diffs as before.
- **Fixed:** a provider option from the other satellite on a *property-level* complex index is now rejected at `migrations add`, like an entity-level one, instead of being dropped by the forwarding whitelist without a word — `.UseGin()` on a property-level index diffed by the SQL Server satellite scaffolded a plain B-tree. The PostgreSQL differ likewise rejects SQL Server options, which it previously passed through to a generator that ignored them.
- **New:** runtime registration of the differ. `Database.EnsureCreated()`, `GenerateCreateScript()` and the pending-model-changes check `Migrate()` performs run the context's *runtime* differ, which the design-time wiring never reaches — so `EnsureCreated()` created the tables and silently none of the complex indexes, and `Migrate()` never warned about one that was not scaffolded. `UseNpgsqlComplexIndexes()` now registers the PostgreSQL differ alongside the generator; SQL Server gets `UseSqlServerComplexIndexes()`; providers without a satellite get `UseComplexIndexes()` from the core package. Each has an `Add…ComplexIndexes` counterpart for a custom internal service provider. With a satellite installed, call the satellite's method only.
- **New:** whole-document JSON indexes on PostgreSQL. Pointing `HasComplexIndex` at a `ToJson()` complex property — or at a complex collection, which is always JSON — now indexes its `jsonb` container column, so `HasComplexIndex(x => x.Payload, ix => ix.UseGin().HasOperators("jsonb_path_ops"))` produces the idiomatic GIN index. Previously the path failed with "could not resolve property path", and complex collections could not be indexed at all. The container is a real column, so no runtime wiring is involved; a complex property nested inside the document resolves to a `->` extraction and renders like any expression index.
- **New:** `HasStorageParameter(name, value)` on PostgreSQL complex and expression indexes — `WITH (fillfactor=70)`, `WITH (fastupdate=false)`, and so on. Column indexes render through Npgsql's own generator; expression indexes through this package's, in the same clause position.
- **New:** `UseCollation(params string[])` on PostgreSQL complex and expression indexes — per-column index collations (`"Name" COLLATE "C"`), positional, with an empty entry leaving that column on its default. Independent of the column's own collation, which is never copied onto the index.
- **New:** the property-level `HasComplexIndex` overloads also exist on the non-generic `ComplexTypePropertyBuilder`, so a property configured by name (`c.Property("Value")`) or by type can carry a complex index.
- **Changed:** a complex index, exclusion constraint or temporal constraint declared on an entity type that is mapped to no table — typically the abstract base of a TPC hierarchy — now fails at `migrations add` instead of producing nothing without a word. Declarations on view-mapped and query-mapped types are still ignored.

## 5.0.3

A packaging and documentation release. No behaviour changes to the differ or the generated SQL.
Expand Down
41 changes: 38 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ that stands alone, and `DocumentationLinkTests` guards only the part that fails

Property-level annotations reach the `CreateIndexOperation` only through
`IsForwardedIndexAnnotation` (virtual on the core differ, default **nothing**; the Npgsql differ
whitelists exactly its five `Npgsql:*` index-option keys). Never revert to sweeping "everything
whitelists exactly its six `Npgsql:*` index-option keys, plus every key under the per-parameter
`Npgsql:StorageParameter:` prefix). The key an option is *stored* under can differ from the key the
provider generator *reads*: `ToOperationAnnotationName` maps `Npgsql:IndexCollation` to
`Relational:Collation` at stamping time, because the property-level API writes options onto the
property, where the relational key would be read as the column's collation. Never revert to sweeping "everything
except known keys": column facets (`Relational:ColumnName`, `Relational:ColumnType`, …) leaked into
scaffolded migrations that way, and snapshot/code-model asymmetries caused phantom drop/create
churn (see `PhantomIndexChurnTests`).
Expand Down Expand Up @@ -281,6 +285,19 @@ There are two distinct hook points, and it matters which one a feature uses:
- **Design-time** (`IDesignTimeServices` via the `.targets`-injected attribute) replaces `IMigrationsModelDiffer`. This runs during `dotnet ef migrations add` and is auto-wired — consumers do nothing.
- **Runtime** (`IMigrationsSqlGenerator`) converts operations to SQL when migrations are *applied*. This is **not** auto-wired; consumers opt in with `optionsBuilder.UseNpgsqlComplexIndexes()` (a `ReplaceService` helper).

Since 5.1.0 the runtime seam also carries the **differ**: `UseComplexIndexes()` (core),
`UseNpgsqlComplexIndexes()` and `UseSqlServerComplexIndexes()` replace `IMigrationsModelDiffer` in the
context's own service provider, because `EnsureCreated()`, `GenerateCreateScript()` and the
pending-model-changes check in `Migrate()` run *that* differ and never see the design-time attribute —
without it, `EnsureCreated()` creates the tables and silently none of the indexes. Design-time
selection is unaffected: EF's `AddDbContextDesignTimeServices` seeds the design-time collection with
the context's differ as a factory registration, and the `.targets` registration is appended after it
(`DesignTimeServiceRegistrationTests`). Related and easy to miss: `MigrationsModelDiffer.HasDifferences`
runs EF's protected `Diff`, not `GetDifferences`, so the core overrides it to route through
`GetDifferences` — otherwise `dotnet ef migrations has-pending-model-changes`, `Migrate()`'s
pending-changes warning and `migrations remove` all reported "no changes" for a complex-index-only
change (`PendingModelChangesTests`).

Anything that depends on the runtime seam silently degrades when a consumer forgets the wiring, so
**prefer rendering DDL at design time** (a `SqlOperation` baked into the migration) whenever the
statement can be built from resolved column names — that is why exclusion *and* temporal
Expand Down Expand Up @@ -317,7 +334,11 @@ differ let satellites resolve what the core cannot:
- `ResolveUnmappedPart` — a path with no table column; the Npgsql differ builds a JSON extraction
(`"col" -> 'A' ->> 'B'`) when the path traverses a `ToJson()` complex property, honoring
`HasJsonPropertyName`. Members extract as text — no automatic casts (text→timestamptz casts are
not IMMUTABLE and would blow up `CREATE INDEX`).
not IMMUTABLE and would blow up `CREATE INDEX`). A path that *ends* at the JSON-mapped complex
property — or at a complex collection, which is always JSON — resolves to the container column
as a plain **column** part (so a whole-document GIN needs no runtime wiring); a complex property
nested inside the document resolves to a `->` extraction yielding `jsonb`. A table-split complex
property stays unresolved: there is no single column to stand for it.
- `ResolveTemplatePart` — substitutes template placeholders with quoted columns or parenthesized
JSON extractions; core throws (identifier quoting is provider-specific).

Expand Down Expand Up @@ -365,7 +386,21 @@ policing those turns any provider index option the satellite doesn't happen to k
hard failure of the consumer's whole `migrations add` — for indexes that never touched this package.
The check has to exist because entity-level provider annotations reach the operation *unfiltered*
(only the property-level path goes through `IsForwardedIndexAnnotation`), so `.UseGin()` on a SQL
Server model is caught, while a native `HasIndex(...).HasMethod("gin")` is left alone.
Server model is caught, while a native `HasIndex(...).HasMethod("gin")` is left alone. The
property-level path used to be the loophole: the whitelist dropped the other satellite's options
without a word, so a property-level `.UseGin()` diffed by SQL Server applied as a plain B-tree.
Since 5.1.0 each satellite's `IsForwardedIndexAnnotation` also returns true for the *other*
provider's prefix (`Npgsql:` / `SqlServer:`) — forwarded solely so the same
`ValidateCreateIndexOperation` rejects it, which keeps one message for both declaration styles.

### Declarations on types with no table

An entity type mapped to no table — the abstract base of a TPC hierarchy is the usual one — used to
be skipped by every descriptor scan (`if (tableName is null) continue;`), so an index or constraint
declared there produced nothing: no DDL, no error. Since 5.1.0 the scans call
`ThrowIfDeclaredOnUnmappedType` when such a type carries declarations; the satellites use the same
helper for exclusion and temporal descriptors. Types mapped to a view, SQL query or function are
still skipped silently — an index on those is nothing this package could create.

### Key extension points

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>5.0.3</Version>
<Version>5.1.0</Version>
<Authors>CaffeinatedCoder</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ EF Core 8.0 introduced complex properties, but migration tooling doesn't automat
| Package | NuGet | Description |
|---|---|---|
| **EFCore.ComplexIndexes** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes/) | Core library — single-column, composite, unique, and filtered indexes on complex type properties. Works with any EF Core relational provider. |
| **EFCore.ComplexIndexes.PostgreSQL** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.PostgreSQL.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes.PostgreSQL/) | PostgreSQL extensions via [Npgsql](https://www.npgsql.org/efcore/) — adds GIN, GiST, BRIN, SP-GiST, and Hash index methods, operator classes, covering indexes (`INCLUDE`), concurrent creation, nulls-distinct control, `NULLS FIRST/LAST`, **expression (functional) indexes** (raw SQL and **typed LINQ**), **JSON member indexes**, **temporal `UNIQUE` constraints (`WITHOUT OVERLAPS`)**, and **exclusion constraints (`EXCLUDE`)**. |
| **EFCore.ComplexIndexes.PostgreSQL** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.PostgreSQL.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes.PostgreSQL/) | PostgreSQL extensions via [Npgsql](https://www.npgsql.org/efcore/) — adds GIN, GiST, BRIN, SP-GiST, and Hash index methods, operator classes, covering indexes (`INCLUDE`), concurrent creation, nulls-distinct control, per-column collation, storage parameters, `NULLS FIRST/LAST`, **expression (functional) indexes** (raw SQL and **typed LINQ**), **JSON member indexes**, **temporal `UNIQUE` constraints (`WITHOUT OVERLAPS`)**, and **exclusion constraints (`EXCLUDE`)**. |
| **EFCore.ComplexIndexes.SqlServer** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.SqlServer.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes.SqlServer/) | SQL Server extensions — clustered/nonclustered control, covering indexes (`INCLUDE`), online index builds, fill factor, sort-in-tempdb, and data compression on complex-property indexes. Rendered by the stock SQL Server generator; no runtime wiring. |

> **Which package do I need?**
Expand Down Expand Up @@ -81,6 +81,26 @@ var provider = new ServiceCollection()
.BuildServiceProvider();
```

### `EnsureCreated`, `GenerateCreateScript`, and the pending-changes check

Migrations go through the design-time differ, which the packages wire up automatically. Three things
use the **runtime** differ instead and never see that wiring: `Database.EnsureCreated()`,
`Database.GenerateCreateScript()`, and the pending-model-changes check `Migrate()` performs. Without a
runtime registration they run EF's stock differ, which cannot see this package's declarations —
`EnsureCreated()` creates the tables and silently none of the indexes, and `Migrate()` does not warn
about a complex index that was never scaffolded. Register the differ once, next to the provider:

| Provider | Call |
|---|---|
| PostgreSQL | `UseNpgsqlComplexIndexes()` — the same call as above; since 5.1.0 it registers the differ too |
| SQL Server | `UseSqlServerComplexIndexes()` |
| Any other provider (SQLite, …) | `UseComplexIndexes()` from the core package |

With a satellite installed, call only the satellite's method: the core differ would give
`EnsureCreated()` a schema without the satellite's features, such as exclusion constraints. Each call
has a counterpart for a custom internal service provider: `AddComplexIndexes()`,
`AddNpgsqlComplexIndexes()` and `AddSqlServerComplexIndexes()`.

---

## Core usage — any relational provider
Expand All @@ -94,6 +114,9 @@ builder.ComplexProperty(x => x.EmailAddress, c =>
);
```

The same overloads exist on the non-generic builder, so a property configured by name works too:
`c.Property("Value").HasComplexIndex()`.

A property-level declaration holds **one** index per property. To give the same column several
differently-filtered indexes (the classic soft-delete pattern), declare them at the **entity level**
— the selector reaches into complex properties, and each index needs its own explicit name:
Expand Down
9 changes: 6 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ remedy for those is to upgrade.

| Version | Supported |
|---|---|
| 5.0.x | ✅ |
| < 5.0 | ❌ |
| 5.1.x | ✅ |
| < 5.1 | ❌ |

### For how long

Expand Down Expand Up @@ -82,7 +82,10 @@ The package has **no runtime presence in your application's request path**. It r
1. **Design time** — a replacement `IMigrationsModelDiffer` invoked by `dotnet ef migrations add`.
It reads your model and emits migration operations.
2. **Migration apply time** — only for PostgreSQL expression indexes and `NULLS FIRST/LAST`
ordering, and only when a consumer opts in with `UseNpgsqlComplexIndexes()`.
ordering, and only when a consumer opts in with `UseNpgsqlComplexIndexes()`. Since 5.1.0 that
call (and `UseComplexIndexes()` / `UseSqlServerComplexIndexes()`) also registers the differ at
runtime, where `EnsureCreated()`, `GenerateCreateScript()` and `Migrate()`'s pending-model-changes
check run it — still against your own model, still opt-in.

Its inputs come from your own `OnModelCreating` code, not from user input. Anyone who can change
that code can already run arbitrary code in your build.
Expand Down
38 changes: 38 additions & 0 deletions docs/postgresql-indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ builder.ComplexProperty(x => x.Payload, c =>
);
```

Storage parameters render as `WITH (…)`; call `HasStorageParameter` once per parameter. Strings are
quoted, booleans render bare. Per-column collations are positional — an empty entry leaves that
column on its default:

```csharp
builder.HasComplexCompositeIndex(x => new { x.Name, x.Email.Value }, idx => idx
.UseCollation("C", "")
.HasStorageParameter("fillfactor", 70)
.HasStorageParameter("deduplicate_items", false));
// CREATE INDEX ... ON people ("Name" COLLATE "C", email) WITH (fillfactor=70, deduplicate_items=false);
```

The index collation is independent of the column's own `UseCollation` on the property, which is never
copied onto the index.

## Expression (functional) indexes

> Requires [`UseNpgsqlComplexIndexes()`](../README.md#runtime-wiring--the-two-features-that-need-it).
Expand Down Expand Up @@ -146,3 +161,26 @@ builder.HasComplexIndex(x => x.Name.ShortName, isUnique: true, indexName: "ux_em
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.

### Indexing the whole document

> No runtime wiring: the container is a real column, so the index renders through the stock generator.

Point the selector at the JSON-mapped complex property itself — or at a complex collection, which is
always JSON — and the index lands on the `jsonb` container column. The PostgreSQL idiom is a GIN
index, usually with `jsonb_path_ops`:

```csharp
builder.ComplexProperty(x => x.Payload, c => c.ToJson("payload"));
builder.ComplexCollection(x => x.Tags, c => c.ToJson("tags"));

builder.HasComplexIndex(x => x.Payload, ix => ix.UseGin().HasOperators("jsonb_path_ops"));
// CREATE INDEX "IX_orders_payload" ON orders USING gin (payload jsonb_path_ops);

builder.HasComplexIndex(x => x.Tags, ix => ix.UseGin());
// CREATE INDEX "IX_orders_tags" ON orders USING gin (tags);
```

A complex property *nested inside* the document has no column of its own and resolves to a `->`
extraction instead (`("payload" -> 'Address')`, yielding `jsonb`), so it is an expression index and
needs the runtime wiring like the member indexes above.
8 changes: 7 additions & 1 deletion docs/sqlserver.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# 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
automatically, and migrations need **no runtime wiring** — every option flows as a native SQL Server
annotation that the provider's own migrations SQL generator renders.

One optional call exists: `UseSqlServerComplexIndexes()` registers the differ at runtime, so
`Database.EnsureCreated()` and `GenerateCreateScript()` include the complex indexes and the
pending-model-changes check in `Migrate()` sees one that was never scaffolded. See
[the runtime wiring section](../README.md#ensurecreated-generatecreatescript-and-the-pending-changes-check)
in the root README.

## Index options

The **EFCore.ComplexIndexes.SqlServer** package brings the SQL Server option set to complex-property
Expand Down
Loading
Loading