This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Build
dotnet build EFCore.ComplexIndexes.slnx
# Test
dotnet test EFCore.ComplexIndexes.Tests/EFCore.ComplexIndexes.Tests.csproj
# Run a single test class
dotnet test --filter "ClassName=MigrationModelDifferTests"
# Run a single test method
dotnet test --filter "FullyQualifiedName~MigrationModelDifferTests.SingleIndex_IsCreated"
# Pack NuGet packages (also runs on build due to GeneratePackageOnBuild=true)
dotnet pack EFCore.ComplexIndexes/EFCore.ComplexIndexes.csprojTests run in parallel at the method level (Scope = ExecutionScope.MethodLevel).
This library fills a gap in EF Core 10.0 migrations: EF Core can model complex properties (value objects) but does not generate migration SQL for indexes on their nested columns. This library hooks into EF Core's design-time pipeline to produce correct CREATE INDEX / DROP INDEX SQL.
| Project | Purpose |
|---|---|
EFCore.ComplexIndexes |
Core library — provider-agnostic fluent API and migration differ |
EFCore.ComplexIndexes.PostgreSQL |
Satellite package — adds Npgsql-specific index methods (GIN, GiST, BRIN, Hash, SP-GiST) |
EFCore.ComplexIndexes.Tests |
MSTest suite covering path extraction, serialization, and migration diffing |
Shared NuGet metadata and the package version live in Directory.Build.props.
-
Fluent API (
ComplexIndexExtensions.cs) — User calls.HasComplexIndex(...)or.HasComplexCompositeIndex(x => new { x.Prop, x.Complex.Nested })inOnModelCreating. These methods store all index metadata as EF Core annotations on the property or entity. -
Annotation storage —
ComplexIndexAnnotations.csdefines the annotation key constants. Composite index definitions are JSON-serialized viaCompositeIndexSerializerand stored as a single annotation on the entity type. -
Design-time service injection — Each project ships a
.targetsfile (underbuild/) that injects aDesignTimeServicesReferenceAttributeinto the consuming assembly at compile time. EF Core's design-time host discovers this attribute and instantiates the customIDesignTimeServices, which replaces the defaultIMigrationsModelDiffer. -
Migration differ (
CustomMigrationsModelDiffer.cs) — ExtendsMigrationsModelDiffer. Duringdotnet ef migrations add, it recursively walks entity type annotations and complex type properties to find index annotations, resolves the actual database column names (respecting both convention-based naming likeOrigin_Sourceand explicitHasColumnNameoverrides), and emitsCreateIndexOperation/DropIndexOperation. -
PostgreSQL satellite (
NpgsqlComplexIndexMigrationsModelDiffer.cs) — Extends the core differ, validates Npgsql-specific annotations, and normalizes JSON element annotations before passing operations upstream.
There are two distinct hook points, and it matters which one a feature uses:
- Design-time (
IDesignTimeServicesvia the.targets-injected attribute) replacesIMigrationsModelDiffer. This runs duringdotnet ef migrations addand is auto-wired — consumers do nothing. - Runtime (
IMigrationsSqlGenerator) converts operations to SQL when migrations are applied. This is not auto-wired; consumers opt in withoptionsBuilder.UseNpgsqlComplexIndexes()(aReplaceServicehelper).
Most index metadata (GIN/operators/include/etc.) flows as real Npgsql annotation keys (Npgsql:IndexMethod, …) on the CreateIndexOperation, so Npgsql's own runtime SQL generator renders it — this package never touches SQL generation for those. Expression indexes are the exception (see below).
Expression indexes are provider-specific and deliberately live in the satellite, not core: PostgreSQL/SQLite render CREATE INDEX … ((expr)) natively, but SQL Server has no functional-index DDL (it models the same intent via persisted computed columns). Exposing the API in provider-agnostic core would be a false promise — a SQL Server consumer could call it and get a CreateIndexOperation with empty Columns that the stock generator can't render. So:
- The entry point
HasExpressionIndex(onEntityTypeBuilder<TEntity>) lives inEFCore.ComplexIndexes.PostgreSQL(NpgsqlExpressionIndexExtensions.cs), as does itsExpressionIndexBuilder. - Core owns only the inert plumbing: the
IIndexExpressionseam (SqlIndexExpressionships today; a future LINQ add-on plugs in here),IndexPartDefinition/ResolvedIndexPart/IndexPartsSerializer,CompositeIndexDefinition.Parts, the differ's part-handling, and theComplexIndexStoragehelper satellites call to dedup-and-store definitions. None of it activates unless a satellite populates it.
Each column-list entry is a "part"; an index is an ordered list of parts. Strings are emitted verbatim (no property→column resolution).
CreateIndexOperation.Columns is a string[] of quoted identifiers with no slot for an expression, so:
- The differ stamps the ordered, resolved parts onto the operation as the
CustomIndex:IndexPartsannotation (ResolvedIndexPart+IndexPartsSerializer), only when a part is an expression (column-only indexes are untouched). NpgsqlComplexIndexSqlGenerator(extendsNpgsqlMigrationsSqlGenerator) overridesGenerate(CreateIndexOperation, …): if that annotation is present it renders the fullCREATE INDEXitself (column parts quoted, expression parts wrapped in parens, reusing the forwarded Npgsql annotations forUSING/INCLUDE/NULLS NOT DISTINCT/etc.); otherwise it delegates tobase. This requires the runtimeUseNpgsqlComplexIndexes()wiring.
CompositeIndexDefinition carries the ordered parts additively via Parts (with EffectiveParts falling back to the legacy PropertyPaths field) so migration snapshots written before expression support still deserialize.
- Adding a new provider: Subclass
CustomMigrationsModelDiffer, implementIDesignTimeServicesto replace the differ, and ship a.targetsfile that injects the attribute. See the PostgreSQL project for the exact pattern. - New index options: Add constants to
ComplexIndexAnnotations.cs(orNpgsqlAnnotations.cs), expose them viaComplexIndexBuilder, and read them in the differ when constructingCreateIndexOperation.
ComplexIndexExtensions parses anonymous-type lambda expressions (x => new { x.Name, x.Address.City }) by recursively walking MemberExpression chains to produce dotted property paths. These paths are then matched against the EF Core metadata model to resolve column names.
DbOrder.Asc/Desc are identity marker functions; ExtractSinglePart peels them (and Convert boxing) off the expression in any order to record a Descending flag per part. Unlike expression indexes, descending columns are provider-agnostic and need no satellite work: the differ maps direction onto the native CreateIndexOperation.IsDescending (bool[]), which every relational provider renders. The differ leaves IsDescending null when all parts are ascending, so existing ascending indexes don't churn. To avoid snapshot churn, HasComplexCompositeIndex keeps writing the legacy PropertyPaths form when every column is ascending and only switches to the ordered Parts form when a descending column is present. Note: wrapping a member in DbOrder.Desc(...) makes it a method call, so C# requires naming it in the anonymous type (new { x.A, B = DbOrder.Desc(x.B) }).