diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index 8c6794f93f1..7a238eeb062 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -189,7 +189,7 @@ private void CollectTypes(Context context, Selection selection, TypeContainer pa return BuildSelectionSetExpression(context, singleTypeNode); } - private static MemberInitExpression? BuildSelectionSetExpression( + private static Expression? BuildSelectionSetExpression( Context context, TypeNode parent) { @@ -210,9 +210,69 @@ private void CollectTypes(Context context, Selection selection, TypeContainer pa return null; } - return Expression.MemberInit( - Expression.New(context.ParentType), - assignments.ToImmutable()); + var assignmentList = assignments.ToImmutable(); + + // Preferred path for mutable types. + var parameterlessConstructor = context.ParentType.GetConstructor(Type.EmptyTypes); + if (parameterlessConstructor is not null) + { + var allWritable = assignmentList.All(a => + a.Member is PropertyInfo { CanWrite: true, SetMethod.IsPublic: true }); + + if (allWritable) + { + return Expression.MemberInit( + Expression.New(parameterlessConstructor), + assignmentList); + } + } + + // Fallback path for record-like types without a parameterless constructor. + var bestMatchingConstructor = context.ParentType.GetConstructors() + .Select(c => (Constructor: c, Parameters: c.GetParameters())) + .OrderBy(c => c.Parameters.Length) + .FirstOrDefault(c => + c.Parameters.Length >= assignmentList.Length + && assignmentList.All(a => + c.Parameters.Any(p => + string.Equals(a.Member.Name, p.Name, StringComparison.OrdinalIgnoreCase) + && a.Expression.Type.IsAssignableTo(p.ParameterType)))); + + if (bestMatchingConstructor.Constructor is not null) + { + var arguments = bestMatchingConstructor.Parameters.Select(p => + { + var assignment = assignmentList.FirstOrDefault(a => + string.Equals(a.Member.Name, p.Name, StringComparison.OrdinalIgnoreCase) + && a.Expression.Type.IsAssignableTo(p.ParameterType)); + + if (assignment is not null) + { + return assignment.Expression.Type == p.ParameterType + ? assignment.Expression + : Expression.Convert(assignment.Expression, p.ParameterType); + } + + if (p.HasDefaultValue) + { + return Expression.Convert(Expression.Constant(p.DefaultValue), p.ParameterType); + } + + if (!p.ParameterType.IsValueType && IsMarkedAsExplicitlyNonNullable(p)) + { + 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(bestMatchingConstructor.Constructor, arguments); + } + + throw new InvalidOperationException( + $"No writable properties or suitable constructor found for type '{context.ParentType.Name}'."); } private void CollectSelection( @@ -383,4 +443,7 @@ private readonly record struct Context( : null; } } + + private static bool IsMarkedAsExplicitlyNonNullable(ParameterInfo parameter) + => new NullabilityInfoContext().Create(parameter).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 fb6a6dbf547..bb9deaebf3f 100644 --- a/src/HotChocolate/Data/test/Data.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/IntegrationTests.cs @@ -10,6 +10,7 @@ using HotChocolate.Data.Sorting; using HotChocolate.Execution; using HotChocolate.Types; +using HotChocolate.Types.Pagination; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Data; @@ -961,6 +962,37 @@ public async Task AsSortDefinition_Descending_QueryContext_2() result.MatchSnapshot(); } + [Fact] + public async Task QueryContext_Should_Not_Throw_For_Record_Node_With_Paging_Filtering_And_Sorting() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddFiltering() + .AddSorting() + .AddQueryType() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + users { + edges { + node { + id + firstName + } + } + } + } + """); + + // assert + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors); + } + [Fact] public async Task QueryContext_Selector_Respects_Include_Directive() { @@ -1364,6 +1396,20 @@ public IQueryable GetConditionalAuthors( .With(context); } + public class RecordQuery + { + [UsePaging] + [UseFiltering] + [UseSorting] + public Connection GetUsers( + QueryContext query) + => Connection.Empty(); + + public record UserRecord( + string Id, + string FirstName); + } + public sealed class ConditionalAuthor { private string _name = "author";