Skip to content
Open
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
17 changes: 16 additions & 1 deletion src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,26 @@ public virtual NpgsqlOptionsExtension WithEnumMapping(
nameTranslator ??= NpgsqlConnection.GlobalTypeMapper.DefaultNameTranslator;
#pragma warning restore CS0618

clone._enumDefinitions.Add(new EnumDefinition(clrType, enumName, schemaName, nameTranslator));
var enumDefinition = new EnumDefinition(clrType, enumName, schemaName, nameTranslator);

// The same mapping is frequently configured more than once, e.g. when both a context and a context factory are registered
// in DI with the same configuration lambda (both lambdas configure the same options). Ignore such duplicates; definitions
// which actually differ are kept, and are reported when the mapping is looked up.
if (!clone._enumDefinitions.Any(d => IsSameEnumMapping(d, enumDefinition)))
{
clone._enumDefinitions.Add(enumDefinition);
}

return clone;
}

private static bool IsSameEnumMapping(EnumDefinition existing, EnumDefinition added)
=> existing.ClrType == added.ClrType
&& existing.StoreTypeName == added.StoreTypeName
&& existing.StoreTypeSchema == added.StoreTypeSchema
&& existing.Labels.Count == added.Labels.Count
&& existing.Labels.All(l => added.Labels.TryGetValue(l.Key, out var label) && label == l.Value);

/// <summary>
/// Returns a copy of the current instance configured to use the specified administrative database.
/// </summary>
Expand Down
58 changes: 58 additions & 0 deletions test/EFCore.PG.Tests/NpgsqlDbContextOptionsExtensionsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,63 @@ public void Varying_data_source_connection_strings_do_not_cause_multiple_service
}
}

[ConditionalTheory] // #3375
[InlineData(false)]
[InlineData(true)]
public void MapEnum_configured_twice_by_context_and_factory_registrations(bool pooled)
{
// Registering both a context and a context factory runs both configuration lambdas against the same options,
// so the same MapEnum call is applied twice.
var serviceCollection = new ServiceCollection();
if (pooled)
{
serviceCollection.AddDbContextPool<EnumDbContext>(b => b.UseNpgsql("Host=localhost", o => o.MapEnum<Mood>()));
serviceCollection.AddPooledDbContextFactory<EnumDbContext>(b => b.UseNpgsql("Host=localhost", o => o.MapEnum<Mood>()));
}
else
{
serviceCollection.AddDbContext<EnumDbContext>(b => b.UseNpgsql("Host=localhost", o => o.MapEnum<Mood>()));
serviceCollection.AddDbContextFactory<EnumDbContext>(b => b.UseNpgsql("Host=localhost", o => o.MapEnum<Mood>()));
}

using var services = serviceCollection.BuildServiceProvider();

using (var scope = services.CreateScope())
{
AssertMoodMapped(scope.ServiceProvider.GetRequiredService<EnumDbContext>());
}

using var factoryContext = services.GetRequiredService<IDbContextFactory<EnumDbContext>>().CreateDbContext();
AssertMoodMapped(factoryContext);

static void AssertMoodMapped(EnumDbContext context)
{
var npgsqlOptions = context.GetService<IDbContextOptions>().FindExtension<NpgsqlOptionsExtension>()!;
var enumDefinition = Assert.Single(npgsqlOptions.EnumDefinitions);
Assert.Same(typeof(Mood), enumDefinition.ClrType);

var property = context.Model.FindEntityType(typeof(Person))!.FindProperty(nameof(Person.Mood))!;
Assert.Equal("mood", property.GetRelationalTypeMapping().StoreType);
Assert.Equal("mood", Assert.Single(context.GetService<IDesignTimeModel>().Model.GetPostgresEnums()).Name);
}
}

private class ApplicationDbContext(DbContextOptions options) : DbContext(options);

private class EnumDbContext(DbContextOptions<EnumDbContext> options) : DbContext(options)
{
public DbSet<Person> People { get; set; }
}

private class Person
{
public int Id { get; set; }
public Mood Mood { get; set; }
}

private enum Mood
{
Happy,
Sad
}
}
44 changes: 41 additions & 3 deletions test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Npgsql.EntityFrameworkCore.PostgreSQL.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;
using Npgsql.NameTranslation;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage;

Expand Down Expand Up @@ -348,7 +349,6 @@ public void Enum_mapping()
}

[Theory]
[InlineData("dummy_enum", null)]
[InlineData("other_enum", null)]
[InlineData("dummy_enum", "other")]
public void Enum_with_multiple_definitions_for_clr_type(string secondName, string secondSchema)
Expand All @@ -361,10 +361,48 @@ public void Enum_with_multiple_definitions_for_clr_type(string secondName, strin
Assert.Throws<InvalidOperationException>(() => source.FindMapping(typeof(DummyEnum))).Message);
}

[Theory] // #3375
[InlineData(null)]
[InlineData("some_schema")]
public void Enum_mapped_twice_with_same_definition(string schema)
{
var source = CreateTypeMappingSource(
configure: b => b.MapEnum<DummyEnum>("dummy_enum", schema).MapEnum<DummyEnum>("dummy_enum", schema));
var storeType = schema is null ? "dummy_enum" : schema + ".dummy_enum";

Assert.Equal(storeType, source.FindMapping(typeof(DummyEnum)).StoreType);
Assert.Same(typeof(DummyEnum), source.FindMapping(storeType).ClrType);
Assert.Same(typeof(DummyEnum), source.FindMapping(typeof(DummyEnum), storeType).ClrType);
}

[Fact] // #3375
public void Enum_mapped_twice_with_equivalent_name_translators()
{
var source = CreateTypeMappingSource(
configure: b => b
.MapEnum<DummyEnum>(nameTranslator: new NpgsqlSnakeCaseNameTranslator())
.MapEnum<DummyEnum>(nameTranslator: new NpgsqlSnakeCaseNameTranslator()));

Assert.Equal("dummy_enum", source.FindMapping(typeof(DummyEnum)).StoreType);
}

[Fact]
public void Enum_mapped_twice_with_different_labels()
{
// Same CLR type and store type, but the name translators produce different labels ("value" vs. "Value"): this is a real
// conflict, so both definitions are kept and the lookup reports it.
var source = CreateTypeMappingSource(
configure: b => b
.MapEnum<DummyEnum>("dummy_enum", nameTranslator: new NpgsqlSnakeCaseNameTranslator())
.MapEnum<DummyEnum>("dummy_enum", nameTranslator: new NpgsqlNullNameTranslator()));

Assert.Equal(
$"Multiple enum definitions registered for CLR type '{typeof(DummyEnum).FullName}'.",
Assert.Throws<InvalidOperationException>(() => source.FindMapping(typeof(DummyEnum))).Message);
}

[Theory]
[InlineData(null, null, "dummy_enum", typeof(DummyEnum))]
[InlineData(null, null, "dummy_enum", typeof(OtherDummyEnum))]
[InlineData("test", "test", "test.dummy_enum", typeof(DummyEnum))]
[InlineData("test", "test", "\"test\".\"dummy_enum\"", typeof(OtherDummyEnum))]
[InlineData("one", "two", "dummy_enum", typeof(OtherDummyEnum))]
public void Enum_with_multiple_definitions_for_store_type(
Expand Down