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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# AGENTS.md

`CLAUDE.md` is the canonical source of repository instructions.

Before working in this repository, every coding agent that does not load `CLAUDE.md` automatically must read it in full and follow all of its guidance.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ covering only what changed for that package:
[PostgreSQL](src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md),
[SQL Server](src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md).

## 5.3.0

One fix, found the first time a 5.2.0 converter-member path met the model snapshot
it had just been scaffolded into.

- **Fixed:** a property path through a value converter (`x => x.Email.Value`) now resolves against a model snapshot as well as against the configured model. A snapshot persists a converted property as its provider type — `string`, on a property-bag type — and drops the converter, so the member check that guards `x.CreatedAt.Year` had nothing to check against and the path failed to resolve. The first `dotnet ef migrations add` succeeded, because the snapshot did not hold the path yet; everything that diffed the resulting snapshot then threw `Could not resolve property path 'Email.Value'` — the next `migrations add`, `has-pending-model-changes`, and `Migrate()`, whose pending-model-changes check runs the differ this package registers before applying anything. On a property-bag type the persisted scalar is now accepted for a path the configured model already validated; against a configured model the provider-type check is unchanged. Covers expression templates, column parts, composite parts, filter placeholders and PostgreSQL exclusion elements, at the top level and inside a complex type.

## 5.2.0

The features AuditOffice's review of its own workarounds asked for, in the order they pay off:
The features a consumer's review of its own workarounds asked for, in the order they pay off:
a validation for a failure that reports nothing, a read model so an application can check its
own obligations, filters that resolve property paths the way index parts already do, an amend
API, and typed filter predicates.
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,15 @@ carries on both sides of the diff, so placeholder filters never churn and need n
`ResolveProperty` (core, shared by every path walk) also unwraps one member of a converter-mapped
value object — `Email.Value` resolves to the `Email` column only when the property has a converter
and the member's type equals the converter's provider type; without that check `CreatedAt.Year`
would silently index the whole column.
would silently index the whole column. A model snapshot has neither the value object nor the
converter: it persists the property as its provider type (`string`) on a property-bag type, so the
member check has nothing to check against. On a property-bag type, for an indexer property without
a converter, the resolver therefore accepts the persisted scalar — the path was validated against
the configured model when the snapshot was scaffolded. This matters more than churn: the first
`migrations add` succeeds because the snapshot does not hold the path yet, and everything that
diffs the resulting snapshot fails — the next `migrations add`, `has-pending-model-changes`, and
`Migrate()`, whose pending-changes check throws by default since EF Core 9. `SnapshotRoundTripTests`
covers every place such a path can appear, at the top level and inside a complex type.

Typed filters (`NpgsqlTypedFilterExtensions`, PostgreSQL only) run
`NpgsqlLinqIndexTranslator.TranslatePredicate` at the declaration and store the resulting
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>5.2.0</Version>
<Version>5.3.0</Version>
<Authors>CaffeinatedCoder</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand Down Expand Up @@ -63,7 +63,7 @@
-->
<PropertyGroup>
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>5.1.0</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>5.2.0</PackageValidationBaselineVersion>
</PropertyGroup>

<!--
Expand Down
4 changes: 2 additions & 2 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.2.x | ✅ |
| < 5.2 | ❌ |
| 5.3.x | ✅ |
| < 5.3 | ❌ |

### For how long

Expand Down
10 changes: 10 additions & 0 deletions src/EFCore.ComplexIndexes/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ 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.3.0

- **Fixed:** a converter-member path (`x => x.Email.Value`) resolves against a model snapshot. The
snapshot persists the property as its provider type on a property-bag type and drops the
converter, so every diff whose source was the snapshot — the next `migrations add`,
`has-pending-model-changes`, `Migrate()`'s pending-model-changes check — threw
`Could not resolve property path 'Email.Value'`; only the first `migrations add` succeeded. On a
property-bag type the persisted scalar is accepted for a path the configured model already
validated; against a configured model the provider-type check is unchanged.

## 5.2.0

- **Changed:** a complex index name longer than the provider's identifier limit
Expand Down
24 changes: 19 additions & 5 deletions src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,10 @@ StoreObjectIdentifier storeObject
/// The member unwraps to the property when the property has a converter and the member's type
/// is the converter's provider type — the column holds exactly that member. Without the type
/// check, <c>CreatedAt.Year</c> would resolve to the whole column and index something other
/// than what was written.
/// than what was written. A model snapshot cannot be checked that way: it persists the
/// property as its provider type on a property-bag type and drops the converter, so there the
/// path — already validated against the configured model when it was scaffolded — unwraps to
/// the persisted scalar unconditionally.
/// </remarks>
/// <param name="typeBase">The entity or complex type the path starts from.</param>
/// <param name="dotPath">The path, e.g. <c>Address.City</c>.</param>
Expand Down Expand Up @@ -811,11 +814,22 @@ StoreObjectIdentifier storeObject

private static IProperty? FindConvertedMember(ITypeBase typeBase, string propertyName, string memberName)
{
var property = typeBase.FindProperty(propertyName);
var converter = property?.FindTypeMapping()?.Converter ?? property?.GetValueConverter();
if (property is null || converter is null)
var property = typeBase.FindProperty(propertyName);
if (property is null)
return null;

var converter = property.FindTypeMapping()?.Converter ?? property.GetValueConverter();
if (converter is null)
{
// A model snapshot rebuilds entity and complex types alike as property bags and persists
// a converted property as its provider type, without the converter — there is no member
// left to check. The path was validated against the configured model when the snapshot
// was scaffolded, so resolve it to the persisted scalar. Every diff whose source is the
// snapshot depends on this: the next `migrations add`, `has-pending-model-changes`, and
// the pending-changes check `Migrate()` runs before applying anything.
return typeBase.IsPropertyBag && property.IsIndexerProperty() ? property : null;
}

var memberType = property.ClrType.GetProperty(memberName, BindingFlags.Public | BindingFlags.Instance)?.PropertyType
?? property.ClrType.GetField(memberName, BindingFlags.Public | BindingFlags.Instance)?.FieldType;
if (memberType is null)
Expand Down Expand Up @@ -890,4 +904,4 @@ public override int GetHashCode()
}
}

#pragma warning restore EF1001
#pragma warning restore EF1001
10 changes: 9 additions & 1 deletion test/EFCore.ComplexIndexes.Tests/MutableApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,15 @@ private static string FilterOf(IEnumerable<MigrationOperation> operations, strin
[TestMethod(DisplayName = "The predicate is ANDed onto selected declarations, property-level and entity-level, once")]
public void Filters_are_amended_idempotently()
{
using var context = new AmendingContext(new DbContextOptionsBuilder<AmendingContext>().UseNpgsql(MigrationHarness.NpgsqlConnection).Options);
// Amended is instance state written from OnModelCreating, so this instance's OnModelCreating
// has to run. Contexts with equal options share an internal service provider and its model
// cache, and EF builds the runtime model from an already-cached design-time model without
// calling OnModelCreating again — which the sibling test, building this context's
// design-time model through the harness, does in parallel. A private provider starts empty.
using var context = new AmendingContext(new DbContextOptionsBuilder<AmendingContext>()
.UseNpgsql(MigrationHarness.NpgsqlConnection)
.EnableServiceProviderCaching(false)
.Options);
var grant = context.Model.FindEntityType(typeof(Grant))!;

// Two unique indexes and two constraints on the first pass; nothing on the second.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ private static List<string> ExclusionSql(IEnumerable<MigrationOperation> operati

private class EmptyContext(DbContextOptions options) : DbContext(options);

// The AuditOffice shape: overlap protection per (grantee, role), ignoring revoked grants.
// The motivating shape: overlap protection per (grantee, role), ignoring revoked grants.
private class RoleGrant
{
public int Id { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) =>
});
}

// The AuditOffice shape: property-level declaration inside the ToJson complex property.
// The motivating shape: property-level declaration inside the ToJson complex property.
private class PropertyLevelJsonIndexContext(DbContextOptions<PropertyLevelJsonIndexContext> options) : DbContext(options)
{
public DbSet<Employer> Employers => Set<Employer>();
Expand Down
7 changes: 6 additions & 1 deletion test/EFCore.ComplexIndexes.Tests/NpgsqlTypedFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,12 @@ public void Unsupported_construct_is_refused()
[TestMethod(DisplayName = "The typed amend calls translate and delegate")]
public void Typed_amend_calls()
{
using var context = new AmendingContext(new DbContextOptionsBuilder<AmendingContext>().UseNpgsql(MigrationHarness.NpgsqlConnection).Options);
// Amended is written from OnModelCreating; a private service provider guarantees it runs for this
// instance instead of EF reusing a model another test cached (see MutableApiTests).
using var context = new AmendingContext(new DbContextOptionsBuilder<AmendingContext>()
.UseNpgsql(MigrationHarness.NpgsqlConnection)
.EnableServiceProviderCaching(false)
.Options);
var grant = context.Model.FindEntityType(typeof(Grant))!;

Assert.AreEqual(2, context.Amended);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ public void Json_member_index_applies_and_enforces()
Sql("""INSERT INTO ig_employers ("Id", name) VALUES (3, '{"ShortName":"Globex","LegalName":"Globex GmbH"}')""");
}

// ── The AuditOffice regression: native HasIndex ⇄ HasComplexIndex round-trips cleanly ──
// ── The regression: native HasIndex ⇄ HasComplexIndex round-trips cleanly ──

private class EmailAddress
{
Expand Down
89 changes: 89 additions & 0 deletions test/EFCore.ComplexIndexes.Tests/SnapshotRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,51 @@ public void Complex_index_roundtrip_is_noop()
Assert.IsEmpty(operations, string.Join("\n", operations.Select(o => o.GetType().Name)));
}

[TestMethod(DisplayName = "Converter-member paths survive the snapshot round trip without churn")]
public void Converter_member_roundtrip_is_noop()
{
var source = BuildModelViaSnapshot<RoundTripConverterContext>();

// The snapshot's premise: the value object is gone, only its provider type and column remain.
var entity = source.Model.GetEntityTypes().Single();
var email = entity.FindProperty(nameof(RoundTripAccount.Email))!;
var handle = entity.FindComplexProperty(nameof(RoundTripAccount.Profile))!.ComplexType.FindProperty(nameof(RoundTripProfile.Handle))!;

foreach (var property in new[] { email, handle })
{
Assert.AreEqual(typeof(string), property.ClrType, property.Name);
Assert.IsTrue(property.DeclaringType.IsPropertyBag, property.Name);
Assert.IsTrue(property.IsIndexerProperty(), property.Name);
Assert.IsNull(property.FindTypeMapping()?.Converter ?? property.GetValueConverter(), property.Name);
}

var operations = GetDifferences(source, BuildLiveModel<RoundTripConverterContext>());

Assert.IsEmpty(operations, string.Join("\n", operations.Select(o => o.GetType().Name)));
}

[TestMethod(DisplayName = "Migrate's runtime differ accepts a snapshot with converter-member paths")]
public void Runtime_differ_accepts_converter_member_snapshot()
{
var source = BuildModelViaSnapshot<RoundTripConverterContext>();

using var provider = new ServiceCollection()
.AddEntityFrameworkNpgsql()
.AddNpgsqlComplexIndexes()
.BuildServiceProvider();
using var context = new EmptyContext(
new DbContextOptionsBuilder()
.UseNpgsql("Host=localhost;Database=test")
.UseInternalServiceProvider(provider)
.Options);
var differ = context.GetService<IMigrationsModelDiffer>();

Assert.IsInstanceOfType<NpgsqlComplexIndexMigrationsModelDiffer>(differ);
Assert.IsFalse(
differ.HasDifferences(source, BuildLiveModel<RoundTripConverterContext>()),
"Migrate() uses this runtime pending-model-changes check before applying migrations.");
}

[TestMethod(DisplayName = "A filter change against the snapshot model is still detected")]
public void Exclusion_filter_change_is_detected_against_snapshot()
{
Expand Down Expand Up @@ -302,4 +347,48 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
});
}

internal readonly record struct RoundTripEmailAddress(string Value);

internal class RoundTripProfile
{
public RoundTripEmailAddress Handle { get; set; }
}

internal class RoundTripAccount
{
public int Id { get; set; }
public RoundTripEmailAddress Email { get; set; }
public RoundTripProfile Profile { get; set; } = new();
public NpgsqlRange<DateOnly> Period { get; set; }
}

// Every place a converter-member path can appear: an expression-index template, an entity-level
// column part, a composite part, a filter placeholder, an exclusion element — at the top level and
// nested inside a complex type. The snapshot persists the provider type and drops the converter,
// so each of these is a way the runtime differ could fail to resolve what design time resolved.
internal class RoundTripConverterContext(DbContextOptions<RoundTripConverterContext> options) : DbContext(options)
{
public DbSet<RoundTripAccount> Accounts => Set<RoundTripAccount>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<RoundTripAccount>(b =>
{
b.ToTable("rt_accounts");
b.HasKey(x => x.Id);
b.Property(x => x.Email)
.HasConversion(email => email.Value, value => new RoundTripEmailAddress(value))
.HasColumnName("email");
b.Property(x => x.Period).HasColumnName("period");
b.ComplexProperty(x => x.Profile, c => c.Property(x => x.Handle)
.HasConversion(handle => handle.Value, value => new RoundTripEmailAddress(value))
.HasColumnName("handle"));

b.HasExpressionIndex(x => x.Email.Value.ToLower(), indexName: "ix_rt_accounts_email_lower");
b.HasExpressionIndex(x => x.Profile.Handle.Value.ToLower(), indexName: "ix_rt_accounts_handle_lower");
b.HasComplexIndex(x => x.Email.Value, filter: "{Profile.Handle.Value} <> ''", indexName: "ix_rt_accounts_email_with_handle");
b.HasComplexCompositeIndex(x => new { x.Id, x.Profile.Handle.Value }, indexName: "ix_rt_accounts_id_handle");
b.HasExclusionConstraint(x => x.Email.Value, x => x.Period, filter: "{Email.Value} <> ''", name: "ex_rt_accounts_email_period");
});
}

#pragma warning restore EF1001
Loading