Skip to content

Commit 9bedc20

Browse files
Add per-column sort direction support in composite indexes
- Introduced `DbOrder.Asc`/`DbOrder.Desc` to specify sort order for individual columns in composite indexes. - Updated `ResolvedIndexPart` and `IndexPartDefinition` to track sort direction. - Enhanced MigrationsModelDiffer to set `CreateIndexOperation.IsDescending`. - Preserved legacy `PropertyPaths` for all-ascending indexes to avoid migration snapshot churn. - Updated documentation and added tests for new functionality. - Incremented package version to 3.1.0.
1 parent d066d30 commit 9bedc20

11 files changed

Lines changed: 266 additions & 67 deletions

CLAUDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,30 @@ Shared NuGet metadata and the package version live in `Directory.Build.props`.
4949

5050
5. **PostgreSQL satellite** (`NpgsqlComplexIndexMigrationsModelDiffer.cs`) — Extends the core differ, validates Npgsql-specific annotations, and normalizes JSON element annotations before passing operations upstream.
5151

52+
### Two integration seams: design-time vs. runtime
53+
54+
There are two distinct hook points, and it matters which one a feature uses:
55+
56+
- **Design-time** (`IDesignTimeServices` via the `.targets`-injected attribute) replaces `IMigrationsModelDiffer`. This runs during `dotnet ef migrations add` and is auto-wired — consumers do nothing.
57+
- **Runtime** (`IMigrationsSqlGenerator`) converts operations to SQL when migrations are *applied*. This is **not** auto-wired; consumers opt in with `optionsBuilder.UseNpgsqlComplexIndexes()` (a `ReplaceService` helper).
58+
59+
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).
60+
61+
### Expression indexes (`HasExpressionIndex`)
62+
63+
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:
64+
65+
- The **entry point** `HasExpressionIndex` (on `EntityTypeBuilder<TEntity>`) lives in `EFCore.ComplexIndexes.PostgreSQL` (`NpgsqlExpressionIndexExtensions.cs`), as does its `ExpressionIndexBuilder`.
66+
- Core owns only the inert **plumbing**: the `IIndexExpression` seam (`SqlIndexExpression` ships today; a future LINQ add-on plugs in here), `IndexPartDefinition`/`ResolvedIndexPart`/`IndexPartsSerializer`, `CompositeIndexDefinition.Parts`, the differ's part-handling, and the `ComplexIndexStorage` helper satellites call to dedup-and-store definitions. None of it activates unless a satellite populates it.
67+
68+
Each column-list entry is a "part"; an index is an ordered list of parts. Strings are emitted verbatim (no property→column resolution).
69+
70+
`CreateIndexOperation.Columns` is a `string[]` of quoted identifiers with no slot for an expression, so:
71+
- The differ stamps the ordered, resolved parts onto the operation as the `CustomIndex:IndexParts` annotation (`ResolvedIndexPart` + `IndexPartsSerializer`), **only when a part is an expression** (column-only indexes are untouched).
72+
- `NpgsqlComplexIndexSqlGenerator` (extends `NpgsqlMigrationsSqlGenerator`) overrides `Generate(CreateIndexOperation, …)`: if that annotation is present it renders the full `CREATE INDEX` itself (column parts quoted, expression parts wrapped in parens, reusing the forwarded Npgsql annotations for `USING`/`INCLUDE`/`NULLS NOT DISTINCT`/etc.); otherwise it delegates to `base`. This requires the runtime `UseNpgsqlComplexIndexes()` wiring.
73+
74+
`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.
75+
5276
### Key extension points
5377

5478
- **Adding a new provider**: Subclass `CustomMigrationsModelDiffer`, implement `IDesignTimeServices` to replace the differ, and ship a `.targets` file that injects the attribute. See the PostgreSQL project for the exact pattern.
@@ -57,3 +81,7 @@ Shared NuGet metadata and the package version live in `Directory.Build.props`.
5781
### Expression path extraction
5882

5983
`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.
84+
85+
### Per-column sort direction (`DbOrder.Asc`/`DbOrder.Desc`)
86+
87+
`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) }`).

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>3.0.0</Version>
3+
<Version>3.1.0</Version>
44
<Authors>CaffeinatedCoder</Authors>
55
<PackageLicenseExpression>MIT</PackageLicenseExpression>
66
<GenerateDocumentationFile>true</GenerateDocumentationFile>

EFCore.ComplexIndexes.Tests/CompositeIndexSerializerTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,29 @@ public void Roundtrips_parts()
5757
Assert.IsTrue(deserialized[0].EffectiveParts[1].IsExpression);
5858
}
5959

60+
[TestMethod(DisplayName = "Roundtrips per-column descending direction")]
61+
public void Roundtrips_descending_direction()
62+
{
63+
var definitions = new List<CompositeIndexDefinition>
64+
{
65+
new()
66+
{
67+
Parts =
68+
[
69+
new IndexPartDefinition { PropertyPath = "Name" },
70+
new IndexPartDefinition { PropertyPath = "Created", Descending = true }
71+
]
72+
}
73+
};
74+
75+
var json = CompositeIndexSerializer.Serialize(definitions);
76+
var deserialized = CompositeIndexSerializer.Deserialize(json);
77+
78+
Assert.AreEqual(definitions[0], deserialized[0]);
79+
Assert.IsFalse(deserialized[0].EffectiveParts[0].Descending);
80+
Assert.IsTrue(deserialized[0].EffectiveParts[1].Descending);
81+
}
82+
6083
[TestMethod(DisplayName = "Legacy paths-only JSON still deserializes via EffectiveParts")]
6184
public void Legacy_paths_json_deserializes()
6285
{

EFCore.ComplexIndexes.Tests/MigrationModelDifferTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,47 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
360360
}
361361
}
362362

363+
// Composite index with a descending column
364+
private class ContextWithDescendingComposite(
365+
DbContextOptions<ContextWithDescendingComposite> options) : DbContext(options)
366+
{
367+
public DbSet<PersonV1> People => Set<PersonV1>();
368+
369+
protected override void OnModelCreating(ModelBuilder modelBuilder)
370+
{
371+
modelBuilder.Entity<PersonV1>(builder =>
372+
{
373+
builder.ToTable("person");
374+
builder.HasKey(x => x.Id);
375+
builder.Property(x => x.Name).HasColumnName("name");
376+
builder.ComplexProperty(x => x.EmailAddress, c => { c.Property(x => x.Value).HasColumnName("email_address"); });
377+
builder.HasComplexCompositeIndex(x => new { x.Name, Email = DbOrder.Desc(x.EmailAddress.Value) });
378+
});
379+
}
380+
}
381+
382+
[TestMethod(DisplayName = "Composite index sets per-column descending order")]
383+
public void Composite_index_sets_descending_order()
384+
{
385+
var target = BuildRelationalModel<ContextWithDescendingComposite>();
386+
var operations = GetDifferences(source: null, target: target);
387+
388+
var createIndex = Assert.ContainsSingle(operations.OfType<CreateIndexOperation>());
389+
Assert.IsTrue(createIndex.Columns.SequenceEqual(["name", "email_address"]));
390+
Assert.IsNotNull(createIndex.IsDescending);
391+
Assert.IsTrue(createIndex.IsDescending!.SequenceEqual([false, true]));
392+
}
393+
394+
[TestMethod(DisplayName = "All-ascending composite leaves IsDescending null")]
395+
public void Ascending_composite_leaves_isdescending_null()
396+
{
397+
var target = BuildRelationalModel<ContextV3>();
398+
var operations = GetDifferences(source: null, target: target);
399+
400+
var createIndex = Assert.ContainsSingle(operations.OfType<CreateIndexOperation>());
401+
Assert.IsNull(createIndex.IsDescending);
402+
}
403+
363404
// V4: expression index on a regular column
364405
private class ContextWithExpressionIndex(
365406
DbContextOptions<ContextWithExpressionIndex> options) : DbContext(options)

EFCore.ComplexIndexes.Tests/PathExtractionTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,29 @@ public void Extracts_deeply_nested_complex_property()
6262
Assert.IsTrue(expectedPaths.SequenceEqual(paths));
6363
}
6464

65+
[TestMethod(DisplayName = "Extracts per-column direction from DbOrder markers")]
66+
public void Extracts_direction_from_dborder_markers()
67+
{
68+
var parts = ComplexIndexExtensions
69+
.ExtractIndexParts<Person, object>(x => new { x.FirstName, Email = DbOrder.Desc(x.EmailAddress.Value) });
70+
71+
Assert.AreEqual("FirstName", parts[0].PropertyPath);
72+
Assert.IsFalse(parts[0].Descending);
73+
74+
Assert.AreEqual("EmailAddress.Value", parts[1].PropertyPath);
75+
Assert.IsTrue(parts[1].Descending);
76+
}
77+
78+
[TestMethod(DisplayName = "DbOrder markers do not affect extracted paths")]
79+
public void DbOrder_markers_do_not_affect_paths()
80+
{
81+
var paths = ComplexIndexExtensions
82+
.ExtractPropertyPaths<Person, object>(x => new { x.FirstName, Email = DbOrder.Desc(x.EmailAddress.Value) });
83+
84+
List<string> expectedPaths = ["FirstName", "EmailAddress.Value"];
85+
Assert.IsTrue(expectedPaths.SequenceEqual(paths));
86+
}
87+
6588
[TestMethod(DisplayName = "Throws for non anonymous type")]
6689
public void Throws_for_non_anonymous_type()
6790
{

0 commit comments

Comments
 (0)