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.78k stars 3.19k forks source link

Infinity loop when joining entities #33021

Closed piotrkantorowicz closed 8 months ago

piotrkantorowicz commented 9 months ago

Hi guys,

I have a problem with joining two simple entities, because of infinity loop I guess (observed during debugging).
here is an objects and its entity types configurations:

public class BudgetPermission : IAggregateRoot
{
    private readonly DomainEventsSource _domainEventsSource = new();

    // Required by EF Core   
    private BudgetPermission()
    {
    }

    private BudgetPermission(BudgetPermissionId id, BudgetId budgetId, PersonId ownerId)
    {
        Id = id;
        BudgetId = budgetId;
        OwnerId = ownerId;
        Permissions = new List<Permission>();
    }

    public BudgetPermissionId Id { get; }

    public BudgetId BudgetId { get; }

    public PersonId OwnerId { get; }

    public ICollection<Permission> Permissions { get; }

    public static BudgetPermission Create(BudgetId budgetId, PersonId ownerId)
    {
        return new BudgetPermission(Guid.NewGuid(), budgetId, ownerId);
    } 

   ...

public class Permission
{
    // Required by EF Core   
    private Permission()
    {
    }

    private Permission(PersonId participantId, PermissionType permissionType)
    {
        DomainModelState.CheckBusinessRules([
            new UnknownPermissionTypeCannotBeProcessed(permissionType)
        ]);

        ParticipantId = participantId;
        PermissionType = permissionType;
    }

    public PersonId ParticipantId { get; }

    public PermissionType PermissionType { get; }

   ...

internal sealed class BudgetPermissionEntityTypeConfiguration : IEntityTypeConfiguration<BudgetPermission>
{
    public void Configure(EntityTypeBuilder<BudgetPermission> builder)
    {
        builder.HasKey(x => x.Id);

        builder
            .Property(x => x.Id)
            .HasConversion(x => x.Value, x => BudgetPermissionId.Create(x))
            .IsRequired();

        builder.HasIndex(x => x.BudgetId).IsUnique();

        builder
            .Property(x => x.BudgetId)
            .HasConversion(x => x.Value, x => BudgetId.Create(x))
            .IsRequired();

        builder
            .Property(x => x.OwnerId)
            .HasConversion(x => x.Value, x => PersonId.Create(x))
            .IsRequired();

        builder.OwnsMany(x => x.Permissions, permissionsBuilder =>
        {
            permissionsBuilder
                .Property(x => x.ParticipantId)
                .HasConversion(x => x.Value, x => PersonId.Create(x))
                .IsRequired();

            permissionsBuilder
                .Property(x => x.PermissionType)
                .HasConversion(x => x.Value, x => PermissionType.Create(x))
                .IsRequired();
        });
    }
}

When I fetching a single object in SingleOrDefaultAsync() clause this query has been produced and it also throws exception:

    public async Task<BudgetPermission?> SingleAsync(BudgetPermissionFilter filter,
        CancellationToken cancellationToken = default)
    {
        return await _budgetSharingDbContext
            .BudgetPermissions.Where(filter.ToFilterExpression())
            .SingleOrDefaultAsync(cancellationToken);
    }

SELECT t."Id", t."BudgetId", t."OwnerId", p."BudgetPermissionId", p."Id", p."ParticipantId", p."PermissionType" FROM ( SELECT b."Id", b."BudgetId", b."OwnerId" FROM "BudgetSharing"."BudgetPermissions" AS b WHERE b."Id"::uuid = @__filter_Id_0 LIMIT 2 ) AS t LEFT JOIN "BudgetSharing"."Permission" AS p ON t."Id" = p."BudgetPermissionId" ORDER BY t."Id", p."BudgetPermissionId"

System.InvalidOperationException: Sequence contains more than one element. at Microsoft.EntityFrameworkCore.Query.ShapedQueryCompilingExpressionVisitor.SingleOrDefaultAsync[TSource](IAsyncEnumerable1 asyncEnumerable, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Query.ShapedQueryCompilingExpressionVisitor.SingleOrDefaultAsync[TSource](IAsyncEnumerable1 asyncEnumerable, CancellationToken cancellationToken) at Expenso.BudgetSharing.Infrastructure.Persistence.EfCore.Repositories.Read.BudgetPermissionQueryStore.SingleAsync(Bud getPermissionFilter filter, CancellationToken cancellationToken) in C:\Users\piotr\Projects\Expenso\Src\BudgetSharing\Expenso.BudgetSharing.Infrastructure\Persistence\EfCore\Repositories\Read\BudgetPermissionQueryStore.cs:line 26 at Expenso.BudgetSharing.Application.Read.GetBudgetPermission.GetBudgetPermissionQueryHandler.HandleAsync(GetBudgetPerm issionQuery query, CancellationToken cancellationToken) in C:\Users\piotr\Projects\Expenso\Src\BudgetSharing\Expenso.BudgetSharing.Application\Read\GetBudgetPermission\GetBudgetPermissionQueryHandler.cs:line 28 at Expenso.BudgetSharing.Api.BudgetSharingModule.<>c.<b__6_0>d.MoveNext() in C:\Users\piotr\Projects\Expenso\Src\BudgetSharing\Expenso.BudgetSharing.Api\BudgetSharingModule.cs:line 156 --- End of stack trace from previous location --- at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task1 task, HttpContext httpContext) at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext) at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider) at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.<Invoke>g__Awaited|10_0(ExceptionHandlerMiddlewareImpl middleware, HttpContext context, Task task) fail: Expenso.Api.Configuration.Errors.GlobalExceptionHandler[0] Exception occurred: Sequence contains more than one element. System.InvalidOperationException: Sequence contains more than one element. at Microsoft.EntityFrameworkCore.Query.ShapedQueryCompilingExpressionVisitor.SingleOrDefaultAsync[TSource](IAsyncEnumerable1 asyncEnumerable, CancellationToken cancellationToken) at Microsoft.EntityFrameworkCore.Query.ShapedQueryCompilingExpressionVisitor.SingleOrDefaultAsync[TSource](IAsyncEnumerable`1 asyncEnumerable, CancellationToken cancellationToken)

EF Core version: Database provider: Postgres Target framework: NET 8.0

Could you explain me please what I doing wrong?

chadmobrien commented 9 months ago

The behavior of Single, SingleOrDefault, and their async implementations throw exceptions if the query made has more than one element. It means that your filter query is not specific enough to gather at most 1 result. Consider using FirstOrDefault(Async), update your query, or update your database relationships to ensure the query guarantees at most 1 result.

https://learn.microsoft.com/en-us/dotnet/api/system.data.entity.queryableextensions.singleordefaultasync?view=entity-framework-6.2.0

piotrkantorowicz commented 9 months ago

@chadmobrien, I aware about how SingleOrDefault() works and I tried also used FirstOrDefaultAsync() with the same result. In case when I use ToListAsync() request has been frozen and looping on BudgetPermission constructor.

I also tried configure this as HasMany instead of Owned enties and it working fine with single entity (BudgetPermission) but behaved exactly in the same manner when I used Include (Permissions).

roji commented 9 months ago

also used FirstOrDefaultAsync() with the same result

Are you saying you got System.InvalidOperationException: Sequence contains more than one element with FirstOrDefaultAsync()? That seems highly unlikely - please check exactly what it is you're trying. Otherwise, I think we need an actual runnable, minimal code sample (and not partial snippets) in order to help further.

piotrkantorowicz commented 9 months ago

@roji, Correct, same error in both cases SingleOrDefaultAsync()/FirstOrDefaultAsync(). I prepared basic example here - https://github.com/piotrkantorowicz/EfCore.Investigation

ajcvickers commented 9 months ago

@piotrkantorowicz Since the types your are converting to are reference types, they will use reference comparison by default. Either implement value semantics for non-entity types, or use a value comparer.

piotrkantorowicz commented 8 months ago

@ajcvickers, thanks for help. I was pretty sure that my typed ids were created as records and have value comparison built in.