From 397df182ed957be244f4fb74836c41f99ad95ee7 Mon Sep 17 00:00:00 2001 From: Dmytro Khmara Date: Thu, 30 Apr 2026 00:46:11 +0100 Subject: [PATCH] Resolve string enum parameters on the raw-SQL path The relational type-mapping plugin only resolved a mapping when EF gave it both a CLR type and a store type. EF's raw-SQL parameter pipeline (DatabaseFacade.ExecuteSqlRaw{,Async}) supplies only the CLR type, so the plugin returned null, EF fell back to NpgsqlStringTypeMapping, the parameter was pinned to NpgsqlDbType.Text, and the server rejected the statement with 42804 against any column of a native enum type. Adds a StringEnumPgTypeRegistry that the plugin consults when storeType is null, and a configure overload on UseStringEnumsAsPostgresEnums that lets callers pre-register their string enums: optionsBuilder .UseNpgsql(dataSource) .UseStringEnums() .UseStringEnumsAsPostgresEnums(r => r.MapStringEnum()); Pre-registration is required because EF Core's relational type-mapping cache locks in the result of the very first FindMapping(typeof(TEnum), null) call, which fires during convention setup before OnModelCreating runs -- MapStringEnumAsPostgresEnum() in the model builder is too late. Covered by a new integration test that round-trips a Sport value through ExecuteSqlRawAsync against a real Postgres enum column, and by a unit test that pins the registry-fallback branch of the plugin. --- .../DbContextOptionsBuilderExtensions.cs | 18 ++++- ...NpgsqlStringEnumTypeMappingSourcePlugin.cs | 26 +++++-- .../Internal/StringEnumPgTypeRegistry.cs | 43 ++++++++++ .../ModelBuilderExtensions.cs | 7 ++ .../StringEnumPostgresEnumRegistrar.cs | 36 +++++++++ .../RawSqlEnumParameterTests.cs | 78 +++++++++++++++++++ ...lStringEnumTypeMappingSourcePluginTests.cs | 22 +++++- 7 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs index 7d233cd..3bef7b4 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs @@ -57,11 +57,19 @@ public static DbContextOptionsBuilder UseStringEnums(this Db /// The plugin returns a that sets the /// parameter's DataTypeName to the enum name, so Npgsql's resolver picks it up. /// - public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbContextOptionsBuilder builder) + public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums( + this DbContextOptionsBuilder builder, + Action? configure = null) { if (builder == null) throw new ArgumentNullException(nameof(builder)); + // Populate the registry up-front, before EF Core builds the model and starts its + // 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 extension = builder.Options.FindExtension() ?? new StrEnumNpgsqlOptionsExtension(); @@ -70,14 +78,16 @@ public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbConte return builder; } - /// - public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbContextOptionsBuilder builder) + /// + public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums( + this DbContextOptionsBuilder builder, + Action? configure = null) where TContext : DbContext { if (builder == null) throw new ArgumentNullException(nameof(builder)); - UseStringEnumsAsPostgresEnums((DbContextOptionsBuilder)builder); + UseStringEnumsAsPostgresEnums((DbContextOptionsBuilder)builder, configure); return builder; } diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs index 3b476b3..da37450 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs @@ -4,26 +4,36 @@ namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; /// /// EF Core plugin that supplies a for any property -/// whose CLR type derives from and which has an explicit store type -/// (typically set by HasPostgresStringEnum<TEnum>() on the property or -/// MapStringEnumAsPostgresEnum<TEnum>() on the model). 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). +/// whose CLR type derives from . Resolves the store type from one of +/// two places: +/// +/// 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. +/// +/// 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). /// internal sealed class NpgsqlStringEnumTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin { public RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) { var clrType = mappingInfo.ClrType; - var storeType = mappingInfo.StoreTypeName; - if (clrType is null || storeType is null) + if (clrType is null) return null; if (!clrType.IsStringEnum()) return null; + var storeType = mappingInfo.StoreTypeName; + if (storeType is null && !StringEnumPgTypeRegistry.TryGetPgTypeName(clrType, out storeType)) + return null; + var mappingType = typeof(NpgsqlStringEnumTypeMapping<>).MakeGenericType(clrType); return (RelationalTypeMapping)Activator.CreateInstance(mappingType, storeType)!; } diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs new file mode 100644 index 0000000..4de0685 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StringEnumPgTypeRegistry.cs @@ -0,0 +1,43 @@ +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 7fa4c42..33e5546 100644 --- a/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using StrEnum.Npgsql; +using StrEnum.Npgsql.EntityFrameworkCore.Internal; namespace StrEnum.Npgsql.EntityFrameworkCore; @@ -21,6 +22,12 @@ 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 new file mode 100644 index 0000000..f608495 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumPostgresEnumRegistrar.cs @@ -0,0 +1,36 @@ +using StrEnum.Npgsql; +using StrEnum.Npgsql.EntityFrameworkCore.Internal; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +/// +/// Surface for declaring ↔ Postgres-enum mappings up-front, before +/// EF Core's model-building begins. Passed to the configure delegate of +/// . +/// +/// +/// Necessary because EF Core's relational type-mapping cache is keyed on +/// RelationalTypeMappingInfo and the very first miss for a CLR type with no store type is +/// memoised — by the time MapStringEnumAsPostgresEnum<TEnum>() runs inside +/// OnModelCreating, EF has already locked in a null mapping for the +/// raw-SQL (typeof(TEnum), null) lookup. Registering here populates the registry before any +/// FindMapping call, so the first lookup succeeds. +/// +public sealed class StringEnumPostgresEnumRegistrar +{ + /// + /// Registers a CLR type with the Postgres enum type name it + /// maps to. Defaults match those of the model-level + /// and the data-source + /// NpgsqlDataSourceBuilder.MapStringEnum<TEnum> — pass the same + /// and in all three places. + /// + public StringEnumPostgresEnumRegistrar MapStringEnum(string? name = null, string? schema = null) + where TEnum : StringEnum, new() + { + var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); + var pgTypeName = schema is null ? enumName : $"{schema}.{enumName}"; + StringEnumPgTypeRegistry.Register(typeof(TEnum), pgTypeName); + return this; + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs new file mode 100644 index 0000000..f3e23bc --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/RawSqlEnumParameterTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using StrEnum.Npgsql; +using Xunit; + +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 +/// 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. +/// +public class RawSqlEnumParameterTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public RawSqlEnumParameterTests(PostgresFixture postgres) => _postgres = postgres; + + private class RaceContext : DbContext + { + private readonly NpgsqlDataSource _dataSource; + + public DbSet Races => Set(); + + public RaceContext(NpgsqlDataSource dataSource) => _dataSource = dataSource; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql(_dataSource) + .UseStringEnums() + // Pre-register the enum so the type-mapping cache resolves it on the + // raw-SQL parameter path, where EF has no property metadata to drive the lookup. + .UseStringEnumsAsPostgresEnums(r => r.MapStringEnum()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.MapStringEnumAsPostgresEnum(); + } + } + + [Fact] + public async Task Binds_a_string_enum_parameter_in_ExecuteSqlRawAsync() + { + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString); + dataSourceBuilder.MapStringEnum(); + + await using var dataSource = dataSourceBuilder.Build(); + await using var context = new RaceContext(dataSource); + + await context.Database.EnsureCreatedAsync(); + + var raceId = Guid.NewGuid(); + context.Races.Add(new Race { Id = raceId, Name = "Cape Epic", Sport = Sport.RoadCycling }); + await context.SaveChangesAsync(); + + // Raw SQL update — passes the Sport value as a plain object parameter. EF's raw-SQL pipeline + // looks up a type mapping by CLR type alone; the registry fallback supplies the store type + // ("sport"), letting Npgsql bind by OID instead of falling through to text. + var rowsAffected = await context.Database.ExecuteSqlRawAsync( + "UPDATE \"Races\" SET \"Sport\" = {0} WHERE \"Id\" = {1}", + Sport.MountainBiking, raceId); + + rowsAffected.Should().Be(1); + + // Re-read with a fresh context so the change tracker doesn't mask a stale read. + await using var verifyContext = new RaceContext(dataSource); + var race = await verifyContext.Races.SingleAsync(r => r.Id == raceId); + race.Sport.Should().Be(Sport.MountainBiking); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs index d081273..3e38661 100644 --- a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs @@ -13,6 +13,11 @@ public class Sport : StringEnum public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); } + public class UnregisteredEnum : StringEnum + { + public static readonly UnregisteredEnum One = Define("ONE"); + } + [Fact] public void FindMapping_ReturnsNpgsqlStringEnumTypeMapping_ForStringEnumWithStoreType() { @@ -27,11 +32,26 @@ public void FindMapping_ReturnsNpgsqlStringEnumTypeMapping_ForStringEnumWithStor } [Fact] - public void FindMapping_ReturnsNull_WhenStoreTypeIsMissing() + public void FindMapping_FallsBackToRegistry_WhenStoreTypeIsMissing() { + StringEnumPgTypeRegistry.Register(typeof(Sport), "races.sport_kind"); + var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); var info = new RelationalTypeMappingInfo(typeof(Sport)); + var mapping = plugin.FindMapping(info); + + mapping.Should().NotBeNull(); + mapping!.ClrType.Should().Be(); + mapping.StoreType.Should().Be("races.sport_kind"); + } + + [Fact] + public void FindMapping_ReturnsNull_WhenStoreTypeIsMissingAndNoRegistration() + { + var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var info = new RelationalTypeMappingInfo(typeof(UnregisteredEnum)); + plugin.FindMapping(info).Should().BeNull(); }