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(); }