Skip to content

Commit eeb3fed

Browse files
Fix MapStringEnumAsPostgresEnum re-probing entities via modelBuilder.Entity (#2)
The walker called modelBuilder.Entity(entityType.ClrType) on each entity to set the column type on string-enum properties. That re-enters EF's entity-discovery conventions, which probe every CLR property on the type (and on reference-typed nested types they pull in). On EF Core 10 models with a complex-collection element exposing e.g. Dictionary<K, V> with a user-supplied HasConversion, the probing fires before the user's fluent config has been applied — and EF throws "Unable to determine the relationship represented by navigation ...", breaking design-time DbContext construction (migrations, scaffolding). Mutate IMutableProperty directly via SetColumnType instead. Walks only existing model state; no convention re-entry. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa5623d commit eeb3fed

2 files changed

Lines changed: 77 additions & 7 deletions

File tree

src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,19 @@ public static ModelBuilder MapStringEnumAsPostgresEnum<TEnum>(this ModelBuilder
4747

4848
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
4949
{
50-
if (entityType.ClrType is null)
51-
continue;
52-
53-
var entityBuilder = modelBuilder.Entity(entityType.ClrType);
54-
5550
foreach (var property in entityType.GetProperties())
5651
{
5752
if (property.ClrType != typeof(TEnum))
5853
continue;
5954

60-
// No HasConversion — see PropertyBuilderExtensions.HasPostgresStringEnum for why.
61-
entityBuilder.Property(property.Name).HasColumnType(columnType);
55+
// Mutate IMutableProperty directly instead of going through modelBuilder.Entity(...).Property(...).
56+
// The latter re-enters EF's entity-discovery conventions, which will probe every CLR property
57+
// on the entity (and on any reference-typed nested types they pull in). For an entity whose
58+
// complex-collection element exposes e.g. Dictionary<K, V> with a user-supplied HasConversion,
59+
// that probing fires before the user's fluent config has been applied — and EF then throws
60+
// "Unable to determine the relationship represented by navigation ...". No HasConversion call
61+
// here either — see PropertyBuilderExtensions.HasPostgresStringEnum for why.
62+
property.SetColumnType(columnType);
6263
}
6364
}
6465

test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ModelBuilderExtensionsTests.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Microsoft.EntityFrameworkCore;
33
using Microsoft.EntityFrameworkCore.Infrastructure;
44
using Microsoft.EntityFrameworkCore.Metadata;
5+
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
56
using Xunit;
67

78
namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests;
@@ -94,4 +95,72 @@ public void MapStringEnumAsPostgresEnum_QualifiesColumnTypeWithSchema()
9495

9596
sportProperty.GetColumnType().Should().Be("races.sport");
9697
}
98+
99+
public class Stage
100+
{
101+
public Dictionary<int, long>? Splits { get; set; }
102+
}
103+
104+
public class Schedule
105+
{
106+
public List<Stage> Stages { get; set; } = new();
107+
}
108+
109+
public class Tournament
110+
{
111+
public Guid Id { get; set; }
112+
public Schedule Schedule { get; set; } = new();
113+
public Sport Sport { get; set; } = null!;
114+
}
115+
116+
private sealed class DictionaryToStringConverter : ValueConverter<Dictionary<int, long>?, string>
117+
{
118+
public DictionaryToStringConverter()
119+
: base(d => "", s => new Dictionary<int, long>())
120+
{
121+
}
122+
}
123+
124+
private sealed class TournamentContext : DbContext
125+
{
126+
public DbSet<Tournament> Tournaments => Set<Tournament>();
127+
128+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
129+
{
130+
optionsBuilder
131+
.UseNpgsql("Host=localhost;Database=tests")
132+
.UseStringEnums()
133+
.ReplaceService<IModelCacheKeyFactory, UncachedModelKeyFactory>();
134+
}
135+
136+
protected override void OnModelCreating(ModelBuilder modelBuilder)
137+
{
138+
// Walker call FIRST, before the entity is configured — matches the failing consumer order.
139+
modelBuilder.MapStringEnumAsPostgresEnum<Sport>();
140+
141+
modelBuilder.Entity<Tournament>(t =>
142+
{
143+
t.ComplexProperty(x => x.Schedule, schedule =>
144+
{
145+
schedule.ToJson("schedule");
146+
schedule.ComplexCollection(s => s.Stages, stage =>
147+
{
148+
stage.Property(s => s.Splits).HasConversion(new DictionaryToStringConverter());
149+
});
150+
});
151+
});
152+
}
153+
154+
public IModel DesignTimeModel => this.GetService<IDesignTimeModel>().Model;
155+
}
156+
157+
[Fact]
158+
public void MapStringEnumAsPostgresEnum_DoesNotProbeCustomConvertedComplexCollectionProperties()
159+
{
160+
using var context = new TournamentContext();
161+
162+
var act = () => context.DesignTimeModel;
163+
164+
act.Should().NotThrow();
165+
}
97166
}

0 commit comments

Comments
 (0)