From 4023997aac403e32d1b8f391cd66523fc86f423c Mon Sep 17 00:00:00 2001 From: Dmytro Khmara Date: Thu, 13 Aug 2026 22:54:33 +0100 Subject: [PATCH] Scope the Postgres enum registrations to the EF Core service provider --- ...NpgsqlStringEnumTypeMappingSourcePlugin.cs | 21 +++++++-- .../Internal/StrEnumNpgsqlOptionsExtension.cs | 3 +- .../Internal/StringEnumPgTypeKey.cs | 9 ++++ .../Internal/StringEnumPgTypeRegistry.cs | 43 ------------------- .../ModelBuilderExtensions.cs | 6 --- .../StringEnumPostgresEnumRegistrar.cs | 5 ++- .../RawSqlEnumParameterTests.cs | 2 +- ...lStringEnumTypeMappingSourcePluginTests.cs | 15 ++++--- 8 files changed, 40 insertions(+), 64 deletions(-) create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeKey.cs delete mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs index da37450..1ba0445 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs @@ -10,16 +10,29 @@ namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; /// property metadata — when EF passes a storeType (set by /// HasPostgresStringEnum<TEnum>() on the property or /// MapStringEnumAsPostgresEnum<TEnum>() on the model); -/// — on the raw-SQL parameter path -/// (DatabaseFacade.ExecuteSqlRawAsync(sql, object[])), where EF asks for a mapping by -/// CLR type alone and there is no property to carry the store type. +/// the enums registered up-front through — on +/// the raw-SQL parameter path (DatabaseFacade.ExecuteSqlRawAsync(sql, object[])), where EF +/// asks for a mapping by CLR type alone and there is no property to carry the store type. /// /// Plugins are consulted *before* EFCore.PG's built-in NpgsqlTypeMappingSource, so this is /// the hook that prevents EF from falling back to NpgsqlStringTypeMapping (which would force /// NpgsqlDbType.Text on the parameter and break the wire-level enum binding). /// +/// +/// Registrations are held per service provider, not process-wide. EF Core memoises the first +/// FindMapping(typeof(TEnum), storeType: null) result in a cache owned by the service provider, +/// so shared state lets one context's miss — or another context's differently named enum — be served +/// to a context that registered its own. +/// internal sealed class NpgsqlStringEnumTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin { + private readonly IReadOnlyDictionary _registrations; + + public NpgsqlStringEnumTypeMappingSourcePlugin(IReadOnlyDictionary registrations) + { + _registrations = registrations; + } + public RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) { var clrType = mappingInfo.ClrType; @@ -31,7 +44,7 @@ internal sealed class NpgsqlStringEnumTypeMappingSourcePlugin : IRelationalTypeM return null; var storeType = mappingInfo.StoreTypeName; - if (storeType is null && !StringEnumPgTypeRegistry.TryGetPgTypeName(clrType, out storeType)) + if (storeType is null && !_registrations.TryGetValue(StringEnumPgTypeKey.For(clrType), out storeType)) return null; var mappingType = typeof(NpgsqlStringEnumTypeMapping<>).MakeGenericType(clrType); diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs index 8505b64..1926af4 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs @@ -35,7 +35,8 @@ public StrEnumNpgsqlOptionsExtension(IReadOnlyDictionary registr public void ApplyServices(IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton( + new NpgsqlStringEnumTypeMappingSourcePlugin(Registrations)); } public void Validate(IDbContextOptions options) { } diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeKey.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeKey.cs new file mode 100644 index 0000000..652088b --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeKey.cs @@ -0,0 +1,9 @@ +namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; + +/// +/// Keys a CLR type in the per-service-provider registration map. +/// +internal static class StringEnumPgTypeKey +{ + public static string For(Type clrType) => clrType.AssemblyQualifiedName ?? clrType.FullName!; +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs deleted file mode 100644 index 4de0685..0000000 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Concurrent; - -namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; - -/// -/// Process-wide registry mapping a CLR type to the Postgres enum -/// type name that or the -/// model-level registered for it. -/// Consulted by when EF Core asks for a -/// mapping by CLR type alone — i.e. on the raw-SQL parameter path -/// (DatabaseFacade.ExecuteSqlRawAsync(sql, object[])), where there is no property metadata -/// and therefore no storeType to drive the mapping. -/// -/// -/// State is process-wide because EF Core type-mapping plugins are stateless from EF's perspective -/// and have no access to the model. Within a process, a given type -/// is expected to map to a single Postgres enum — mapping the same CLR type to different enum -/// names across multiple DbContexts in one process is not supported. -/// -internal static class StringEnumPgTypeRegistry -{ - private static readonly ConcurrentDictionary _map = new(); - - public static void Register(Type clrType, string pgTypeName) - { - if (clrType is null) throw new ArgumentNullException(nameof(clrType)); - if (pgTypeName is null) throw new ArgumentNullException(nameof(pgTypeName)); - - _map[clrType] = pgTypeName; - } - - public static bool TryGetPgTypeName(Type clrType, out string? pgTypeName) - { - if (_map.TryGetValue(clrType, out var found)) - { - pgTypeName = found; - return true; - } - - pgTypeName = null; - return false; - } -} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs index 33e5546..712b85e 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs @@ -22,12 +22,6 @@ public static ModelBuilder HasPostgresStringEnum(this ModelBuilder modelB var labels = StringEnumLabels.For(); var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); - var pgTypeName = schema is null ? enumName : $"{schema}.{enumName}"; - - // Register in the process-wide CLR-type → PG-type-name map so the EF Core type-mapping - // plugin can resolve the store type on the raw-SQL parameter path, where there's no - // property metadata to carry it. - StringEnumPgTypeRegistry.Register(typeof(TEnum), pgTypeName); return modelBuilder.HasPostgresEnum(schema, enumName, labels); } diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs index f73d262..b0cf17a 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs @@ -38,8 +38,9 @@ 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; + + _registrations[StringEnumPgTypeKey.For(typeof(TEnum))] = pgTypeName; + return this; } } diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs index f3e23bc..b3aec93 100644 --- a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs @@ -10,7 +10,7 @@ namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests; /// Verifies that parameters bind to native Postgres enum columns on /// the raw-SQL path — DatabaseFacade.ExecuteSqlRawAsync(sql, object[]) — where EF Core has /// no property metadata to carry the column type. Without the -/// fallback, EF would fall through to +/// up-front registration, EF would fall through to /// NpgsqlStringTypeMapping, pin the parameter to NpgsqlDbType.Text, and the server /// would reject the UPDATE with 42804: column "x" is of type sport but expression is of type /// text. diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs index 3e38661..bce3382 100644 --- a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs @@ -18,10 +18,13 @@ public class UnregisteredEnum : StringEnum public static readonly UnregisteredEnum One = Define("ONE"); } + private static NpgsqlStringEnumTypeMappingSourcePlugin CreatePlugin(params (Type ClrType, string PgTypeName)[] registrations) + => new(registrations.ToDictionary(r => StringEnumPgTypeKey.For(r.ClrType), r => r.PgTypeName)); + [Fact] public void FindMapping_ReturnsNpgsqlStringEnumTypeMapping_ForStringEnumWithStoreType() { - var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var plugin = CreatePlugin(); var info = new RelationalTypeMappingInfo(typeof(Sport), storeTypeName: "sport", unicode: null, size: null, precision: null, scale: null); var mapping = plugin.FindMapping(info); @@ -32,11 +35,9 @@ public void FindMapping_ReturnsNpgsqlStringEnumTypeMapping_ForStringEnumWithStor } [Fact] - public void FindMapping_FallsBackToRegistry_WhenStoreTypeIsMissing() + public void FindMapping_FallsBackToTheRegisteredEnums_WhenStoreTypeIsMissing() { - StringEnumPgTypeRegistry.Register(typeof(Sport), "races.sport_kind"); - - var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var plugin = CreatePlugin((typeof(Sport), "races.sport_kind")); var info = new RelationalTypeMappingInfo(typeof(Sport)); var mapping = plugin.FindMapping(info); @@ -49,7 +50,7 @@ public void FindMapping_FallsBackToRegistry_WhenStoreTypeIsMissing() [Fact] public void FindMapping_ReturnsNull_WhenStoreTypeIsMissingAndNoRegistration() { - var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var plugin = CreatePlugin(); var info = new RelationalTypeMappingInfo(typeof(UnregisteredEnum)); plugin.FindMapping(info).Should().BeNull(); @@ -58,7 +59,7 @@ public void FindMapping_ReturnsNull_WhenStoreTypeIsMissingAndNoRegistration() [Fact] public void FindMapping_ReturnsNull_ForNonStringEnumClrType() { - var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var plugin = CreatePlugin(); var info = new RelationalTypeMappingInfo(typeof(string), storeTypeName: "sport", unicode: null, size: null, precision: null, scale: null); plugin.FindMapping(info).Should().BeNull();