diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index 02b8a4ba0b2..9cf8beceb75 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -163,7 +163,7 @@ private void CollectTypes(Context context, ISelection selection, TypeContainer p return BuildSelectionSetExpression(context, parent.Nodes[0]); } - private static MemberInitExpression? BuildSelectionSetExpression( + private static Expression? BuildSelectionSetExpression( Context context, TypeNode parent) { @@ -183,9 +183,84 @@ private void CollectTypes(Context context, ISelection selection, TypeContainer p return null; } - return Expression.MemberInit( - Expression.New(context.ParentType), - assignments.ToImmutable()); + var assignmentList = assignments.ToImmutable(); + + // Quick path: parameterless constructor + MemberInit + var parameterlessCtor = context.ParentType.GetConstructor(Type.EmptyTypes); + if (parameterlessCtor != null) + { + var allWritable = assignmentList.All(a => + a.Member is PropertyInfo { CanWrite: true, SetMethod.IsPublic: true }); + if (allWritable) + { + return Expression.MemberInit( + Expression.New(parameterlessCtor), + assignmentList); + } + } + + // Fallback: Use the best matching constructor. + // Argument names must match parameter names (case-insensitive), + // and argument types must be assignable to the parameter types. + var bestMatchingCtor = context.ParentType.GetConstructors() + .Select(c => (Constructor: c, Parameters: c.GetParameters())) + .OrderBy(cp => cp.Parameters.Length) + .FirstOrDefault(cp => + cp.Parameters.Length >= assignmentList.Length + && assignmentList.All(a => + cp.Parameters.Any(p => + string.Equals(a.Member.Name, p.Name, StringComparison.OrdinalIgnoreCase) + && a.Expression.Type.IsAssignableTo(p.ParameterType)))); + + if (bestMatchingCtor.Constructor != null) + { + var ctorParams = bestMatchingCtor.Parameters; + var args = ctorParams.Select(p => + { + var match = assignmentList.FirstOrDefault(a => + string.Equals(a.Member.Name, p.Name, StringComparison.OrdinalIgnoreCase) + && a.Expression.Type.IsAssignableTo(p.ParameterType)); + + if (match != null) + { + return match.Expression.Type == p.ParameterType + ? match.Expression + : Expression.Convert(match.Expression, p.ParameterType); + } + + if (p.HasDefaultValue) + { + return Expression.Convert(Expression.Constant(p.DefaultValue), p.ParameterType); + } + + if (!p.ParameterType.IsValueType && IsMarkedAsExplicitlyNonNullable(p)) + { + /* + * The constructor includes a non-nullable reference type (e.g., + * public record Foo(string Prop1, string Prop2);), + * but we cannot provide a value for 'Prop2' based on the current selection set, + * as 'Prop2' is not included in it. + * + * To fix this, consider updating the constructor to: + * - Make the parameter nullable: Foo(string Prop1, string? Prop2); + * - Provide a default value: Foo(string Prop1, string Prop2 = ""); + * - That may also be a null-forgiving one: Foo(string Prop1, string Prop2 = default!) + * + * Lets hope that this error is descriptive for the user. + */ + throw new InvalidOperationException( + $"Cannot construct '{context.ParentType.Name}': missing required argument '{p.Name}' " + + "(non-nullable reference type with no default value)."); + } + + return Expression.Default(p.ParameterType); + }).ToArray(); + + return Expression.New(bestMatchingCtor.Constructor, args); + } + + throw new InvalidOperationException( + $"No writable properties or suitable constructor found for type '{context.ParentType.Name}'."); } private void CollectSelection( @@ -350,4 +425,9 @@ private readonly record struct Context( : null; } } + + static bool IsMarkedAsExplicitlyNonNullable(ParameterInfo p) + { + return new NullabilityInfoContext().Create(p).WriteState is NullabilityState.NotNull; + } } diff --git a/src/HotChocolate/Data/test/Data.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.Tests/IntegrationTests.cs index 90fd097c207..aba0b4c69c8 100644 --- a/src/HotChocolate/Data/test/Data.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/IntegrationTests.cs @@ -9,6 +9,7 @@ using HotChocolate.Data.Sorting; using HotChocolate.Execution; using HotChocolate.Types; +using HotChocolate.Types.Pagination; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Data; @@ -960,6 +961,88 @@ public async Task AsSortDefinition_Descending_QueryContext_2() result.MatchSnapshot(); } + [Fact] + public async Task UsingQueryContext_ShouldNotBreak_Pagination_ForRecordReturnType() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddErrorFilter(x => new Error {Message = x.Exception!.Message}) + .AddFiltering() + .AddSorting() + .AddProjections() + .AddQueryType() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + query f { + # Selection set matches record-ctor params + u1: nonNullUsers { + edges { + node { + firstName + id + } + } + } + + # Selection set is subset of record-ctor params (incompatible, since we can`t provide a valid value) + u2: nonNullUsers { + edges { + node { + firstName + } + } + } + + # Selection set matches record-ctor params + u3: nonNullDefaultValuesUsers { + edges { + node { + id + firstName + zipCode + address + } + } + } + + # Selection set is subset of record-ctor params (compatible, since we can provide the default value) + u4: nonNullDefaultValuesUsers { + edges { + node { + firstName + } + } + } + + # Selection set matches record-ctor params + u5: nullableUsers { + edges { + node { + id + firstName + } + } + } + + # Selection set is subset of record-ctor params + u6: nullableUsers { + edges { + node { + firstName + } + } + } + } + """); + + // assert + result.MatchSnapshot(); + } + [QueryType] public static class StaticQuery { @@ -1214,4 +1297,42 @@ public IQueryable GetAuthorsData2(QueryContext context) }.AsQueryable() .With(context, t => t with { Operations = t.Operations.Add(SortBy.Ascending(t => t.Id)) }); } + +#pragma warning disable RCS1102 + public class RecordQuery +#pragma warning restore RCS1102 + { + [UsePaging] + [UseFiltering] + public Connection GetNonNullUsers(QueryContext query) + => Connection.Empty(); + + [UsePaging] + [UseFiltering] + public Connection GetNonNullDefaultValuesUsers(QueryContext query) + => Connection.Empty(); + + [UsePaging] + [UseFiltering] + public Connection GetNullableUsers(QueryContext query) + => Connection.Empty(); + + public record NonNullUser( + string Id, + string FirstName + ); + + public record NonNullDefaultValuesUser( + string Id = "", + string FirstName = "", + string ZipCode = null!, + // ReSharper disable once PreferConcreteValueOverDefault + string Address = default! + ); + + public record NullableUser( + string? Id, + string? FirstName + ); + } } diff --git a/src/HotChocolate/Data/test/Data.Tests/__snapshots__/IntegrationTests.UsingQueryContext_ShouldNotBreak_Pagination_ForRecordReturnType.snap b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/IntegrationTests.UsingQueryContext_ShouldNotBreak_Pagination_ForRecordReturnType.snap new file mode 100644 index 00000000000..2dab764c355 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/IntegrationTests.UsingQueryContext_ShouldNotBreak_Pagination_ForRecordReturnType.snap @@ -0,0 +1,34 @@ +{ + "errors": [ + { + "message": "Cannot construct 'NonNullUser': missing required argument 'Id' (non-nullable reference type with no default value).", + "locations": [ + { + "line": 13, + "column": 3 + } + ], + "path": [ + "u2" + ] + } + ], + "data": { + "u1": { + "edges": [] + }, + "u2": null, + "u3": { + "edges": [] + }, + "u4": { + "edges": [] + }, + "u5": { + "edges": [] + }, + "u6": { + "edges": [] + } + } +}