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
Original file line number Diff line number Diff line change
Expand Up @@ -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<StrEnumNpgsqlOptionsExtension>()
?? new StrEnumNpgsqlOptionsExtension();
// The registrations also key EF Core's service provider — see StrEnumNpgsqlOptionsExtension.
var extension = new StrEnumNpgsqlOptionsExtension(registrar.Registrations);

((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(extension);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,28 @@ namespace StrEnum.Npgsql.EntityFrameworkCore.Internal;
/// the type-mapping source consults it during property mapping resolution. Wired up via
/// <see cref="DbContextOptionsBuilderExtensions.UseStringEnumsAsPostgresEnums"/>.
/// </summary>
/// <remarks>
/// Keyed on the enums registered through <see cref="StringEnumPostgresEnumRegistrar"/>. EF Core caches
/// one internal service provider — and one type-mapping cache — per service provider key, memoising the
/// first <c>FindMapping(typeof(TEnum), storeType: null)</c> 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 <c>text</c>.
/// </remarks>
internal sealed class StrEnumNpgsqlOptionsExtension : IDbContextOptionsExtension
{
private DbContextOptionsExtensionInfo? _info;

public StrEnumNpgsqlOptionsExtension() : this(new Dictionary<string, string>())
{
}

public StrEnumNpgsqlOptionsExtension(IReadOnlyDictionary<string, string> registrations)
{
Registrations = registrations;
}

public IReadOnlyDictionary<string, string> Registrations { get; }

public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this);

public void ApplyServices(IServiceCollection services)
Expand All @@ -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<string, string> debugInfo)
{
debugInfo["StrEnum.Npgsql:UseStringEnumsAsPostgresEnums"] = "1";

foreach (var registration in Extension.Registrations)
{
debugInfo[$"StrEnum.Npgsql:PostgresEnum:{registration.Key}"] = registration.Value;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

<PackageReference Include="StrEnum" Version="[2.0.0,3.0.0)" />
<PackageReference Include="StrEnum" Version="[2.1.0,3.0.0)" />
<PackageReference Include="StrEnum.Npgsql" Version="[2.0.0,3.0.0)" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ namespace StrEnum.Npgsql.EntityFrameworkCore;
/// </remarks>
public sealed class StringEnumPostgresEnumRegistrar
{
private readonly SortedDictionary<string, string> _registrations = new(StringComparer.Ordinal);

/// <summary>
/// The mappings declared here, keyed by assembly-qualified CLR type name. Used to key EF Core's
/// internal service provider.
/// </summary>
internal IReadOnlyDictionary<string, string> Registrations => _registrations;

/// <summary>
/// Registers a <see cref="StringEnum{TEnum}"/> CLR type with the Postgres enum type name it
/// maps to. Defaults match those of the model-level
Expand All @@ -31,6 +39,7 @@ public StringEnumPostgresEnumRegistrar MapStringEnum<TEnum>(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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StrEnum.Npgsql;
using Xunit;

namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests;

/// <summary>
/// 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:
/// <c>Property 'NationalRecord.Sport' cannot be used as a key because it has type 'Sport' which does
/// not implement 'IComparable&lt;T&gt;', 'IComparable' or 'IStructuralComparable'</c>. StrEnum 2.1.0
/// added those interfaces to <see cref="StringEnum{TEnum}"/>, 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.
/// </summary>
public class CompositePrimaryKeyTests : IClassFixture<PostgresFixture>
{
private readonly PostgresFixture _postgres;

public CompositePrimaryKeyTests(PostgresFixture postgres) => _postgres = postgres;

private class RecordsContext : DbContext
{
private readonly NpgsqlDataSource _dataSource;

public DbSet<NationalRecord> NationalRecords => Set<NationalRecord>();

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<NationalRecord>()
.HasKey(r => new { r.Sport, r.Year });

modelBuilder.MapStringEnumAsPostgresEnum<Sport>();
}
}

private RecordsContext CreateContext(NpgsqlDataSource dataSource) => new(dataSource);

private NpgsqlDataSource CreateDataSource()
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString);
dataSourceBuilder.MapStringEnum<Sport>();

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<Sport>();
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");
}
}
10 changes: 10 additions & 0 deletions test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ public class Race
public string Name { get; set; } = "";
public Sport Sport { get; set; } = null!;
}

/// <summary>
/// A national record, keyed on the composite of the sport and the year it was set.
/// </summary>
public class NationalRecord
{
public Sport Sport { get; set; } = null!;
public int Year { get; set; }
public string Athlete { get; set; } = "";
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="StrEnum" Version="2.0.3" />
<PackageReference Include="StrEnum" Version="2.1.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="StrEnum" Version="2.0.3" />
<PackageReference Include="StrEnum" Version="2.1.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class StrEnumNpgsqlOptionsExtensionTests
{
public class Sport : StringEnum<Sport>
{
public static readonly Sport TrailRunning = Define("TRAIL_RUNNING");
}

public class Discipline : StringEnum<Discipline>
{
public static readonly Discipline Sprint = Define("SPRINT");
}

private static DbContextOptionsExtensionInfo InfoFor(Action<StringEnumPostgresEnumRegistrar>? configure)
{
var builder = new DbContextOptionsBuilder().UseStringEnumsAsPostgresEnums(configure);

return builder.Options.FindExtension<StrEnumNpgsqlOptionsExtension>()!.Info;
}

[Fact]
public void ShouldUseSameServiceProvider_GivenTheSameRegistrations_ShouldBeTrue()
{
var first = InfoFor(r => r.MapStringEnum<Sport>());
var second = InfoFor(r => r.MapStringEnum<Sport>());

first.ShouldUseSameServiceProvider(second).Should().BeTrue();
first.GetServiceProviderHashCode().Should().Be(second.GetServiceProviderHashCode());
}

[Fact]
public void ShouldUseSameServiceProvider_GivenDifferentRegistrations_ShouldBeFalse()
{
var registered = InfoFor(r => r.MapStringEnum<Sport>());
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<Sport>());
var schemaQualified = InfoFor(r => r.MapStringEnum<Sport>(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<Sport>());
var discipline = InfoFor(r => r.MapStringEnum<Discipline>());

sport.ShouldUseSameServiceProvider(discipline).Should().BeFalse();
sport.GetServiceProviderHashCode().Should().NotBe(discipline.GetServiceProviderHashCode());
}
}
Loading