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 @@ -57,11 +57,19 @@ public static DbContextOptionsBuilder<TContext> UseStringEnums<TContext>(this Db
/// The plugin returns a <see cref="NpgsqlStringEnumTypeMapping{TEnum}"/> that sets the
/// parameter's <c>DataTypeName</c> to the enum name, so Npgsql's resolver picks it up.
/// </remarks>
public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbContextOptionsBuilder builder)
public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(
this DbContextOptionsBuilder builder,
Action<StringEnumPostgresEnumRegistrar>? 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<StrEnumNpgsqlOptionsExtension>()
?? new StrEnumNpgsqlOptionsExtension();

Expand All @@ -70,14 +78,16 @@ public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbConte
return builder;
}

/// <inheritdoc cref="UseStringEnumsAsPostgresEnums(DbContextOptionsBuilder)"/>
public static DbContextOptionsBuilder<TContext> UseStringEnumsAsPostgresEnums<TContext>(this DbContextOptionsBuilder<TContext> builder)
/// <inheritdoc cref="UseStringEnumsAsPostgresEnums(DbContextOptionsBuilder, Action{StringEnumPostgresEnumRegistrar}?)"/>
public static DbContextOptionsBuilder<TContext> UseStringEnumsAsPostgresEnums<TContext>(
this DbContextOptionsBuilder<TContext> builder,
Action<StringEnumPostgresEnumRegistrar>? configure = null)
where TContext : DbContext
{
if (builder == null)
throw new ArgumentNullException(nameof(builder));

UseStringEnumsAsPostgresEnums((DbContextOptionsBuilder)builder);
UseStringEnumsAsPostgresEnums((DbContextOptionsBuilder)builder, configure);

return builder;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,36 @@ namespace StrEnum.Npgsql.EntityFrameworkCore.Internal;

/// <summary>
/// EF Core plugin that supplies a <see cref="NpgsqlStringEnumTypeMapping{TEnum}"/> for any property
/// whose CLR type derives from <see cref="StringEnum{TEnum}"/> and which has an explicit store type
/// (typically set by <c>HasPostgresStringEnum&lt;TEnum&gt;()</c> on the property or
/// <c>MapStringEnumAsPostgresEnum&lt;TEnum&gt;()</c> on the model). Plugins are consulted *before*
/// EFCore.PG's built-in <c>NpgsqlTypeMappingSource</c>, so this is the hook that prevents EF from
/// falling back to <c>NpgsqlStringTypeMapping</c> (which would force <c>NpgsqlDbType.Text</c> on the
/// parameter and break the wire-level enum binding).
/// whose CLR type derives from <see cref="StringEnum{TEnum}"/>. Resolves the store type from one of
/// two places:
/// <list type="bullet">
/// <item>property metadata — when EF passes a <c>storeType</c> (set by
/// <c>HasPostgresStringEnum&lt;TEnum&gt;()</c> on the property or
/// <c>MapStringEnumAsPostgresEnum&lt;TEnum&gt;()</c> on the model);</item>
/// <item><see cref="StringEnumPgTypeRegistry"/> — on the raw-SQL parameter path
/// (<c>DatabaseFacade.ExecuteSqlRawAsync(sql, object[])</c>), where EF asks for a mapping by
/// CLR type alone and there is no property to carry the store type.</item>
/// </list>
/// Plugins are consulted *before* EFCore.PG's built-in <c>NpgsqlTypeMappingSource</c>, so this is
/// the hook that prevents EF from falling back to <c>NpgsqlStringTypeMapping</c> (which would force
/// <c>NpgsqlDbType.Text</c> on the parameter and break the wire-level enum binding).
/// </summary>
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)!;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Collections.Concurrent;

namespace StrEnum.Npgsql.EntityFrameworkCore.Internal;

/// <summary>
/// Process-wide registry mapping a <see cref="StringEnum{TEnum}"/> CLR type to the Postgres enum
/// type name that <see cref="ModelBuilderExtensions.MapStringEnumAsPostgresEnum{TEnum}"/> or the
/// model-level <see cref="ModelBuilderExtensions.HasPostgresStringEnum{TEnum}"/> registered for it.
/// Consulted by <see cref="NpgsqlStringEnumTypeMappingSourcePlugin"/> when EF Core asks for a
/// mapping by CLR type alone — i.e. on the raw-SQL parameter path
/// (<c>DatabaseFacade.ExecuteSqlRawAsync(sql, object[])</c>), where there is no property metadata
/// and therefore no <c>storeType</c> to drive the mapping.
/// </summary>
/// <remarks>
/// 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 <see cref="StringEnum{TEnum}"/> 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.
/// </remarks>
internal static class StringEnumPgTypeRegistry
{
private static readonly ConcurrentDictionary<Type, string> _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;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using StrEnum.Npgsql;
using StrEnum.Npgsql.EntityFrameworkCore.Internal;

namespace StrEnum.Npgsql.EntityFrameworkCore;

Expand All @@ -21,6 +22,12 @@ public static ModelBuilder HasPostgresStringEnum<TEnum>(this ModelBuilder modelB

var labels = StringEnumLabels.For<TEnum>();
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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using StrEnum.Npgsql;
using StrEnum.Npgsql.EntityFrameworkCore.Internal;

namespace StrEnum.Npgsql.EntityFrameworkCore;

/// <summary>
/// Surface for declaring <see cref="StringEnum{TEnum}"/> ↔ Postgres-enum mappings up-front, before
/// EF Core's model-building begins. Passed to the configure delegate of
/// <see cref="DbContextOptionsBuilderExtensions.UseStringEnumsAsPostgresEnums(Microsoft.EntityFrameworkCore.DbContextOptionsBuilder, System.Action{StringEnumPostgresEnumRegistrar}?)"/>.
/// </summary>
/// <remarks>
/// Necessary because EF Core's relational type-mapping cache is keyed on
/// <c>RelationalTypeMappingInfo</c> and the very first miss for a CLR type with no store type is
/// memoised — by the time <c>MapStringEnumAsPostgresEnum&lt;TEnum&gt;()</c> runs inside
/// <c>OnModelCreating</c>, EF has already locked in a null mapping for the
/// raw-SQL <c>(typeof(TEnum), null)</c> lookup. Registering here populates the registry before any
/// FindMapping call, so the first lookup succeeds.
/// </remarks>
public sealed class StringEnumPostgresEnumRegistrar
{
/// <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
/// <see cref="ModelBuilderExtensions.MapStringEnumAsPostgresEnum{TEnum}"/> and the data-source
/// <c>NpgsqlDataSourceBuilder.MapStringEnum&lt;TEnum&gt;</c> — pass the same <paramref name="name"/>
/// and <paramref name="schema"/> in all three places.
/// </summary>
public StringEnumPostgresEnumRegistrar MapStringEnum<TEnum>(string? name = null, string? schema = null)
where TEnum : StringEnum<TEnum>, new()
{
var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name);
var pgTypeName = schema is null ? enumName : $"{schema}.{enumName}";
StringEnumPgTypeRegistry.Register(typeof(TEnum), pgTypeName);
return this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StrEnum.Npgsql;
using Xunit;

namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests;

/// <summary>
/// Verifies that <see cref="StringEnum{TEnum}"/> parameters bind to native Postgres enum columns on
/// the raw-SQL path — <c>DatabaseFacade.ExecuteSqlRawAsync(sql, object[])</c> — where EF Core has
/// no property metadata to carry the column type. Without the
/// <see cref="Internal.StringEnumPgTypeRegistry"/> fallback, EF would fall through to
/// <c>NpgsqlStringTypeMapping</c>, pin the parameter to <c>NpgsqlDbType.Text</c>, and the server
/// would reject the UPDATE with <c>42804: column "x" is of type sport but expression is of type
/// text</c>.
/// </summary>
public class RawSqlEnumParameterTests : IClassFixture<PostgresFixture>
{
private readonly PostgresFixture _postgres;

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

private class RaceContext : DbContext
{
private readonly NpgsqlDataSource _dataSource;

public DbSet<Race> Races => Set<Race>();

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

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Race>();
modelBuilder.MapStringEnumAsPostgresEnum<Sport>();
}
}

[Fact]
public async Task Binds_a_string_enum_parameter_in_ExecuteSqlRawAsync()
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString);
dataSourceBuilder.MapStringEnum<Sport>();

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ public class Sport : StringEnum<Sport>
public static readonly Sport TrailRunning = Define("TRAIL_RUNNING");
}

public class UnregisteredEnum : StringEnum<UnregisteredEnum>
{
public static readonly UnregisteredEnum One = Define("ONE");
}

[Fact]
public void FindMapping_ReturnsNpgsqlStringEnumTypeMapping_ForStringEnumWithStoreType()
{
Expand All @@ -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<Sport>();
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();
}

Expand Down
Loading