1

Using HotChocolate v15.1.8 with EF v9.0.7 and Mapster v7.4.0, I encounter a bug with [UseSorting] / [UseFiltering] LinQ conversions which appear invalid when applied to DDD ValueObject or EF complex property.

Executing this graphQL Query :

query Test {
  legalEntity(where: { label: { eq: "TestSociety" } }) {
    id
    label
  }
}

Gives me the following error :

"extensions": {
   "message": "The LINQ expression 'DbSet<LegalEntity>()\r\n    .Where(l => l.Label.Value == __p_0)' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.",
   "stackTrace": "   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)\r\n   at Microsoft.EntityFrameworkCore.Query.QueryCompilationContext.CreateQueryExecutorExpression[TResult](Expression query)\r\n   at Microsoft.EntityFrameworkCore.Query.QueryCompilationContext.CreateQueryExecutor[TResult](Expression query)\r\n   at Microsoft.EntityFrameworkCore.Storage.Database.CompileQuery[TResult](Expression query, Boolean async)\r\n   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.CompileQueryCore[TResult](IDatabase database, Expression query, IModel model, Boolean async)\r\n   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.<>c__DisplayClass11_0`1.<ExecuteCore>b__0()\r\n at Microsoft.EntityFrameworkCore.Query.Internal.CompiledQueryCache.GetOrAddQuery[TResult](Object cacheKey, Func`1 compiler)\r\n   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.ExecuteCore[TResult](Expression query, Boolean async, CancellationToken cancellationToken)\r\n   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.ExecuteAsync[TResult](Expression query, CancellationToken cancellationToken)\r\n   at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.ExecuteAsync[TResult](Expression expression, CancellationToken cancellationToken)\r\n   at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1.GetAsyncEnumerator(CancellationToken cancellationToken)\r\n   at System.Runtime.CompilerServices.ConfiguredCancelableAsyncEnumerable`1.GetAsyncEnumerator()\r\n at HotChocolate.DefaultAsyncEnumerableExecutable`1.ToListAsync(CancellationToken cancellationToken)\r\n   at HotChocolate.Execution.ListPostProcessor`1.ToCompletionResultAsync(Object result, CancellationToken cancellationToken)\r\n   at HotChocolate.Execution.Processing.Tasks.ResolverTask.ExecuteResolverPipelineAsync(CancellationToken cancellationToken)\r\n   at HotChocolate.Execution.Processing.Tasks.ResolverTask.TryExecuteAsync(CancellationToken cancellationToken)"
}

CODE

To obtain this result, I use HotChocolate

//Query
[Authorize]
[QueryType]
public class LegalEntityQueries
{
    [UseFiltering]
    [UseSorting]
    public IQueryable<LegalEntityGql> GetLegalEntities(
        AppDbContext dbContext,
        int? limit = null,
        int? offset = null
    )
    {
        return dbContext.LegalEntities.TakeIf(limit).SkipIf(offset).ProjectToType<LegalEntityGql>();
    }
}

To avoid my whole entity to be public, I have a Gql DTO with equal names

//DTO
public record LegalEntityGql
{
    public Guid Id { get; init; }
    public string Label { get; init; } // As you can see, Label is here as a primitive string
}

To convert my entity to a Gql, I use Mapster v7.4.0

//Mapster Config
internal sealed class ValueObjectMapsterConfig : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<Label, string>().MapWith(l => l.Value);
    }
}

Mapster and Hot Chocolate are configured in my program.cs with "AddGraphQL":

//Hot Chocolate config
public static class DependencyInjection
{
    public static IServiceCollection AddGraphQL(this IServiceCollection services)
    {
        TypeAdapterConfig.GlobalSettings.Scan(typeof(ValueObjectMapsterConfig).Assembly);

        services
            .AddGraphQLServer()
            .AddAuthorization()
            .AddGraphQLTypes()
            .AddProjections()
            .AddFiltering()
            .AddSorting();

        MapsterConfiguration.RegisterMappings();

        return services;
    }
}

I'm following a DDD perspective, with Domain including aggregated roots and value object usings :

//Domain object
public class LegalEntity : BaseEntity
{
    // 1. Properties
    public Label Label { get; private set; }

    // 2. Private constructor (used by factories)
    private LegalEntity(Guid? id, Label labelt)
        : base(id)
    {
        Label = label;
    }

    // 3. Factory methods
    public static Result<LegalEntity> Create(Label label) =>
        CreateFull(null, label);

    public static Result<LegalEntity> CreateFull(
        Guid? id,
        Label label,
    )
    {
        //To be reused immediatly, each legalEntity should be created using a new Guid
        Guid legalEntityId = id ?? Guid.NewGuid();

        return new LegalEntity(legalEntityId, label);
    }

    // 4. Muleability methods (behavior/modification)
    public void UpdateLabel(Label label)
    {
        Label = label;
    }

    // ORM constructor
    private LegalEntity() { }
}

My Label property is designed as a ValueObject, to preserve immutability inside the domain :

//ValueObject "Label"
public sealed class Label : ValueObject
{
    public const int MaxLength = 50;

    public string Value { get; }

    private Label(string value)
    {
        Value = value;
    }

    public static Result<Label> Create(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return Result.Failure<Label>(DomainErrors.Label.Empty);
        }

        return new Label(value);
    }

    protected override IEnumerable<object> GetAtomicValues()
    {
        yield return Value;
    }

    // ORM constructor
    private Label() { }
}

However, EF persist it as a single string in SQL, and convert it when reading it from db :

public static class AdminModelBuilderExtension
{
    public static void ConfigureAdminModule(this ModelBuilder modelBuilder)
    {    
        modelBuilder.Entity<LegalEntity>(entity =>
        {
            entity
                .Property(le => le.Label)
                .HasConversion(le => le.Value, v => Label.Create(v).Value);

            entity.HasIndex(le => le.Label).IsUnique();
        });
    }
}

Base of explanation

To read the GraphQL field Label, there is no problem using Mapster and the Label.Value conversion. But UseSorting/UseFiltering are automatically converting the (where: { label: { eq: "TestSociety" } }) expression to an invalid LinQ sequence. I've reproduced the problem by building 2 EF queries :

LegalEntity? test1 = await dbContext
    .LegalEntities.OrderBy(le => le.Label)
    .FirstOrDefaultAsync(CancellationToken.None);

LegalEntity? test2 = await dbContext
    .LegalEntities.OrderBy(le => le.Label.Value)
    .FirstOrDefaultAsync(CancellationToken.None);

In this example, test1 works successfully (and it's fine!), but test2 emits the same error :

The LINQ expression 'DbSet()\r\n .OrderBy(le => le.Label.Value)' could not be translated.

Thanks!

1 Answer 1

1

For anyone who has the same problem

Finally, the issue was not caused by Mapster or HotChocolate, but it was a misconfiguration for DDD ValueObject for EF itself.

Instead of

public static class AdminModelBuilderExtension
{
    public static void ConfigureAdminModule(this ModelBuilder modelBuilder)
    {    
        modelBuilder.Entity<LegalEntity>(entity =>
        {
            entity
                .Property(le => le.Label)
                .HasConversion(le => le.Value, v => Label.Create(v).Value);

            entity.HasIndex(le => le.Label).IsUnique();
        });
    }
}

The well suited configuration for a DDD is by using ComplexProperty, as mentionned for example in this article

In my case, the configuration has switch to

public static class AdminModelBuilderExtension
{
    public static void ConfigureAdminModule(this ModelBuilder modelBuilder)
    {    
        modelBuilder.Entity<LegalEntity>(entity =>
        {
            entity.ComplexProperty(
                le => le.Label,
                label =>
                    label
                        .Property(l => l.Value)
                        .HasColumnName(StringHelper.ToSnakeCase(nameof(LegalEntity.Label)))

            // Index cannot be created on complexProperty
            // They are instead manually created in the migration
            // https://github.com/dotnet/efcore/issues/17123
        });
    }
}

However, as you can see, EF fluent API doesn't allow to set index directly on complexProperty. Those index must be created manually in your migration file.

Working with that setup, all previous

LegalEntity? test = await dbContext
    .LegalEntities.OrderBy(le => le.Label)
    .FirstOrDefaultAsync(CancellationToken.None);

doesn't work anymore, and should always be updated to

LegalEntity? test = await dbContext
    .LegalEntities.OrderBy(le => le.Label.Value)
    .FirstOrDefaultAsync(CancellationToken.None);
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.