diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs index 3bef7b4..b5808ea 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs @@ -68,10 +68,11 @@ public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums( // type-mapping cache. This is the only opportunity that beats EF's first // FindMapping(typeof(TEnum)) call — any registration that happens later (e.g. in // OnModelCreating) misses the cache window for the raw-SQL parameter path. - configure?.Invoke(new StringEnumPostgresEnumRegistrar()); + var registrar = new StringEnumPostgresEnumRegistrar(); + configure?.Invoke(registrar); - var extension = builder.Options.FindExtension() - ?? new StrEnumNpgsqlOptionsExtension(); + // The registrations also key EF Core's service provider — see StrEnumNpgsqlOptionsExtension. + var extension = new StrEnumNpgsqlOptionsExtension(registrar.Registrations); ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(extension); diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs index 0bb9ae7..8505b64 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs @@ -9,10 +9,28 @@ namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; /// the type-mapping source consults it during property mapping resolution. Wired up via /// . /// +/// +/// Keyed on the enums registered through . EF Core caches +/// one internal service provider — and one type-mapping cache — per service provider key, memoising the +/// first FindMapping(typeof(TEnum), storeType: null) miss. Contexts that registered an enum must +/// not share that cache with contexts that didn't, or the miss is served to both and raw-SQL parameters +/// fall back to text. +/// internal sealed class StrEnumNpgsqlOptionsExtension : IDbContextOptionsExtension { private DbContextOptionsExtensionInfo? _info; + public StrEnumNpgsqlOptionsExtension() : this(new Dictionary()) + { + } + + public StrEnumNpgsqlOptionsExtension(IReadOnlyDictionary registrations) + { + Registrations = registrations; + } + + public IReadOnlyDictionary Registrations { get; } + public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this); public void ApplyServices(IServiceCollection services) @@ -26,17 +44,56 @@ private sealed class ExtensionInfo : DbContextOptionsExtensionInfo { public ExtensionInfo(IDbContextOptionsExtension extension) : base(extension) { } + private new StrEnumNpgsqlOptionsExtension Extension => (StrEnumNpgsqlOptionsExtension)base.Extension; + public override bool IsDatabaseProvider => false; public override string LogFragment => "using StrEnum.Npgsql "; - public override int GetServiceProviderHashCode() => 0; + public override int GetServiceProviderHashCode() + { + var hashCode = new HashCode(); - public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => other is ExtensionInfo; + foreach (var registration in Extension.Registrations) + { + hashCode.Add(registration.Key, StringComparer.Ordinal); + hashCode.Add(registration.Value, StringComparer.Ordinal); + } + + return hashCode.ToHashCode(); + } + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) + { + if (other is not ExtensionInfo otherInfo) + return false; + + var registrations = Extension.Registrations; + var otherRegistrations = otherInfo.Extension.Registrations; + + if (registrations.Count != otherRegistrations.Count) + return false; + + foreach (var registration in registrations) + { + if (!otherRegistrations.TryGetValue(registration.Key, out var otherPgTypeName) + || !string.Equals(registration.Value, otherPgTypeName, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } public override void PopulateDebugInfo(IDictionary debugInfo) { debugInfo["StrEnum.Npgsql:UseStringEnumsAsPostgresEnums"] = "1"; + + foreach (var registration in Extension.Registrations) + { + debugInfo[$"StrEnum.Npgsql:PostgresEnum:{registration.Key}"] = registration.Value; + } } } } diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj b/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj index cf55195..d8f4073 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj @@ -53,7 +53,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs index f608495..f73d262 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs @@ -18,6 +18,14 @@ namespace StrEnum.Npgsql.EntityFrameworkCore; /// public sealed class StringEnumPostgresEnumRegistrar { + private readonly SortedDictionary _registrations = new(StringComparer.Ordinal); + + /// + /// The mappings declared here, keyed by assembly-qualified CLR type name. Used to key EF Core's + /// internal service provider. + /// + internal IReadOnlyDictionary Registrations => _registrations; + /// /// Registers a CLR type with the Postgres enum type name it /// maps to. Defaults match those of the model-level @@ -31,6 +39,7 @@ public StringEnumPostgresEnumRegistrar MapStringEnum(string? name = null, var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); var pgTypeName = schema is null ? enumName : $"{schema}.{enumName}"; StringEnumPgTypeRegistry.Register(typeof(TEnum), pgTypeName); + _registrations[typeof(TEnum).AssemblyQualifiedName ?? typeof(TEnum).FullName!] = pgTypeName; return this; } } diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/CompositePrimaryKeyTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/CompositePrimaryKeyTests.cs new file mode 100644 index 0000000..5d232ac --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/CompositePrimaryKeyTests.cs @@ -0,0 +1,139 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using StrEnum.Npgsql; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests; + +/// +/// Verifies that a string enum mapped to a native Postgres enum can take part in a composite primary +/// key. EF Core orders key values internally, so it rejects key types that aren't comparable: +/// Property 'NationalRecord.Sport' cannot be used as a key because it has type 'Sport' which does +/// not implement 'IComparable<T>', 'IComparable' or 'IStructuralComparable'. StrEnum 2.1.0 +/// added those interfaces to , which is what lets the model below +/// validate. A value converter would sidestep the check by comparing the provider type instead, so a +/// native enum column is the only way to exercise it. +/// +public class CompositePrimaryKeyTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public CompositePrimaryKeyTests(PostgresFixture postgres) => _postgres = postgres; + + private class RecordsContext : DbContext + { + private readonly NpgsqlDataSource _dataSource; + + public DbSet NationalRecords => Set(); + + public RecordsContext(NpgsqlDataSource dataSource) => _dataSource = dataSource; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql(_dataSource) + .UseStringEnums() + .UseStringEnumsAsPostgresEnums(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(r => new { r.Sport, r.Year }); + + modelBuilder.MapStringEnumAsPostgresEnum(); + } + } + + private RecordsContext CreateContext(NpgsqlDataSource dataSource) => new(dataSource); + + private NpgsqlDataSource CreateDataSource() + { + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString); + dataSourceBuilder.MapStringEnum(); + + return dataSourceBuilder.Build(); + } + + [Fact] + public async Task Builds_a_composite_primary_key_that_includes_a_string_enum() + { + await using var dataSource = CreateDataSource(); + await using var context = CreateContext(dataSource); + + var primaryKey = context.Model.FindEntityType(typeof(NationalRecord))!.FindPrimaryKey()!; + + primaryKey.Properties.Select(p => p.Name).Should().Equal(nameof(NationalRecord.Sport), nameof(NationalRecord.Year)); + + var sportProperty = primaryKey.Properties.First(); + + // The property is mapped straight onto the Postgres enum, with no value converter in play. + sportProperty.ClrType.Should().Be(); + sportProperty.GetValueConverter().Should().BeNull(); + sportProperty.GetColumnType().Should().Be("sport"); + } + + [Fact] + public async Task Round_trips_a_row_keyed_on_a_native_postgres_enum() + { + await using var dataSource = CreateDataSource(); + + await using (var seedContext = CreateContext(dataSource)) + { + await seedContext.Database.EnsureCreatedAsync(); + + seedContext.NationalRecords.AddRange( + new NationalRecord { Sport = Sport.TrailRunning, Year = 2024, Athlete = "Olha Bilyk" }, + new NationalRecord { Sport = Sport.RoadCycling, Year = 2024, Athlete = "Andrii Kovalenko" }, + new NationalRecord { Sport = Sport.MountainBiking, Year = 2025, Athlete = "Yana Melnyk" }); + + await seedContext.SaveChangesAsync(); + } + + // Look the row up by the composite key, which is the path that orders key values internally. + await using (var updateContext = CreateContext(dataSource)) + { + var record = await updateContext.NationalRecords.FindAsync(Sport.RoadCycling, 2024); + + record!.Athlete.Should().Be("Andrii Kovalenko"); + + record.Athlete = "Andrii Kovalenko-Shevchenko"; + + await updateContext.SaveChangesAsync(); + } + + await using var queryContext = CreateContext(dataSource); + + var records = await queryContext.NationalRecords + .Where(r => r.Year == 2024) + .ToArrayAsync(); + + records.Should().HaveCount(2); + records.Single(r => r.Sport == Sport.RoadCycling).Athlete.Should().Be("Andrii Kovalenko-Shevchenko"); + records.Single(r => r.Sport == Sport.TrailRunning).Athlete.Should().Be("Olha Bilyk"); + } + + [Fact] + public async Task Distinguishes_rows_that_share_a_year_but_differ_by_string_enum() + { + await using var dataSource = CreateDataSource(); + await using var context = CreateContext(dataSource); + + await context.Database.EnsureCreatedAsync(); + + context.NationalRecords.AddRange( + new NationalRecord { Sport = Sport.TrailRunning, Year = 2026, Athlete = "Olha Bilyk" }, + new NationalRecord { Sport = Sport.MountainBiking, Year = 2026, Athlete = "Yana Melnyk" }); + + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var trailRunningRecord = await context.NationalRecords.FindAsync(Sport.TrailRunning, 2026); + var mountainBikingRecord = await context.NationalRecords.FindAsync(Sport.MountainBiking, 2026); + + trailRunningRecord!.Athlete.Should().Be("Olha Bilyk"); + mountainBikingRecord!.Athlete.Should().Be("Yana Melnyk"); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs index 9c3d3b1..f92e78c 100644 --- a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs @@ -13,3 +13,13 @@ public class Race public string Name { get; set; } = ""; public Sport Sport { get; set; } = null!; } + +/// +/// A national record, keyed on the composite of the sport and the year it was set. +/// +public class NationalRecord +{ + public Sport Sport { get; set; } = null!; + public int Year { get; set; } + public string Athlete { get; set; } = ""; +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj index 5f99ef0..d4c89fb 100644 --- a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj index 7356f79..af1682e 100644 --- a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnumNpgsqlOptionsExtensionTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnumNpgsqlOptionsExtensionTests.cs new file mode 100644 index 0000000..34752a0 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnumNpgsqlOptionsExtensionTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using StrEnum.Npgsql.EntityFrameworkCore.Internal; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +/// +/// Contexts that register different Postgres enums must not share EF Core's internal service provider, +/// or one context's memoised type-mapping miss is served to the other. +/// +public class StrEnumNpgsqlOptionsExtensionTests +{ + public class Sport : StringEnum + { + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); + } + + public class Discipline : StringEnum + { + public static readonly Discipline Sprint = Define("SPRINT"); + } + + private static DbContextOptionsExtensionInfo InfoFor(Action? configure) + { + var builder = new DbContextOptionsBuilder().UseStringEnumsAsPostgresEnums(configure); + + return builder.Options.FindExtension()!.Info; + } + + [Fact] + public void ShouldUseSameServiceProvider_GivenTheSameRegistrations_ShouldBeTrue() + { + var first = InfoFor(r => r.MapStringEnum()); + var second = InfoFor(r => r.MapStringEnum()); + + first.ShouldUseSameServiceProvider(second).Should().BeTrue(); + first.GetServiceProviderHashCode().Should().Be(second.GetServiceProviderHashCode()); + } + + [Fact] + public void ShouldUseSameServiceProvider_GivenDifferentRegistrations_ShouldBeFalse() + { + var registered = InfoFor(r => r.MapStringEnum()); + var unregistered = InfoFor(configure: null); + + registered.ShouldUseSameServiceProvider(unregistered).Should().BeFalse(); + registered.GetServiceProviderHashCode().Should().NotBe(unregistered.GetServiceProviderHashCode()); + } + + [Fact] + public void ShouldUseSameServiceProvider_GivenTheSameEnumUnderDifferentPostgresNames_ShouldBeFalse() + { + var plain = InfoFor(r => r.MapStringEnum()); + var schemaQualified = InfoFor(r => r.MapStringEnum(name: "sport_kind", schema: "races")); + + plain.ShouldUseSameServiceProvider(schemaQualified).Should().BeFalse(); + plain.GetServiceProviderHashCode().Should().NotBe(schemaQualified.GetServiceProviderHashCode()); + } + + [Fact] + public void ShouldUseSameServiceProvider_GivenDifferentEnums_ShouldBeFalse() + { + var sport = InfoFor(r => r.MapStringEnum()); + var discipline = InfoFor(r => r.MapStringEnum()); + + sport.ShouldUseSameServiceProvider(discipline).Should().BeFalse(); + sport.GetServiceProviderHashCode().Should().NotBe(discipline.GetServiceProviderHashCode()); + } +}