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.63k stars 3.15k forks source link

System.InvalidCastException when using custom collection as navigation collection #34247

Closed alex-samuilov closed 1 month ago

alex-samuilov commented 1 month ago

Hello everyone!

I introduced a custom collection for accessing parameters through its friendly ID - ParameterCollection (in fact, the collection works with all entities that have this “friendly id”):

public interface IFriendlyId
{
   public string FriendlyId { get; }
}
public class ParameterCollection<T> : IReadOnlyCollection<T> where T : IFriendlyId
{
   private readonly Dictionary<string, T> _items;

   public ParameterCollection(IEnumerable<T> parameters)
   {
     _items = new Dictionary<string, T>(
         parameters.Select(parameter => new KeyValuePair<string, T>(parameter.FriendlyId, parameter)));
   }

   public T this[string name] => _items[name];

   public IEnumerator<T> GetEnumerator() => _items.Values.GetEnumerator();

   IEnumerator IEnumerable.GetEnumerator() => _items.Values.GetEnumerator();

   public int Count => _items.Count;
}

And I'm trying to use it as a navigation collection for an ObjectScheme entity consisting of multiple FieldSchemes:

public class FieldScheme : IFriendlyId
{
   public Guid Id { get; set; }

   public required string Name { get; init; }

   public string FriendlyId => Name;
}
public class ObjectScheme
{
   public Guid Id { get; set; }

   public required string Name { get; init; }

   private readonly ICollection<FieldScheme> _fields = new List<FieldScheme>();

   public required ParameterCollection<FieldScheme> Fields
   {
     get => new(_fields);
     init => _fields = new List<FieldScheme>(value);
   }
}

Entity configuration is following:

public void Configure(EntityTypeBuilder<ObjectScheme> builder)
{
   builder.HasKey(t => t.Id);
   builder.HasMany(t => t.Fields)
      .WithMany();

   builder.Property(t => t.Name).IsRequired();
}

My DbContext:

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
   public DbSet<ObjectScheme> ObjectSchemes => Set<ObjectScheme>();

   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
     modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
   }
}

However, when adding an entity (dbContext.ObjectSchemes.Add(contactScheme)), an exception is thrown:

System.InvalidCastException
Unable to cast object of type 'System.Collections.Generic.List`1[Core.FieldScheme]' to type 'Core.ParameterCollection`1[Core.FieldScheme]'.
   at lambda_method44(Closure, ObjectScheme)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.ReadPropertyValue(IPropertyBase propertyBase)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.get_Item(IPropertyBase propertyBase)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.InitialFixup(InternalEntityEntry entry, InternalEntityEntry duplicateEntry, Boolean fromQuery)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.StateChanged(InternalEntityEntry entry, EntityState oldState, Boolean fromQuery)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntryNotifier.StateChanged(InternalEntityEntry entry, EntityState oldState, Boolean fromQuery)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.FireStateChanged(EntityState oldState)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetEntityState(EntityState oldState, EntityState newState, Boolean acceptChanges, Boolean modifyProperties)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetEntityState(EntityState entityState, Boolean acceptChanges, Boolean modifyProperties, Nullable`1 forceStateWhenUnknownKey, Nullable`1 fallbackState)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityGraphAttacher.PaintAction(EntityEntryGraphNode`1 node)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityEntryGraphIterator.TraverseGraph[TState](EntityEntryGraphNode`1 node, Func`2 handleNode)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityGraphAttacher.AttachGraph(InternalEntityEntry rootEntry, EntityState targetState, EntityState storeGeneratedWithKeySetTargetState, Boolean forceStateWhenUnknownKey)
   at Microsoft.EntityFrameworkCore.Internal.InternalDbSet`1.SetEntityState(InternalEntityEntry entry, EntityState entityState)
   at Microsoft.EntityFrameworkCore.Internal.InternalDbSet`1.Add(TEntity entity)
   at Test.Playground.Test() in /Users/alexander.samuilov/Documents/Work/Source/CustomNavigationCollection/Test/Playground.cs:line 12
   at Xunit.Sdk.TestInvoker`1.<>c__DisplayClass48_0.<<InvokeTestMethodAsync>b__1>d.MoveNext() in /_/src/xunit.execution/Sdk/Frameworks/Runners/TestInvoker.cs:line 276
--- End of stack trace from previous location ---
   at Xunit.Sdk.ExecutionTimer.AggregateAsync(Func`1 asyncAction) in /_/src/xunit.execution/Sdk/Frameworks/ExecutionTimer.cs:line 48
   at Xunit.Sdk.ExceptionAggregator.RunAsync(Func`1 code) in /_/src/xunit.core/Sdk/ExceptionAggregator.cs:line 90

Adding conversion methods from List to ParameterCollection also doesn't give a positive result - they aren't even called.

My model is:

Model: 
  EntityType: FieldScheme
    Properties: 
      Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Name (string) Required
    Skip navigations: 
      ObjectScheme (no field, IEnumerable<ObjectScheme>) CollectionObjectScheme Inverse: Fields
    Keys: 
      Id PK
  EntityType: ObjectScheme
    Properties: 
      Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Name (string) Required
    Skip navigations: 
      Fields (_fields, ParameterCollection<FieldScheme>) CollectionFieldScheme Inverse: ObjectScheme
    Keys: 
      Id PK
  EntityType: FieldSchemeObjectScheme (Dictionary<string, object>) CLR Type: Dictionary<string, object>
    Properties: 
      FieldsId (no field, Guid) Indexer Required PK FK AfterSave:Throw
      ObjectSchemeId (no field, Guid) Indexer Required PK FK Index AfterSave:Throw
    Keys: 
      FieldsId, ObjectSchemeId PK
    Foreign keys: 
      FieldSchemeObjectScheme (Dictionary<string, object>) {'FieldsId'} -> FieldScheme {'Id'} Required Cascade
      FieldSchemeObjectScheme (Dictionary<string, object>) {'ObjectSchemeId'} -> ObjectScheme {'Id'} Required Cascade
    Indexes: 
      ObjectSchemeId

As far as I can see, EF Core found a backing field (_fields) in the navigation collection. But if you explicitly configure the entity to save the _fields field instead of the Fields property, the exception above isn't thrown.

I would be glad for any advice on what I'm doing wrong.

Thank you in advance.

Additional information: EF Core version: 8.0.7 Database provider: Npgsql.EntityFrameworkCore.PostgreSQL, version 8.0.4 Target framework: .NET 8.0 Operating system: macOS Sonoma 14.5 IDE: Rider 2024.1

cincuranet commented 1 month ago

I'm not sure I understand what are you trying to achieve. But basically your Fields property is of type ParameterCollection<FieldScheme>, yet the backing field _fields is ICollection<FieldScheme>/List<FieldScheme>.

As I'm not sure what is your goal, it's hard say what direction to take. One option would be to have not mapped property for ParameterCollection<FieldScheme> and regular ICollection<FieldScheme> for rest of what EF is doing. Something like this:

using var db = new MyContext();
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
var contactScheme = new ObjectScheme()
{
    Name = "test",
    FriendlyFields = new(
    [
        new FieldScheme { Name = "test" }
    ]),
};
db.Set<ObjectScheme>().Add(contactScheme);

public interface IFriendlyId
{
    public string FriendlyId { get; }
}
public class ParameterCollection<T> : IReadOnlyCollection<T> where T : IFriendlyId
{
    private readonly Dictionary<string, T> _items;

    public ParameterCollection(IEnumerable<T> parameters)
    {
        _items = new Dictionary<string, T>(parameters.Select(parameter => new KeyValuePair<string, T>(parameter.FriendlyId, parameter)));
    }

    public T this[string name] => _items[name];
    public IEnumerator<T> GetEnumerator() => _items.Values.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => _items.Values.GetEnumerator();
    public int Count => _items.Count;
}
public class FieldScheme : IFriendlyId
{
    public Guid Id { get; set; }
    public required string Name { get; init; }
    public string FriendlyId => Name;
}
public class ObjectScheme
{
    public Guid Id { get; set; }
    public required string Name { get; init; }
    private readonly ICollection<FieldScheme> _fields = new List<FieldScheme>();
    public required ParameterCollection<FieldScheme> FriendlyFields
    {
        get => new(_fields);
        init => _fields = new List<FieldScheme>(value);
    }
    public ICollection<FieldScheme> Fields
    {
        get => _fields;
        init => _fields = value;
    }
}

class MyContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);
        optionsBuilder.UseSqlite("Data Source=test.db");
        optionsBuilder.LogTo(Console.WriteLine);
        optionsBuilder.EnableSensitiveDataLogging();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<ObjectScheme>(Configure);
        void Configure(EntityTypeBuilder<ObjectScheme> builder)
        {
            builder.HasKey(t => t.Id);
            builder.HasMany(t => t.Fields)
               .WithMany();
            builder.Property(t => t.Name).IsRequired();
            builder.Ignore(x => x.FriendlyFields);
        }
    }
}
alex-samuilov commented 1 month ago

@cincuranet, thank you for your time.

I want to allow clients of a class to only access fields through the ParameterCollection. I can of course change the entity configuration to directly store the _fields something like that:

builder.Ignore(t => t.Fields);
builder.HasMany("_fields").WithMany();

And it works. However, my question is more about why it doesn't work in my original case. Moreover, EF Core validates the configuration (at least the System.InvalidOperationException exception is not thrown through the Microsoft.EntityFrameworkCore.Infrastructure.ModelValidator.ThrowPropertyNotMappedException method), and correctly finds the backing field (_fields). And since EF Core found the backing field, then what is the difference between my original code and the configuration that uses _field directly?

cincuranet commented 1 month ago

why it doesn't work in my original case

Because Fields property is not ignored there.

exception is not thrown

That's fine. Because you can have ICollection<FieldScheme> that would cast to ParameterCollection<FieldScheme>.

And since EF Core found the backing field, then what is the difference between my original code and the configuration that uses _field directly?

Now the Field, because it is ignored, cannot be used for i.e. querying or any data manipulation through EF Core. For EF Core it's like the property is not there.