dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.75k stars 3.18k forks source link

Inconsistent behavior related to relationship cycle #32422

Closed dzsibi closed 10 months ago

dzsibi commented 11 months ago

Hello,

Take the following DbContext:

using Microsoft.EntityFrameworkCore;

namespace CircularKeys;

public class CircularContext : DbContext
{
    public record ChildEntity(string ParentId, string Id);

    public record ParentEntity(string Id, string? FavoriteChildId);

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseNpgsql("Host=localhost;Database=postgres;Username=postgres;Password=postgres");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var parent = modelBuilder.Entity<ParentEntity>();
        parent.HasKey(e => e.Id);
        parent.HasOne<ChildEntity>()
            .WithOne()
            .HasForeignKey<ParentEntity>(x => new { x.Id, x.FavoriteChildId });

        var child = modelBuilder.Entity<ChildEntity>();
        child.HasKey(e => new { e.ParentId, e.Id });
        child.HasOne<ParentEntity>()
            .WithMany()
            .HasForeignKey(e => e.ParentId);
    }
}

When I want to add a migration using dotnet ef migrations add Init --verbose, it works fine. Let's add another entity:

using Microsoft.EntityFrameworkCore;

namespace CircularKeys;

public class CircularContext : DbContext
{
    public record ChildEntity(string ParentId, string Id);

    public record ParentEntity(string Id, string? FavoriteChildId);

    public record AnotherEntity(string ParentId, string Id);

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseNpgsql("Host=localhost;Database=postgres;Username=postgres;Password=postgres");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var parent = modelBuilder.Entity<ParentEntity>();
        parent.HasKey(e => e.Id);
        parent.HasOne<ChildEntity>()
            .WithOne()
            .HasForeignKey<ParentEntity>(x => new { x.Id, x.FavoriteChildId });

        var child = modelBuilder.Entity<ChildEntity>();
        child.HasKey(e => new { e.ParentId, e.Id });
        child.HasOne<ParentEntity>()
            .WithMany()
            .HasForeignKey(e => e.ParentId);

        var another = modelBuilder.Entity<AnotherEntity>();
        another.HasKey(e => new { e.ParentId, e.Id });
        another.HasOne<ParentEntity>()
            .WithMany()
            .HasForeignKey(e => e.ParentId);
    }
}

A new migration can no longer be added, with the following error message:

Microsoft.EntityFrameworkCore.Design.OperationException: Unable to create a 'DbContext' of type ''. The exception 'A relationship cycle involving the property 'AnotherEntity.ParentId' was detected. This prevents Entity Framework from determining the correct configuration. Review the foreign keys defined on the property and the corresponding principal property and either remove one of them or specify 'ValueConverter' explicitly on one of the properties.' was thrown while attempting to create an instance. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728
 ---> System.InvalidOperationException: A relationship cycle involving the property 'AnotherEntity.ParentId' was detected. This prevents Entity Framework from determining the correct configuration. Review the foreign keys defined on the property and the corresponding principal property and either remove one of them or specify 'ValueConverter' explicitly on one of the properties.
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.GetValueConverter()
   at Microsoft.EntityFrameworkCore.Storage.TypeMappingInfo..ctor(IReadOnlyList`1 principals, Nullable`1 fallbackUnicode, Nullable`1 fallbackSize, Nullable`1 fallbackPrecision, Nullable`1 fallbackScale)
   at Microsoft.EntityFrameworkCore.Storage.RelationalTypeMappingInfo..ctor(IReadOnlyList`1 principals, String storeTypeName, String storeTypeNameBase, Nullable`1 fallbackUnicode, Nullable`1 fallbackFixedLength, Nullable`1 fallbackSize, Nullable`1 fallbackPrecision, Nullable`1 fallbackScale)
   at Microsoft.EntityFrameworkCore.Storage.RelationalTypeMappingSource.FindMapping(IProperty property)
   at Microsoft.EntityFrameworkCore.Metadata.Conventions.ElementMappingConvention.<ProcessModelFinalizing>g__Validate|4_0(IConventionTypeBase typeBase)
   at Microsoft.EntityFrameworkCore.Metadata.Conventions.ElementMappingConvention.ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext`1 context)
   at Microsoft.EntityFrameworkCore.Metadata.Conventions.Internal.ConventionDispatcher.ImmediateConventionScope.OnModelFinalizing(IConventionModelBuilder modelBuilder)
   at Microsoft.EntityFrameworkCore.Metadata.Conventions.Internal.ConventionDispatcher.OnModelFinalizing(IConventionModelBuilder modelBuilder)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Model.FinalizeModel()
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelRuntimeInitializer.Initialize(IModel model, Boolean designTime, IDiagnosticsLogger`1 validationLogger)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.GetModel(DbContext context, ModelCreationDependencies modelCreationDependencies, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Internal.DbContextServices.CreateModel(Boolean designTime)
   at Microsoft.EntityFrameworkCore.Internal.DbContextServices.get_Model()
   at Microsoft.EntityFrameworkCore.Infrastructure.EntityFrameworkServicesBuilder.<>c.<TryAddCoreServices>b__8_4(IServiceProvider p)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(FactoryCallSite factoryCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass2_0.<RealizeService>b__0(ServiceProviderEngineScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(ServiceIdentifier serviceIdentifier, ServiceProviderEngineScope serviceProviderEngineScope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
   at Microsoft.EntityFrameworkCore.DbContext.get_DbContextDependencies()
   at Microsoft.EntityFrameworkCore.DbContext.get_ContextServices()
   at Microsoft.EntityFrameworkCore.DbContext.get_InternalServiceProvider()
   at Microsoft.EntityFrameworkCore.DbContext.Microsoft.EntityFrameworkCore.Infrastructure.IInfrastructure<System.IServiceProvider>.get_Instance()
   at Microsoft.EntityFrameworkCore.Infrastructure.Internal.InfrastructureExtensions.GetService(IInfrastructure`1 accessor, Type serviceType)
   at Microsoft.EntityFrameworkCore.Infrastructure.Internal.InfrastructureExtensions.GetService[TService](IInfrastructure`1 accessor)
   at Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService[TService](IInfrastructure`1 accessor)
   at Microsoft.EntityFrameworkCore.Design.Internal.DbContextOperations.CreateContext(String contextType)
   --- End of inner exception stack trace ---
   at Microsoft.EntityFrameworkCore.Design.Internal.DbContextOperations.CreateContext(String contextType)
   at Microsoft.EntityFrameworkCore.Design.Internal.MigrationsOperations.AddMigration(String name, String outputDir, String contextType, String namespace)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.AddMigrationImpl(String name, String outputDir, String contextType, String namespace)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.AddMigration.<>c__DisplayClass0_0.<.ctor>b__0()
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.OperationBase.<>c__DisplayClass3_0`1.<Execute>b__0()
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.OperationBase.Execute(Action action)
Unable to create a 'DbContext' of type ''. The exception 'A relationship cycle involving the property 'AnotherEntity.ParentId' was detected. This prevents Entity Framework from determining the correct configuration. Review the foreign keys defined on the property and the corresponding principal property and either remove one of them or specify 'ValueConverter' explicitly on one of the properties.' was thrown while attempting to create an instance. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728

The new entity did not introduce any new relationship cycles, yet EF was able to handle the previous one just fine. So I started digging. The exception is thrown here:

https://github.com/dotnet/efcore/blob/45673126512a0fe99ef73bf5c1e5701012fd9c26/src/EFCore/Metadata/Internal/Property.cs#L812C22-L812C22

There is this condition:

if (principalProperty == this || principalProperty == property)

In the first case, on the second cycle, this condition is triggered with principalProperty == this, and the function returns. In the second case, with the additional entity, this does not happen, and an exception is thrown. I am not sure why this behaves like this. If the goal was to detect cycles and throw, it fails to throw on the trivial cycle in the first example. If the goal was to break cycles, it throws anyways if there is a "leaf", as in a related entity that is not included in the cycle.

So let's try following the recommendation in the exception, and add value converters to the properties involved:

using Microsoft.EntityFrameworkCore;

namespace CircularKeys;

public class CircularContext : DbContext
{
    public record ChildEntity(string ParentId, string Id);

    public record ParentEntity(string Id, string? FavoriteChildId);

    public record AnotherEntity(string ParentId, string Id);

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseNpgsql("Host=localhost;Database=postgres;Username=postgres;Password=postgres");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var parent = modelBuilder.Entity<ParentEntity>();
        parent.HasKey(e => e.Id);
        parent.HasOne<ChildEntity>()
            .WithOne()
            .HasForeignKey<ParentEntity>(x => new { x.Id, x.FavoriteChildId });
        parent.Property(e => e.Id).HasConversion(e => e, e => e);
        parent.Property(e => e.FavoriteChildId).HasConversion(e => e, e => e);

        var child = modelBuilder.Entity<ChildEntity>();
        child.HasKey(e => new { e.ParentId, e.Id });
        child.HasOne<ParentEntity>()
            .WithMany()
            .HasForeignKey(e => e.ParentId);
        child.Property(e => e.Id).HasConversion(e => e, e => e);
        child.Property(e => e.ParentId).HasConversion(e => e, e => e);

        var another = modelBuilder.Entity<AnotherEntity>();
        another.HasKey(e => new { e.ParentId, e.Id });
        another.HasOne<ParentEntity>()
            .WithMany()
            .HasForeignKey(e => e.ParentId);
    }
}

This works, and the migration is generated as expected. We are not out of the woods yet, however, as dotnet ef database update --verbose will fail with the following error:

System.InvalidOperationException: A relationship cycle involving the property 'CircularKeys.CircularContext+AnotherEntity (Dictionary<string, object>).ParentId' was detected. This prevents Entity Framework from determining the correct configuration. Review the foreign keys defined on the property and the corresponding principal property and either remove one of them or specify 'ValueConverter' explicitly on one of the properties.
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.GetValueConverter()
   at Microsoft.EntityFrameworkCore.Storage.TypeMappingInfo..ctor(IReadOnlyList`1 principals, Nullable`1 fallbackUnicode, Nullable`1 fallbackSize, Nullable`1 fallbackPrecision, Nullable`1 fallbackScale)
   at Microsoft.EntityFrameworkCore.Storage.RelationalTypeMappingInfo..ctor(IReadOnlyList`1 principals, String storeTypeName, String storeTypeNameBase, Nullable`1 fallbackUnicode, Nullable`1 fallbackFixedLength, Nullable`1 fallbackSize, Nullable`1 fallbackPrecision, Nullable`1 fallbackScale)
   at Microsoft.EntityFrameworkCore.Storage.RelationalTypeMappingSource.FindMapping(IProperty property)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.<>c.<get_TypeMapping>b__91_0(IProperty property)
   at Microsoft.EntityFrameworkCore.Internal.NonCapturingLazyInitializer.EnsureInitialized[TParam,TValue](TValue& target, TParam param, Func`2 valueFactory)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.get_TypeMapping()
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.Microsoft.EntityFrameworkCore.Metadata.IReadOnlyProperty.FindTypeMapping()
   at Microsoft.EntityFrameworkCore.RelationalPropertyExtensions.FindRelationalTypeMapping(IReadOnlyProperty property)
   at Microsoft.EntityFrameworkCore.RelationalPropertyExtensions.GetColumnType(IReadOnlyProperty property)
   at Microsoft.EntityFrameworkCore.RelationalPropertyExtensions.GetColumnType(IProperty property)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalModel.AddDefaultMappings(RelationalModel databaseModel, IEntityType entityType, IRelationalTypeMappingSource relationalTypeMappingSource)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalModel.Create(IModel model, IRelationalAnnotationProvider relationalAnnotationProvider, IRelationalTypeMappingSource relationalTypeMappingSource, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalModel.Add(IModel model, IRelationalAnnotationProvider relationalAnnotationProvider, IRelationalTypeMappingSource relationalTypeMappingSource, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Infrastructure.RelationalModelRuntimeInitializer.InitializeModel(IModel model, Boolean designTime, Boolean prevalidation)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelRuntimeInitializer.Initialize(IModel model, Boolean designTime, IDiagnosticsLogger`1 validationLogger)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.FinalizeModel(IModel model)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.GenerateUpSql(Migration migration, MigrationsSqlGenerationOptions options)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.<>c__DisplayClass16_2.<GetMigrationCommandLists>b__2()
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.Migrate(String targetMigration)
   at Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Internal.NpgsqlMigrator.Migrate(String targetMigration)
   at Microsoft.EntityFrameworkCore.Design.Internal.MigrationsOperations.UpdateDatabase(String targetMigration, String connectionString, String contextType)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.UpdateDatabaseImpl(String targetMigration, String connectionString, String contextType)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.UpdateDatabase.<>c__DisplayClass0_0.<.ctor>b__0()
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.OperationBase.Execute(Action action)
A relationship cycle involving the property 'CircularKeys.CircularContext+AnotherEntity (Dictionary<string, object>).ParentId' was detected. This prevents Entity Framework from determining the correct configuration. Review the foreign keys defined on the property and the corresponding principal property and either remove one of them or specify 'ValueConverter' explicitly on one of the properties.

Looks familiar, but the stack is very different. It turns out that the generated migration conveniently forgot about the value converters:

modelBuilder.Entity("CircularKeys.CircularContext+ParentEntity", b =>
    {
        b.Property<string>("Id")
            .HasColumnType("text");

        b.Property<string>("FavoriteChildId")
            .HasColumnType("text");

        b.HasKey("Id");

        b.HasIndex("Id", "FavoriteChildId")
            .IsUnique();

        b.ToTable("ParentEntity");
    });

And when this is loaded, the annotation is once again missing. We could work around this by overriding the implementation of AnnotationCodeGenerator:

using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.Extensions.DependencyInjection;
using Npgsql.EntityFrameworkCore.PostgreSQL.Design.Internal;

namespace CircularKeys;

public class DesignTimeServices : IDesignTimeServices
{
    public void ConfigureDesignTimeServices(IServiceCollection serviceCollection)
    {
        serviceCollection.AddSingleton<IAnnotationCodeGenerator, CircularAnnotationCodeGenerator>();
    }
}

public class CircularAnnotationCodeGenerator : NpgsqlAnnotationCodeGenerator
{
    public CircularAnnotationCodeGenerator(AnnotationCodeGeneratorDependencies dependencies) : base(dependencies) { }

    public override IReadOnlyList<MethodCallCodeFragment> GenerateFluentApiCalls(IProperty property, IDictionary<string, IAnnotation> annotations)
    {
        var ret = base.GenerateFluentApiCalls(property, annotations);

        if ((property.DeclaringType.ClrType == typeof(CircularContext.ParentEntity) &&
             property.PropertyInfo?.Name is nameof(CircularContext.ParentEntity.Id) or nameof(CircularContext.ParentEntity.FavoriteChildId)))
        {
            var valueConverterFragment = new MethodCallCodeFragment(
                nameof(PropertyBuilder.HasAnnotation), CoreAnnotationNames.ValueConverter, null);
            var providerClrTypeFragment = new MethodCallCodeFragment(
                nameof(PropertyBuilder.HasAnnotation), CoreAnnotationNames.ProviderClrType, null);
            return ret.Concat(new[] { valueConverterFragment, providerClrTypeFragment }).ToList();
        }

        return ret;
    }
}

This will include a null-valued annotation in the generated code for ValueConverter and ProviderClrType, and the migration will run:

modelBuilder.Entity("CircularKeys.CircularContext+ParentEntity", b =>
    {
        b.Property<string>("Id")
            .HasColumnType("text")
            .HasAnnotation("ValueConverter", null)
            .HasAnnotation("ProviderClrType", null);

        b.Property<string>("FavoriteChildId")
            .HasColumnType("text")
            .HasAnnotation("ValueConverter", null)
            .HasAnnotation("ProviderClrType", null);

        b.HasKey("Id");

        b.HasIndex("Id", "FavoriteChildId")
            .IsUnique();

        b.ToTable("ParentEntity");
    });

Once again, I don't know whether this is intentional or not. The recommended workaround does not survive code generation, and overriding methods in AnnotationCodeGenerator seems hacky at best. To me, this whole thing smells like a bug somewhere, but I am not familiar enough with the codebase to pinpoint exactly where.

Provider and version information

EF Core version: 8.0 Database provider: Npgsql.EntityFrameworkCore.PostgreSQL Target framework: .NET 8.0 Operating system: Ubuntu 22.04 IDE: JetBrains Rider 2023.2.3

TXP-AlbertPucciani commented 11 months ago

I've found this happening to several of our code bases, this worked in EF6 and EF7. It's trivial to reproduce having an entity like this:

class Entity {
  public int Id { get; set; }

  public Entity Parent { get; set; }

  public int? ParentId { get; set; }

  public ICollection<Entity> Children { get; set; } = new HashSet<Entity>();
}
ajcvickers commented 11 months ago

/cc @AndriySvyryd

bbascarevic commented 11 months ago

Same case here. The issue is preventing us from upgrading to .Net 8.

bbascarevic commented 11 months ago

I just tried debugging this in EF 7. It turns out the only reason it works is because Property.GetValueConverter() isn't throwing but is silently giving up and returning null after 10_000 tries.

AndriySvyryd commented 10 months ago

The first part was a deliberate breaking change, the second part (when generating the next migration) wasn't

RenesansJG commented 10 months ago

@AndriySvyryd It seems that the current algorithm breaks the loop when we get back to the property we've originally started with or the next property would be the same as the current one. Wouldn't it be better to maintain a set of the properties we've already traversed and break when we get to a property already in the set?

Here you can see exactly what I mean: https://github.com/RenesansJG/efcore/commit/e531d9d055bd787005ea0709bfa7bc0623509bcd

I tried it out and it seemed to solve the problem presented in the original post. I know the extra allocation wouldn't do any good for performance but it can be optimized so that the allocation only gets done if it's actually needed.

AndriySvyryd commented 10 months ago

Wouldn't it be better to maintain a set of the properties we've already traversed and break when we get to a property already in the set?

It would be more accurate, but would have a measurable negative perf impact. We can improve the cycle breaking logic without tracking more than 1 property.

Peter-B- commented 10 months ago

This issue also surfaces during scaffolding.

I try to scaffold an Oracle database created with EF (not-core) 6. Scaffolding works with Microsoft.EntityFrameworkCore.Relational 7.0.14 and Oracle.EntityFrameworkCore 7.21.12, but fails with Microsoft.EntityFrameworkCore.Relational 8.0.0 and Oracle.EntityFrameworkCore 8.21.121.

This is quite inconvenient, because there is not much I can do about the existing database structure (which works fine, by the way). So this new behavior de facto breaks scaffolding for my database.

When I create a DbContext with EF 7 and then upgrade to EF 8 I get a runtime error, but at least I can now try to adapt the structure to get a consistent EF 8 model.

ErikEJ commented 10 months ago

@Peter-B- I think this will be fixed in 8.0.2 - and how is it "failing"?

Peter-B- commented 10 months ago

Hi @ErikEJ,

When I run dotnet ef DbContext scaffold ... it fails with this error message:

System.InvalidOperationException: A relationship cycle involving the property 'PcbType (Dictionary<string, object>).DutCoordinateSetId' was detected. This prevents Entity Framework from determining the correct configuration. Review the foreign keys defined on the property and the corresponding principal property and either remove one of them or specify 'ValueConverter' explicitly on one of the properties.
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.GetValueConverter()
   at Microsoft.EntityFrameworkCore.Storage.TypeMappingInfo..ctor(IReadOnlyList`1 principals, Nullable`1 fallbackUnicode, Nullable`1 fallbackSize, Nullable`1 fallbackPrecision, Nullable`1 fallbackScale)
   at Microsoft.EntityFrameworkCore.Storage.RelationalTypeMappingInfo..ctor(IReadOnlyList`1 principals, String storeTypeName, String storeTypeNameBase, Nullable`1 fallbackUnicode, Nullable`1 fallbackFixedLength, Nullable`1 fallbackSize, Nullable`1 fallbackPrecision, Nullable`1 fallbackScale)
   at Microsoft.EntityFrameworkCore.Storage.RelationalTypeMappingSource.FindMapping(IProperty property)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.<>c.<get_TypeMapping>b__91_0(IProperty property)
   at Microsoft.EntityFrameworkCore.Internal.NonCapturingLazyInitializer.EnsureInitialized[TParam,TValue](TValue& target, TParam param, Func`2 valueFactory)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.get_TypeMapping()
   at Microsoft.EntityFrameworkCore.Metadata.Internal.Property.Microsoft.EntityFrameworkCore.Metadata.IReadOnlyProperty.FindTypeMapping()
   at Microsoft.EntityFrameworkCore.RelationalPropertyExtensions.FindRelationalTypeMapping(IReadOnlyProperty property)
   at Microsoft.EntityFrameworkCore.RelationalPropertyExtensions.GetColumnType(IReadOnlyProperty property)
   at Microsoft.EntityFrameworkCore.RelationalPropertyExtensions.GetColumnType(IProperty property)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalModel.AddDefaultMappings(RelationalModel databaseModel, IEntityType entityType, IRelationalTypeMappingSource relationalTypeMappingSource)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalModel.Create(IModel model, IRelationalAnnotationProvider relationalAnnotationProvider, IRelationalTypeMappingSource relationalTypeMappingSource, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalModel.Add(IModel model, IRelationalAnnotationProvider relationalAnnotationProvider, IRelationalTypeMappingSource relationalTypeMappingSource, Boolean designTime)
   at Microsoft.EntityFrameworkCore.Infrastructure.RelationalModelRuntimeInitializer.InitializeModel(IModel model, Boolean designTime, Boolean prevalidation)
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelRuntimeInitializer.Initialize(IModel model, Boolean designTime, IDiagnosticsLogger`1 validationLogger)
   at Microsoft.EntityFrameworkCore.Scaffolding.Internal.RelationalScaffoldingModelFactory.Create(DatabaseModel databaseModel, ModelReverseEngineerOptions options)
   at Microsoft.EntityFrameworkCore.Scaffolding.Internal.ReverseEngineerScaffolder.ScaffoldModel(String connectionString, DatabaseModelFactoryOptions databaseOptions, ModelReverseEngineerOptions modelOptions, ModelCodeGenerationOptions codeOptions)
   at Microsoft.EntityFrameworkCore.Design.Internal.DatabaseOperations.ScaffoldContext(String provider, String connectionString, String outputDir, String outputContextDir, String dbContextClassName, IEnumerable`1 schemas, IEnumerable`1 tables, String modelNamespace, String contextNamespace, Boolean useDataAnnotations, Boolean overwriteFiles, Boolean useDatabaseNames, Boolean suppressOnConfiguring, Boolean noPluralize)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.ScaffoldContextImpl(String provider, String connectionString, String outputDir, String outputDbContextDir, String dbContextClassName, IEnumerable`1 schemaFilters, IEnumerable`1 tableFilters, String modelNamespace, String contextNamespace, Boolean useDataAnnotations, Boolean overwriteFiles, Boolean useDatabaseNames, Boolean suppressOnConfiguring, Boolean noPluralize)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.ScaffoldContext.<>c__DisplayClass0_0.<.ctor>b__0()
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.OperationBase.<>c__DisplayClass3_0`1.<Execute>b__0()
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.OperationBase.Execute(Action action)

No models or db context are created.

I saw that there are PRs for 8.0.2. I just wanted to bring to your attention, that this also affects scaffolding.

Thanks for your prompt reply and the fast fix Peter

nwoolls commented 8 months ago

Was this addressed in v8.0.201? I'm still getting this error with SDK 8.0.201 installed. I wanted to double-check before creating a sample to reproduce the issue.

Edit: scratch that! Upgrading the SDK to 8.0.201 did not work, but — maybe obviously? — updating EF NuGet packages to 8.0.2 did work.