ChilliCream / graphql-platform

Welcome to the home of the Hot Chocolate GraphQL server for .NET, the Strawberry Shake GraphQL client for .NET and Banana Cake Pop the awesome Monaco based GraphQL IDE.
https://chillicream.com
MIT License
5.24k stars 744 forks source link

Using records as a projection fails on QueryableProjectionScopeExtensions #6487

Open CasperCBroeren opened 1 year ago

CasperCBroeren commented 1 year ago

Is there an existing issue for this?

Product

Hot Chocolate

Describe the bug

If you use a record in a projection, it will fail because of trying to use the default constructor on it. Type 'PriceData' does not have a default constructor (Parameter 'type')

Steps to reproduce

  1. Create a record
  2. Create a query
  3. Mark query as projection
  4. Call the query
  5. Response has a result with error node

Relevant log output

message
: 
"Type 'PriceData' does not have a default constructor (Parameter 'type')"
stackTrace
: 
"   at System.Linq.Expressions.Expression.New(Type type)\r\n   at HotChocolate.Data.Projections.Expressions.QueryableProjectionScopeExtensions.CreateMemberInit(QueryableProjectionScope scope)\r\n   at HotChocolate.Data.Projections.Expressions.QueryableProjectionScopeExtensions.CreateMemberInitLambda(QueryableProjection 

Additional Context?

You can create records with Expressions and turn them in a MemberInitExpression. Still I don't know if this safe and sound. Below is an attempt to patch CreateMemberInit but this is untested

private static Expression GetMemberInit(Object val)
    {
        var isRecord = ((TypeInfo) val.Key).DeclaredProperties.Any(x => x.Name == "EqualityContract");
        if (isRecord)
        {
            var ctor = val.Key.GetConstructors()[0];
            return Expression.MemberInit(Expression.New(ctor, val.Value.Select(x => x.Expression)));
        }
        else
        {
            var ctor = Expression.New(val.Key);
            return Expression.MemberInit(ctor, val.Value);
        }
    }

public static Expression CreateMemberInit(this QueryableProjectionScope scope)
    {
        if (scope.HasAbstractTypes())
        {
            Expression lastValue = Expression.Default(scope.RuntimeType);

            foreach (var val in scope.GetAbstractTypes())
            {
                Expression memberInit = GetMemberInit(val);

                lastValue = Expression.Condition(
                    Expression.TypeIs(scope.Instance.Peek(), val.Key),
                    Expression.Convert(memberInit, scope.RuntimeType),
                    lastValue);
            }

            return lastValue;
        }
        else
        {
            var ctor = Expression.New(scope.RuntimeType);
            return Expression.MemberInit(ctor, scope.Level.Peek());
        }
    }

Version

13.5.0

kyrim commented 1 year ago

+1 on this. A work around i've used is creating a default constructor on your record, eg:

// In Query File.
[UseSingleOrDefault]
[UseProjection]
public IQueryable<Api.User?> GetUserById([Service] DbContext db, Guid userId)
{
   // Note: Db model is different to API model, need to only select out the fields that are required for GraphQL
   return db.Users.AsNoTracking().Where(x => x.Id == userId).Select(x => new Api.User(x.Id, x.Title, x.FirstName));
}

// ...

// API model
public record User(
  Guid Id,
  string Title,
  string FirstName
  )
{
  // Workaround default constructor issue
  public User() : this(Guid.NewGuid(), "", "")
  {
  }
}

However that opens up the record to being used incorrectly (accidentally using the default constructor) in other parts of code, which isn't ideal. I'm also unsure if this will cause other issues.

I'm using Hot Chocolate 13.5.1.

AlexHughesOk commented 3 months ago

Any further updates on this bug?

I have created a default constructor on my record

public record Organisation(
    Guid Key,
    string? DisplayName,
    string? LegalName,
    string? TradingName,
    DateTime CreatedOn,
    bool IsActive,
    List<BusinessUnit> BusinessUnits,
    Partner? Partner,
    Address? Address,
    Guid ContractNumber)
{
    public Organisation() : this(new Guid(), string.Empty, string.Empty, string.Empty, DateTime.MinValue,
        false, new List<BusinessUnit>(), null, null, new Guid())
    {

    }
}

But now I get a LINQ issue where all my mapping has been working perfectly until I tried to add UseProjection and UseFiltering.

{
  "errors": [
    {
      "message": "Unexpected Execution Error",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "organisations"
      ],
      "extensions": {
        "message": "The LINQ expression 'DbSet<Organisation>()\r\n    .Join(\r\n        inner: DbSet<Partner>(), \r\n        outerKeySelector: o => EF.Property<int?>(o, \"PartnerId\"), \r\n        innerKeySelector: p => EF.Property<int?>(p, \"Id\"), \r\n        resultSelector: (o, i) => new TransparentIdentifier<Organisation, Partner>(\r\n            Outer = o, \r\n            Inner = i\r\n        ))\r\n    .LeftJoin(\r\n        inner: DbSet<Address>(), \r\n        outerKeySelector: o => EF.Property<int?>(o.Outer, \"AddressId\"), \r\n        innerKeySelector: a => EF.Property<int?>(a, \"Id\"), \r\n        resultSelector: (o, i) => new TransparentIdentifier<TransparentIdentifier<Organisation, Partner>, Address>(\r\n            Outer = o, \r\n            Inner = i\r\n        ))\r\n    .Where(o => new Organisation{ }\r\n    .ContractNumber == __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.RelationalQueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)\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__DisplayClass12_0`1.<ExecuteAsync>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.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 Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable`1 source, CancellationToken cancellationToken)\r\n   at HotChocolate.Data.ToListMiddleware`1.InvokeAsync(IMiddlewareContext context)\r\n   at HotChocolate.Types.EntityFrameworkObjectFieldDescriptorExtensions.<>c__DisplayClass3_1`1.<<UseDbContext>b__3>d.MoveNext()\r\n--- End of stack trace from previous location ---\r\n   at HotChocolate.Execution.Processing.Tasks.ResolverTask.ExecuteResolverPipelineAsync(CancellationToken cancellationToken)\r\n   at HotChocolate.Execution.Processing.Tasks.ResolverTask.TryExecuteAsync(CancellationToken cancellationToken)"
      }
    }
  ],
  "data": null
}

Is this a version issue between AutoMapper and Hot Chocolate?