ardalis / Specification

Base class with tests for adding specifications to a DDD model
MIT License
1.95k stars 245 forks source link

EF Core throws Collection read-only exception when 'ThenInclude' Navigation is read-only #408

Closed aniekanBane closed 2 months ago

aniekanBane commented 2 months ago

Exception thrown when using specification package with an IReadOnlyCollection navigation property but not if using dbContext directly or removing the then include.

Exception view

at System.Collections.ObjectModel.ReadOnlyCollection`1.System.Collections.Generic.ICollection<T>.Add(T value)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.ClrICollectionAccessor`3.AddStandalone(Object collection, Object value)
   at Microsoft.EntityFrameworkCore.Metadata.Internal.ClrICollectionAccessor`3.Add(Object entity, Object value, Boolean forMaterialization)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.AddToCollection(INavigationBase navigationBase, Object value, Boolean forMaterialization)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.AddToCollection(InternalEntityEntry entry, INavigationBase navigation, InternalEntityEntry value, Boolean fromQuery)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.SetReferenceOrAddToCollection(InternalEntityEntry entry, INavigationBase navigation, InternalEntityEntry value, Boolean fromQuery)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.ToDependentFixup(InternalEntityEntry dependentEntry, InternalEntityEntry principalEntry, IForeignKey foreignKey, Boolean fromQuery)
   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.MarkUnchangedFromQuery()
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.StartTrackingFromQuery(IEntityType baseEntityType, Object entity, ValueBuffer& valueBuffer)
   at Microsoft.EntityFrameworkCore.Query.QueryContext.StartTracking(IEntityType entityType, Object entity, ValueBuffer valueBuffer)
   at lambda_method120(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
   at Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.<PopulateIncludeCollection>g__ProcessCurrentElementRow|23_0[TIncludingEntity,TIncludedEntity](<>c__DisplayClass23_0`2&)
   at Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.PopulateIncludeCollection[TIncludingEntity,TIncludedEntity](Int32 collectionId, QueryContext queryContext, DbDataReader dbDataReader, SingleQueryResultCoordinator resultCoordinator, Func`3 parentIdentifier, Func`3 outerIdentifier, Func`3 selfIdentifier, IReadOnlyList`1 parentIdentifierValueComparers, IReadOnlyList`1 outerIdentifierValueComparers, IReadOnlyList`1 selfIdentifierValueComparers, Func`5 innerShaper, INavigationBase inverseNavigation, Action`2 fixup, Boolean trackingQuery)
   at lambda_method130(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
   at Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.<PopulateIncludeCollection>g__ProcessCurrentElementRow|23_0[TIncludingEntity,TIncludedEntity](<>c__DisplayClass23_0`2&)
   at Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.PopulateIncludeCollection[TIncludingEntity,TIncludedEntity](Int32 collectionId, QueryContext queryContext, DbDataReader dbDataReader, SingleQueryResultCoordinator resultCoordinator, Func`3 parentIdentifier, Func`3 outerIdentifier, Func`3 selfIdentifier, IReadOnlyList`1 parentIdentifierValueComparers, IReadOnlyList`1 outerIdentifierValueComparers, IReadOnlyList`1 selfIdentifierValueComparers, Func`5 innerShaper, INavigationBase inverseNavigation, Action`2 fixup, Boolean trackingQuery)
   at lambda_method132(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
   at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable`1 source, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable`1 source, CancellationToken cancellationToken)

Example code

Entity

public sealed class A : Entity, IAggregateRoot
{
    // code .....

    private readonly List<B> _bs = [];
    public IReadOnlyCollection<B> Bs => _bs.AsReadOnly();
}

public sealed class B : Entity
{   
     // code .....

    private readonly List<C> _cs = [];
    public IReadOnlyCollection<C> Cs => _cs.AsReadOnly();
}

public sealed class C : Entity
{
     // code .....
}

Specification

public class AFilter : Specification<A, AResponse>
{
    public AFilter()
    {
        Query
            .Include(a => a.Bs)
            .ThenInclude(b => b.Cs)
            .AsSplitQuery();

        Query.Select(c => c.MapResponse());
    }
}

Service Call

internal sealed class AService(IDbContextFactory<AppDbContext> dbContextFactory) : IAService
{
    public async Task<IEnumerable<AResponse>> GetAllAsync(
        AFilter filter, CancellationToken cancellationToken = default)
    {
        using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
        var result = await dbContext.Set<A>()
            .AsQueryable()
            .WithSpecification(filter)
            .ToListAsync(cancellationToken);

        return result;
    }
}

Information

Ardalis.Specification == 8.0.0 Ardalis.Specification.EntityFrameworkCore == 8.0.0 Microsoft.EntityFrameworkCore == 8.0.8

.NET Core == 8

fiseni commented 2 months ago

If the collection is IEnumerable it works, right? I think it's a bug on our side, I suppose we're targeting only IEnumerable ThenInclude EF extensions.

fiseni commented 2 months ago

Hey @aniekanBane I'm testing this and I can't reproduce the issue. Now that I'm analyzing your code more closely, you actually have a projection. In the case of projections, EF ignores the Include statements anyway. There is something else going on. Can you post your mapping logic too?

aniekanBane commented 2 months ago

@fiseni Apologies, The error was from my dbContext configuration. My backing field did not match the navigation property. I have added builder.Navigation.HasField() and it works now. False Alarm 🚨