From cde3601d4623e2da79d3640bcd8adb1436dad13d Mon Sep 17 00:00:00 2001 From: Mohith26 <109003762+Mohith26@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:53:11 -0500 Subject: [PATCH] Ignore duplicate MapEnum configuration Registering both a context and a context factory (AddDbContext + AddDbContextFactory, or AddDbContextPool + AddPooledDbContextFactory) runs both configuration lambdas against the same options, so MapEnum is applied twice and type mapping fails with "Multiple enum definitions registered for CLR type". WithEnumMapping now skips a definition that maps the same CLR type to the same store type with the same labels. Definitions that actually conflict are still kept and reported at lookup time. Fixes #3375 --- .../Internal/NpgsqlOptionsExtension.cs | 17 +++++- .../NpgsqlDbContextOptionsExtensionsTest.cs | 58 +++++++++++++++++++ .../Storage/NpgsqlTypeMappingSourceTest.cs | 44 +++++++++++++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs index b8ff030f3..7d1a7caf3 100644 --- a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs +++ b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs @@ -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); + /// /// Returns a copy of the current instance configured to use the specified administrative database. /// diff --git a/test/EFCore.PG.Tests/NpgsqlDbContextOptionsExtensionsTest.cs b/test/EFCore.PG.Tests/NpgsqlDbContextOptionsExtensionsTest.cs index 27d4446bd..8fef741e9 100644 --- a/test/EFCore.PG.Tests/NpgsqlDbContextOptionsExtensionsTest.cs +++ b/test/EFCore.PG.Tests/NpgsqlDbContextOptionsExtensionsTest.cs @@ -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(b => b.UseNpgsql("Host=localhost", o => o.MapEnum())); + serviceCollection.AddPooledDbContextFactory(b => b.UseNpgsql("Host=localhost", o => o.MapEnum())); + } + else + { + serviceCollection.AddDbContext(b => b.UseNpgsql("Host=localhost", o => o.MapEnum())); + serviceCollection.AddDbContextFactory(b => b.UseNpgsql("Host=localhost", o => o.MapEnum())); + } + + using var services = serviceCollection.BuildServiceProvider(); + + using (var scope = services.CreateScope()) + { + AssertMoodMapped(scope.ServiceProvider.GetRequiredService()); + } + + using var factoryContext = services.GetRequiredService>().CreateDbContext(); + AssertMoodMapped(factoryContext); + + static void AssertMoodMapped(EnumDbContext context) + { + var npgsqlOptions = context.GetService().FindExtension()!; + 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().Model.GetPostgresEnums()).Name); + } + } + private class ApplicationDbContext(DbContextOptions options) : DbContext(options); + + private class EnumDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet People { get; set; } + } + + private class Person + { + public int Id { get; set; } + public Mood Mood { get; set; } + } + + private enum Mood + { + Happy, + Sad + } } diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs index 492e720ae..3a70c9e7e 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs @@ -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; @@ -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) @@ -361,10 +361,48 @@ public void Enum_with_multiple_definitions_for_clr_type(string secondName, strin Assert.Throws(() => 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("dummy_enum", schema).MapEnum("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(nameTranslator: new NpgsqlSnakeCaseNameTranslator()) + .MapEnum(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("dummy_enum", nameTranslator: new NpgsqlSnakeCaseNameTranslator()) + .MapEnum("dummy_enum", nameTranslator: new NpgsqlNullNameTranslator())); + + Assert.Equal( + $"Multiple enum definitions registered for CLR type '{typeof(DummyEnum).FullName}'.", + Assert.Throws(() => 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(