diff --git a/src/HotChocolate/Core/src/Abstractions/Execution/Tasks/ExecutionTask.cs b/src/HotChocolate/Core/src/Abstractions/Execution/Tasks/ExecutionTask.cs index b9fb48eda9e..ffe1dd8f594 100644 --- a/src/HotChocolate/Core/src/Abstractions/Execution/Tasks/ExecutionTask.cs +++ b/src/HotChocolate/Core/src/Abstractions/Execution/Tasks/ExecutionTask.cs @@ -109,7 +109,8 @@ private async Task ExecuteInternalAsync(CancellationToken cancellationToken) /// The cancellation token. /// /// A representing the asynchronous operation. - protected virtual ValueTask OnAfterCompletedAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; + protected virtual ValueTask OnAfterCompletedAsync(CancellationToken cancellationToken) + => ValueTask.CompletedTask; /// /// Completes the task as faulted. diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/SelectionPath.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/SelectionPath.cs similarity index 76% rename from src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/SelectionPath.cs rename to src/HotChocolate/Core/src/Execution.Abstractions/Execution/SelectionPath.cs index e599c472d16..08c53462838 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/SelectionPath.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/SelectionPath.cs @@ -1,41 +1,79 @@ using System.Collections.Immutable; using System.Text; -namespace HotChocolate.Fusion.Execution.Nodes; +namespace HotChocolate.Execution; /// /// Represents a path to a selection set or a field selection within a GraphQL operation. /// public sealed class SelectionPath : IEquatable { - private readonly ImmutableArray _segments; + private readonly Segment[] _segments; + private readonly int _length; - private SelectionPath(ImmutableArray segments) + private SelectionPath(Segment[] segments, int length) { _segments = segments; + _length = length; } /// /// Gets a value indicating whether this path represents the root of an operation. /// The root of an operation is the root selection set. /// - public bool IsRoot => _segments.IsEmpty; + public bool IsRoot => _length == 0; + + /// + /// Gets the name of the last segment in the path, or null if the path is root. + /// + public string? Name => _length > 0 ? _segments[_length - 1].Name : null; /// /// Gets the parent path. /// public SelectionPath? Parent - => _segments.Length > 0 - ? new SelectionPath(_segments.RemoveAt(_segments.Length - 1)) + => _length > 0 + ? new SelectionPath(_segments, _length - 1) : null; /// - /// Gets the segments that make up this path. + /// Gets the number of segments in this path. + /// + public int Length => _length; + + /// + /// Gets the segment at the specified index. + /// + /// The zero-based index of the segment to get. + /// The segment at the specified index. + /// + /// Thrown when is less than 0 or greater than or equal to . + /// + public Segment this[int index] => _segments[index]; + + /// + /// Returns a new sharing the same backing array + /// but with the specified number of segments. /// - /// - /// An immutable array of segments representing the path from the root path segment to the current path segment. - /// - public ImmutableArray Segments => _segments; + /// The number of segments to include. + /// A new with the specified length. + /// + /// Thrown when is less than 0 or greater than . + /// + public SelectionPath Slice(int length) + { + if ((uint)length > (uint)_length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + if (length == 0) + { + return Root; + } + + return new SelectionPath(_segments, length); + } /// /// Creates a new selection path by appending a field segment to the current path. @@ -46,7 +84,12 @@ public SelectionPath? Parent /// Thrown when is null. /// public SelectionPath AppendField(string fieldName) - => new(_segments.Add(new Segment(fieldName, SelectionPathSegmentKind.Field))); + { + var newSegments = new Segment[_length + 1]; + Array.Copy(_segments, newSegments, _length); + newSegments[_length] = new Segment(fieldName, SelectionPathSegmentKind.Field); + return new SelectionPath(newSegments, newSegments.Length); + } /// /// Creates a new selection path by appending an inline fragment segment to the current path. @@ -57,12 +100,17 @@ public SelectionPath AppendField(string fieldName) /// Thrown when is null. /// public SelectionPath AppendFragment(string typeName) - => new(_segments.Add(new Segment(typeName, SelectionPathSegmentKind.InlineFragment))); + { + var newSegments = new Segment[_length + 1]; + Array.Copy(_segments, newSegments, _length); + newSegments[_length] = new Segment(typeName, SelectionPathSegmentKind.InlineFragment); + return new SelectionPath(newSegments, newSegments.Length); + } /// /// Gets the root selection path (empty path). /// - public static SelectionPath Root { get; } = new([]); + public static SelectionPath Root { get; } = new([], 0); /// /// Parses a string representation of a selection path into a instance. @@ -104,7 +152,7 @@ public static SelectionPath Parse(string s) return Root; } - var builder = ImmutableArray.CreateBuilder(); + var builder = new List(); var i = 0; while (i < s.Length) @@ -163,7 +211,8 @@ public static SelectionPath Parse(string s) } } - return new SelectionPath(builder.ToImmutable()); + var segments = builder.ToArray(); + return new SelectionPath(segments, segments.Length); } /// @@ -177,7 +226,8 @@ public static SelectionPath Parse(string s) /// public static SelectionPath From(ImmutableArray segments) { - return new SelectionPath(segments); + var array = segments.AsSpan().ToArray(); + return new SelectionPath(array, array.Length); } /// @@ -211,9 +261,15 @@ public SelectionPath RelativeTo(SelectionPath basePath) return Root; } - return !basePath.IsParentOfOrSame(this) - ? throw new ArgumentException(null, nameof(basePath)) - : new SelectionPath(_segments.RemoveRange(0, basePath._segments.Length)); + if (!basePath.IsParentOfOrSame(this)) + { + throw new ArgumentException(null, nameof(basePath)); + } + + var newLength = _length - basePath._length; + var newSegments = new Segment[newLength]; + Array.Copy(_segments, basePath._length, newSegments, 0, newLength); + return new SelectionPath(newSegments, newLength); } /// @@ -233,12 +289,12 @@ public SelectionPath RelativeTo(SelectionPath basePath) /// public bool IsParentOfOrSame(SelectionPath other) { - if (other._segments.Length < _segments.Length) + if (other._length < _length) { return false; } - for (var i = 0; i < _segments.Length; i++) + for (var i = 0; i < _length; i++) { if (_segments[i].Kind != other._segments[i].Kind || !string.Equals(_segments[i].Name, other._segments[i].Name, StringComparison.Ordinal)) @@ -259,7 +315,19 @@ public bool IsParentOfOrSame(SelectionPath other) /// otherwise, false. /// public bool Equals(SelectionPath? other) - => other is not null && _segments.SequenceEqual(other._segments); + { + if (other is null) + { + return false; + } + + if (_length != other._length) + { + return false; + } + + return _segments.AsSpan(0, _length).SequenceEqual(other._segments.AsSpan(0, other._length)); + } /// /// Determines whether this is equal to the specified object. @@ -270,7 +338,7 @@ public bool Equals(SelectionPath? other) /// otherwise, false. /// public override bool Equals(object? obj) - => ReferenceEquals(this, obj) || obj is SelectionPath p && Equals(p); + => ReferenceEquals(this, obj) || (obj is SelectionPath p && Equals(p)); /// /// Returns a hash code for this . @@ -279,10 +347,10 @@ public override bool Equals(object? obj) public override int GetHashCode() { var hash = new HashCode(); - foreach (var s in _segments) + for (var i = 0; i < _length; i++) { - hash.Add(s.Name); - hash.Add((int)s.Kind); + hash.Add(_segments[i].Name); + hash.Add((int)_segments[i].Kind); } return hash.ToHashCode(); } @@ -304,7 +372,7 @@ public override int GetHashCode() /// public override string ToString() { - if (_segments.IsEmpty) + if (_length == 0) { return "$"; } @@ -312,8 +380,7 @@ public override string ToString() var sb = new StringBuilder(); sb.Append('$'); - // Iterate forward through segments, not backward - for (var i = 0; i < _segments.Length; i++) + for (var i = 0; i < _length; i++) { var seg = _segments[i]; @@ -363,14 +430,14 @@ public sealed record Segment(string Name, SelectionPathSegmentKind Kind); /// A new instance. public static Builder CreateBuilder() => new(); - public static Builder CreateBuilder(SelectionPath path) => new(path.Segments); + public static Builder CreateBuilder(SelectionPath path) => new(path); /// /// A builder for creating instances. /// public readonly struct Builder { - private readonly ImmutableArray.Builder _segments = ImmutableArray.CreateBuilder(); + private readonly List _segments = []; /// /// Initializes new instance of . @@ -382,9 +449,9 @@ public Builder() /// /// Initializes new instance of . /// - public Builder(ImmutableArray segments) + public Builder(SelectionPath path) { - _segments.AddRange(segments); + _segments.AddRange(path._segments.AsSpan(0, path._length)); } /// @@ -419,6 +486,10 @@ public Builder AppendFragment(string typeName) /// Builds a from the segments in the builder. /// /// A new with the segments in the builder. - public SelectionPath Build() => new(_segments.ToImmutable()); + public SelectionPath Build() + { + var segments = _segments.ToArray(); + return new SelectionPath(segments, segments.Length); + } } } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/SelectionPathSegmentKind.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/SelectionPathSegmentKind.cs similarity index 63% rename from src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/SelectionPathSegmentKind.cs rename to src/HotChocolate/Core/src/Execution.Abstractions/Execution/SelectionPathSegmentKind.cs index c0c55365628..b92cdbcc7b3 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/SelectionPathSegmentKind.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Execution/SelectionPathSegmentKind.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Execution.Nodes; +namespace HotChocolate.Execution; public enum SelectionPathSegmentKind { diff --git a/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs b/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs index bc156b9fb09..673c5815256 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs @@ -287,9 +287,21 @@ private void WriteResolverBindingExtendsWith( WriteResolverBindingDescriptor(type, resolver); - Writer.WriteIndentedLine( - "configuration.ResultType = typeof({0});", - resolver.ReturnType.ToClassNonNullableFullyQualifiedWithNullRefQualifier()); + if (resolver.Kind is ResolverKind.BatchResolver) + { + // For batch resolvers, the return type is a list (e.g. List). + // The ResultType should be the element type (e.g. string). + var elementType = GetListElementType(resolver.ReturnType); + Writer.WriteIndentedLine( + "configuration.ResultType = typeof({0});", + elementType); + } + else + { + Writer.WriteIndentedLine( + "configuration.ResultType = typeof({0});", + resolver.ReturnType.ToClassNonNullableFullyQualifiedWithNullRefQualifier()); + } WriteFieldFlags(resolver); @@ -303,7 +315,7 @@ private void WriteResolverBindingExtendsWith( if (!resolver.Parameters.IsEmpty) { - var parentInfo = resolver.Parameters.GetParentInfo(); + var parentInfo = resolver.Parameters.GetParentInfo(resolver.Kind); if (parentInfo.HasValue) { Writer.WriteIndentedLine( @@ -524,16 +536,24 @@ private void WriteResolverBindingExtendsWith( } Writer.WriteLine(); - Writer.WriteIndentedLine("configuration.Resolvers = context.Resolvers.{0}();", resolver.Member.Name); - if (resolver.ResultKind is not ResolverResultKind.Pure - && !resolver.Member.HasPostProcessorAttribute() - && resolver.Member.IsListType(out var elementType)) + if (resolver.Kind is ResolverKind.BatchResolver) { - Writer.WriteIndentedLine( - "configuration.ResultPostProcessor = global::{0}<{1}>.Default;", - WellKnownTypes.ListPostProcessor, - elementType); + Writer.WriteIndentedLine("configuration.BatchResolver = context.Resolvers.{0}();", resolver.Member.Name); + } + else + { + Writer.WriteIndentedLine("configuration.Resolvers = context.Resolvers.{0}();", resolver.Member.Name); + + if (resolver.ResultKind is not ResolverResultKind.Pure + && !resolver.Member.HasPostProcessorAttribute() + && resolver.Member.IsListType(out var elementType)) + { + Writer.WriteIndentedLine( + "configuration.ResultPostProcessor = global::{0}<{1}>.Default;", + WellKnownTypes.ListPostProcessor, + elementType); + } } } @@ -542,6 +562,11 @@ protected void WriteFieldFlags(Resolver resolver) Writer.WriteLine(); Writer.WriteIndentedLine("configuration.SetSourceGeneratorFlags();"); + if (resolver.Kind is ResolverKind.BatchResolver) + { + Writer.WriteIndentedLine("configuration.SetBatchResolverFlags();"); + } + if (resolver.Kind is ResolverKind.ConnectionResolver) { Writer.WriteIndentedLine("configuration.SetConnectionFlags();"); @@ -775,6 +800,11 @@ protected void WriteResolver(Resolver resolver, ILocalTypeLookup typeLookup) switch (resolver.Member) { + case IMethodSymbol resolverMethod + when resolver.Kind is ResolverKind.BatchResolver: + WriteBatchResolver(resolver, resolverMethod, typeLookup); + break; + case IMethodSymbol resolverMethod when resolver.ResultKind is ResolverResultKind.Pure: WritePureResolver(resolver, resolverMethod, typeLookup); @@ -950,6 +980,262 @@ private void WritePureResolver(Resolver resolver, IMethodSymbol resolverMethod, Writer.WriteIndentedLine("}"); } + private void WriteBatchResolver( + Resolver resolver, + IMethodSymbol resolverMethod, + ILocalTypeLookup typeLookup) + { + var isAsync = resolver.ResultKind is ResolverResultKind.Task or ResolverResultKind.TaskAsyncEnumerable; + + // Public accessor method: returns BatchFieldDelegate + Writer.WriteMethod( + "public", + returnType: WellKnownTypes.BatchFieldDelegate, + methodName: $"{resolver.Member.Name}", + [], + resolver.Member.Name); + + Writer.WriteLine(); + + // Private batch delegate method + Writer.WriteIndented("private "); + + if (isAsync) + { + Writer.Write("async "); + } + + Writer.WriteLine( + "global::{0} {1}(global::{2} contexts)", + WellKnownTypes.ValueTask, + resolver.Member.Name, + WellKnownTypes.ImmutableArrayOfMiddlewareContext); + Writer.WriteIndentedLine("{"); + + using (Writer.IncreaseIndent()) + { + // Declare variables for batched parameters (parent, arguments) and singular parameters (services, state, etc.) + for (var i = 0; i < resolver.Parameters.Length; i++) + { + var parameter = resolver.Parameters[i]; + + switch (parameter.Kind) + { + case ResolverParameterKind.Parent: + Writer.WriteIndentedLine( + "var args{0} = new {1}(contexts.Length);", + i, + parameter.Type.ToFullyQualified()); + break; + + case ResolverParameterKind.Argument: + case ResolverParameterKind.Unknown: + Writer.WriteIndentedLine( + "var args{0} = new {1}(contexts.Length);", + i, + ToFullyQualifiedString(parameter.Type, resolverMethod, typeLookup)); + break; + + case ResolverParameterKind.CancellationToken: + Writer.WriteIndentedLine( + "var args{0} = contexts[0].RequestAborted;", + i); + break; + + case ResolverParameterKind.Service: + if (parameter.Key is null) + { + Writer.WriteIndentedLine( + "var args{0} = contexts[0].Service<{1}>();", + i, + ToFullyQualifiedString(parameter.Type, resolverMethod, typeLookup)); + } + else + { + Writer.WriteIndentedLine( + "var args{0} = contexts[0].Service<{1}>(\"{2}\");", + i, + ToFullyQualifiedString(parameter.Type, resolverMethod, typeLookup), + parameter.Key); + } + + break; + + case ResolverParameterKind.GetGlobalState when !parameter.IsNullable: + Writer.WriteIndentedLine( + "var args{0} = contexts[0].GetGlobalState<{1}>(\"{2}\");", + i, + parameter.Type.ToFullyQualified(), + parameter.Key); + break; + + case ResolverParameterKind.GetGlobalState: + Writer.WriteIndentedLine( + "var args{0} = contexts[0].GetGlobalStateOrDefault<{1}>(\"{2}\");", + i, + parameter.Type.ToFullyQualified(), + parameter.Key); + break; + + case ResolverParameterKind.GetScopedState when !parameter.IsNullable: + Writer.WriteIndentedLine( + "var args{0} = contexts[0].GetScopedState<{1}>(\"{2}\");", + i, + parameter.Type.ToFullyQualified(), + parameter.Key); + break; + + case ResolverParameterKind.GetScopedState: + Writer.WriteIndentedLine( + "var args{0} = contexts[0].GetScopedStateOrDefault<{1}>(\"{2}\");", + i, + parameter.Type.ToFullyQualified(), + parameter.Key); + break; + + default: + // Fallback for any other kind: extract from first context + Writer.WriteIndentedLine( + "var args{0} = default({1})!;", + i, + ToFullyQualifiedString(parameter.Type, resolverMethod, typeLookup)); + break; + } + } + + // Build collection loop for batched parameters (parent, arguments) + var hasBatchedParams = false; + + for (var i = 0; i < resolver.Parameters.Length; i++) + { + var parameter = resolver.Parameters[i]; + + if (parameter.Kind is ResolverParameterKind.Parent + or ResolverParameterKind.Argument + or ResolverParameterKind.Unknown) + { + hasBatchedParams = true; + break; + } + } + + if (hasBatchedParams) + { + Writer.WriteLine(); + Writer.WriteIndentedLine("for (var i = 0; i < contexts.Length; i++)"); + Writer.WriteIndentedLine("{"); + + using (Writer.IncreaseIndent()) + { + for (var i = 0; i < resolver.Parameters.Length; i++) + { + var parameter = resolver.Parameters[i]; + + switch (parameter.Kind) + { + case ResolverParameterKind.Parent: + { + // Get the element type from the list parameter (e.g. List -> User) + var elementType = GetListElementType(parameter.Type); + Writer.WriteIndentedLine( + "args{0}.Add(contexts[i].Parent<{1}>());", + i, + elementType); + break; + } + + case ResolverParameterKind.Argument: + case ResolverParameterKind.Unknown: + { + var elementType = GetListElementType(parameter.Type); + Writer.WriteIndentedLine( + "args{0}.Add(contexts[i].ArgumentValue<{1}>(\"{2}\"));", + i, + elementType, + parameter.Key ?? parameter.Name); + break; + } + } + } + } + + Writer.WriteIndentedLine("}"); + } + + Writer.WriteLine(); + + // Call the user's batch resolver method + if (isAsync) + { + Writer.WriteIndentedLine( + "var result = await {0}.{1}({2});", + resolver.Member.ContainingType.ToFullyQualified(), + resolver.Member.Name, + GetResolverArgumentAssignments(resolver.Parameters.Length)); + + Writer.WriteLine(); + Writer.WriteIndentedLine("if (result is global::{0} list)", WellKnownTypes.IList); + Writer.WriteIndentedLine("{"); + using (Writer.IncreaseIndent()) + { + Writer.WriteIndentedLine("for (var i = 0; i < contexts.Length; i++)"); + Writer.WriteIndentedLine("{"); + using (Writer.IncreaseIndent()) + { + Writer.WriteIndentedLine("contexts[i].Result = i < list.Count ? list[i] : null;"); + } + + Writer.WriteIndentedLine("}"); + } + + Writer.WriteIndentedLine("}"); + } + else + { + Writer.WriteIndentedLine( + "var result = {0}.{1}({2});", + resolver.Member.ContainingType.ToFullyQualified(), + resolver.Member.Name, + GetResolverArgumentAssignments(resolver.Parameters.Length)); + + Writer.WriteLine(); + Writer.WriteIndentedLine("if (result is global::{0} list)", WellKnownTypes.IList); + Writer.WriteIndentedLine("{"); + using (Writer.IncreaseIndent()) + { + Writer.WriteIndentedLine("for (var i = 0; i < contexts.Length; i++)"); + Writer.WriteIndentedLine("{"); + using (Writer.IncreaseIndent()) + { + Writer.WriteIndentedLine("contexts[i].Result = i < list.Count ? list[i] : null;"); + } + + Writer.WriteIndentedLine("}"); + } + + Writer.WriteIndentedLine("}"); + Writer.WriteIndentedLine("return default;"); + } + } + + Writer.WriteIndentedLine("}"); + } + + private static string GetListElementType(ITypeSymbol type) + { + if (type is IArrayTypeSymbol arrayType) + { + return arrayType.ElementType.ToFullyQualified(); + } + + if (type is INamedTypeSymbol { IsGenericType: true } namedType) + { + return namedType.TypeArguments[0].ToFullyQualified(); + } + + return type.ToFullyQualified(); + } + private void WritePropertyResolver(Resolver resolver) { Writer.WriteMethod( @@ -1607,15 +1893,35 @@ private static string ToFullyQualifiedString( file static class Extensions { - public static (string Requirements, string Type)? GetParentInfo(this ImmutableArray parameters) + public static (string Requirements, string Type)? GetParentInfo( + this ImmutableArray parameters, + ResolverKind resolverKind = ResolverKind.Default) { var parameter = parameters.FirstOrDefault(t => t.Kind is ResolverParameterKind.Parent); if (!string.IsNullOrEmpty(parameter?.Requirements)) { - return (parameter!.Requirements!, parameter.Type.ToFullyQualified()); + var type = resolverKind is ResolverKind.BatchResolver + ? GetListElementType(parameter!.Type) + : parameter!.Type.ToFullyQualified(); + return (parameter.Requirements!, type); } return null; } + + private static string GetListElementType(ITypeSymbol type) + { + if (type is IArrayTypeSymbol arrayType) + { + return arrayType.ElementType.ToFullyQualified(); + } + + if (type is INamedTypeSymbol { IsGenericType: true } namedType) + { + return namedType.TypeArguments[0].ToFullyQualified(); + } + + return type.ToFullyQualified(); + } } diff --git a/src/HotChocolate/Core/src/Types.Analyzers/Helpers/TypeReferenceBuilder.cs b/src/HotChocolate/Core/src/Types.Analyzers/Helpers/TypeReferenceBuilder.cs index e886f70dda3..be33da743ec 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/Helpers/TypeReferenceBuilder.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/Helpers/TypeReferenceBuilder.cs @@ -14,7 +14,10 @@ public static class TypeReferenceBuilder "HotChocolate.Optional" ]; - public static SchemaTypeReference CreateTypeReference(this Compilation compilation, ISymbol member) + public static SchemaTypeReference CreateTypeReference( + this Compilation compilation, + ISymbol member, + bool isBatchResolver = false) { var typeAttribute = compilation.GetTypeByMetadataName(WellKnownAttributes.GraphQLTypeAttribute); var genericTypeAttribute = compilation.GetTypeByMetadataName(WellKnownAttributes.GraphQLTypeAttribute + "`1"); @@ -72,6 +75,13 @@ public static SchemaTypeReference CreateTypeReference(this Compilation compilati // First, we unwrap any non-essential wrapper types and IFieldResult implementations. var unwrapped = UnwrapNonEssentialTypes(member.GetReturnType()!, compilation); + // For batch resolvers, the return type is a list (e.g. List) and we need + // to unwrap to the element type (e.g. string) for the GraphQL field type. + if (isBatchResolver) + { + unwrapped = UnwrapListElementType(unwrapped) ?? unwrapped; + } + // Next, we create a key that describes the type and ensures we are only executing the type factory once. var (typeStructure, typeDefinition, isSimpleType) = CreateTypeKey(unwrapped); @@ -183,6 +193,21 @@ private static (string TypeStructure, string TypeDefinition, bool IsSimpleType) } } + private static ITypeSymbol? UnwrapListElementType(ITypeSymbol typeSymbol) + { + if (typeSymbol is IArrayTypeSymbol arrayType) + { + return arrayType.ElementType; + } + + if (typeSymbol is INamedTypeSymbol namedType && TryGetListElementType(namedType, out var elementType)) + { + return elementType; + } + + return null; + } + private static ITypeSymbol UnwrapNonEssentialTypes(ITypeSymbol typeSymbol, Compilation compilation) { var fieldResultInterface = compilation.GetFieldResultInterface(); diff --git a/src/HotChocolate/Core/src/Types.Analyzers/Inspectors/ObjectTypeInspector.cs b/src/HotChocolate/Core/src/Types.Analyzers/Inspectors/ObjectTypeInspector.cs index 7ff0f8dc5cf..5edc5e2fb76 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/Inspectors/ObjectTypeInspector.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/Inspectors/ObjectTypeInspector.cs @@ -289,6 +289,7 @@ public static Resolver CreateResolver( var parameters = resolverMethod.Parameters; var buffer = new ResolverParameter[parameters.Length]; var resolverParameters = ImmutableCollectionsMarshal.AsImmutableArray(buffer); + var isBatchResolver = resolverMethod.IsBatchResolver(); for (var i = 0; i < parameters.Length; i++) { @@ -298,7 +299,7 @@ public static Resolver CreateResolver( buffer[i] = new ResolverParameter( parameter, parameterKind, - compilation.CreateTypeReference(parameter), + compilation.CreateTypeReference(parameter, isBatchResolver), compilation.GetDescription(parameter)?.Description, compilation.GetDeprecationReason(parameter), key); @@ -314,10 +315,14 @@ public static Resolver CreateResolver( resolverMethod.GetResultKind(), resolverParameters, resolverMethod.GetMemberBindings(), - compilation.CreateTypeReference(resolverMethod), - kind: compilation.IsConnectionType(resolverMethod.ReturnType) - ? ResolverKind.ConnectionResolver - : ResolverKind.Default); + isBatchResolver + ? compilation.CreateTypeReference(resolverMethod, isBatchResolver: true) + : compilation.CreateTypeReference(resolverMethod), + kind: isBatchResolver + ? ResolverKind.BatchResolver + : compilation.IsConnectionType(resolverMethod.ReturnType) + ? ResolverKind.ConnectionResolver + : ResolverKind.Default); } private static Resolver CreateNodeResolver( @@ -484,6 +489,19 @@ public static bool IsNodeResolver(this IMethodSymbol methodSymbol) return false; } + public static bool IsBatchResolver(this IMethodSymbol methodSymbol) + { + foreach (var attribute in methodSymbol.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() == BatchResolverAttribute) + { + return true; + } + } + + return false; + } + public static bool Skip(this IMethodSymbol methodSymbol) { foreach (var attribute in methodSymbol.GetAttributes()) diff --git a/src/HotChocolate/Core/src/Types.Analyzers/Models/Resolver.cs b/src/HotChocolate/Core/src/Types.Analyzers/Models/Resolver.cs index ed917363b6d..38dc1458fff 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/Models/Resolver.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/Models/Resolver.cs @@ -66,7 +66,7 @@ public ITypeSymbol ReturnType public bool IsStatic => Member.IsStatic; public bool IsPure - => Kind is not ResolverKind.NodeResolver + => Kind is not (ResolverKind.NodeResolver or ResolverKind.BatchResolver) && ResultKind is ResolverResultKind.Pure && Parameters.All(t => t.IsPure); diff --git a/src/HotChocolate/Core/src/Types.Analyzers/Models/ResolverKind.cs b/src/HotChocolate/Core/src/Types.Analyzers/Models/ResolverKind.cs index 810026babee..954de64a9bf 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/Models/ResolverKind.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/Models/ResolverKind.cs @@ -5,5 +5,6 @@ public enum ResolverKind Default = 0, NodeResolver = 1, ConnectionResolver = 2, - InstanceResolver = 3 + InstanceResolver = 3, + BatchResolver = 4 } diff --git a/src/HotChocolate/Core/src/Types.Analyzers/ParentAttributeAnalyzer.cs b/src/HotChocolate/Core/src/Types.Analyzers/ParentAttributeAnalyzer.cs index 0380d29c57a..d12dede6272 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/ParentAttributeAnalyzer.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/ParentAttributeAnalyzer.cs @@ -83,12 +83,20 @@ private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) continue; } + // For batch resolvers, the [Parent] parameter is a list type (e.g. List). + // Unwrap the element type before validating. + var typeToCheck = parameterType; + if (IsBatchResolverMethod(methodDeclaration, semanticModel)) + { + typeToCheck = UnwrapListElementType(parameterType) ?? parameterType; + } + // Check if the parameter type is compatible with the ObjectType generic argument // Valid if: // 1. parameterType == objectTypeGenericArg // 2. objectTypeGenericArg inherits from parameterType // 3. objectTypeGenericArg implements parameterType (if it's an interface) - if (!IsValidParentType(objectTypeGenericArg, parameterType)) + if (!IsValidParentType(objectTypeGenericArg, typeToCheck)) { var diagnostic = Diagnostic.Create( Errors.ParentAttributeTypeMismatch, @@ -136,6 +144,50 @@ private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) return null; } + private static bool IsBatchResolverMethod( + MethodDeclarationSyntax methodDeclaration, + SemanticModel semanticModel) + { + if (semanticModel.GetDeclaredSymbol(methodDeclaration) is not IMethodSymbol methodSymbol) + { + return false; + } + + foreach (var attribute in methodSymbol.GetAttributes()) + { + if (attribute.AttributeClass is { Name: "BatchResolverAttribute" } attributeClass + && attributeClass.ContainingNamespace?.ToDisplayString() == "HotChocolate.Types") + { + return true; + } + } + + return false; + } + + private static ITypeSymbol? UnwrapListElementType(ITypeSymbol typeSymbol) + { + if (typeSymbol is IArrayTypeSymbol arrayType) + { + return arrayType.ElementType; + } + + if (typeSymbol is INamedTypeSymbol { IsGenericType: true } namedType) + { + var fullName = namedType.ConstructedFrom.ToDisplayString(); + if (fullName is "System.Collections.Generic.List" + or "System.Collections.Generic.IList" + or "System.Collections.Generic.IReadOnlyList" + or "System.Collections.Generic.ICollection" + or "System.Collections.Generic.IEnumerable") + { + return namedType.TypeArguments[0]; + } + } + + return null; + } + private static bool IsValidParentType(ITypeSymbol objectTypeGenericArg, ITypeSymbol parameterType) { // Check if types are equal diff --git a/src/HotChocolate/Core/src/Types.Analyzers/WellKnownAttributes.cs b/src/HotChocolate/Core/src/Types.Analyzers/WellKnownAttributes.cs index 2093b00a5bc..cd7c538c5e8 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/WellKnownAttributes.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/WellKnownAttributes.cs @@ -37,6 +37,7 @@ public static class WellKnownAttributes public const string DescriptorAttribute = "HotChocolate.Types.DescriptorAttribute"; public const string GraphQLDeprecatedAttribute = "HotChocolate.GraphQLDeprecatedAttribute"; public const string ObsoleteAttribute = "System.ObsoleteAttribute"; + public const string BatchResolverAttribute = "HotChocolate.Types.BatchResolverAttribute"; public const string GraphQLTypeAttribute = "HotChocolate.GraphQLTypeAttribute"; public static HashSet BindAttributes { get; } = diff --git a/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs b/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs index b1dc7f0385a..6e175e895b1 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs @@ -106,6 +106,10 @@ public static class WellKnownTypes public const string NonNullTypeNode = "HotChocolate.Language.NonNullTypeNode"; public const string ListTypeNode = "HotChocolate.Language.ListTypeNode"; public const string NamedTypeNode = "HotChocolate.Language.NamedTypeNode"; + public const string BatchFieldDelegate = "HotChocolate.Resolvers.BatchFieldDelegate"; + public const string ImmutableArrayOfMiddlewareContext = "System.Collections.Immutable.ImmutableArray"; + public const string IList = "System.Collections.IList"; + public const string MiddlewareContext = "HotChocolate.Resolvers.IMiddlewareContext"; public static HashSet TypeClass { get; } = [ diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeInitializer.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeInitializer.cs index c01f3b44490..e2c7b993d00 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/TypeInitializer.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/TypeInitializer.cs @@ -429,6 +429,12 @@ internal void CompileResolvers(RegisteredType registeredType) { foreach (var field in objectType.Configuration!.Fields) { + if ((CoreFieldFlags.BatchResolver & field.Flags) == CoreFieldFlags.BatchResolver) + { + field.BatchResolver ??= CompileBatchResolver(field, _context.ResolverCompiler); + continue; + } + if (!field.Resolvers.HasResolvers) { field.Resolvers = CompileResolver(field, _context.ResolverCompiler); @@ -439,6 +445,12 @@ internal void CompileResolvers(RegisteredType registeredType) { foreach (var field in interfaceType.Configuration!.Fields) { + if ((CoreFieldFlags.BatchResolver & field.Flags) == CoreFieldFlags.BatchResolver) + { + field.BatchResolver ??= CompileBatchResolver(field, _context.ResolverCompiler); + continue; + } + if (!field.Resolvers.HasResolvers) { field.Resolvers = CompileResolver(field, _context.ResolverCompiler); @@ -512,6 +524,70 @@ static void BuildArgumentLookup( } } + private static BatchFieldDelegate? CompileBatchResolver( + ObjectFieldConfiguration definition, + IResolverCompiler resolverCompiler) + { + var method = (definition.ResolverMember ?? definition.Member) as MethodInfo; + + if (method is null) + { + return null; + } + + var map = TypeMemHelper.RentArgumentNameMap(); + + foreach (var argument in definition.Arguments) + { + if (argument.Parameter is not null) + { + map[argument.Parameter] = argument.Name; + } + } + + var result = resolverCompiler.CompileBatchResolve( + method, + definition.SourceType, + definition.ResolverType, + map, + definition.GetParameterExpressionBuilders()); + + TypeMemHelper.Return(map); + return result; + } + + private static BatchFieldDelegate? CompileBatchResolver( + InterfaceFieldConfiguration definition, + IResolverCompiler resolverCompiler) + { + var method = (definition.ResolverMember ?? definition.Member) as MethodInfo; + + if (method is null) + { + return null; + } + + var map = TypeMemHelper.RentArgumentNameMap(); + + foreach (var argument in definition.Arguments) + { + if (argument.Parameter is not null) + { + map[argument.Parameter] = argument.Name; + } + } + + var result = resolverCompiler.CompileBatchResolve( + method, + definition.SourceType, + definition.ResolverType, + map, + definition.GetParameterExpressionBuilders()); + + TypeMemHelper.Return(map); + return result; + } + private static FieldResolverDelegates CompileResolver( InterfaceFieldConfiguration definition, IResolverCompiler resolverCompiler) diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/Factories/OperationContextFactory.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/Factories/OperationContextFactory.cs index f0e36070029..1112d33180e 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/Factories/OperationContextFactory.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/Factories/OperationContextFactory.cs @@ -19,6 +19,7 @@ namespace Microsoft.Extensions.DependencyInjection; /// internal sealed class OperationContextFactory( IFactory resolverTaskFactory, + IFactory batchResolverTaskFactory, ITypeConverter typeConverter, AggregateServiceScopeInitializer serviceScopeInitializer) : IFactory @@ -26,6 +27,7 @@ internal sealed class OperationContextFactory( public OperationContext Create() => new OperationContext( resolverTaskFactory, + batchResolverTaskFactory, typeConverter, serviceScopeInitializer); } diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs index 0d8c703d88b..6893898681b 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using HotChocolate.Execution.Processing.Tasks; using HotChocolate.Fetching; using HotChocolate.Internal; +using HotChocolate.Resolvers; using HotChocolate.Types; using HotChocolate.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -37,6 +38,25 @@ internal static IServiceCollection TryAddResolverTaskPool( return services; } + internal static IServiceCollection TryAddBatchResolverTaskPool( + this IServiceCollection services, + int maximumRetained = 64) + { + services.TryAddSingleton>>( + _ => new DefaultObjectPool>( + new ArgumentMapPoolPolicy())); + services.TryAddSingleton>( + sp => new ExecutionTaskPool( + new BatchResolverTaskPoolPolicy( + sp.GetRequiredService>(), + sp.GetRequiredService>>()), + maximumRetained)); + services.TryAddSingleton>( + sp => new PooledServiceFactory( + sp.GetRequiredService>())); + return services; + } + internal static IServiceCollection TryAddOperationContextPool( this IServiceCollection services) { diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs index ede52a9b255..7f8db92b2a1 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static IServiceCollection AddGraphQLCore(this IServiceCollection services // pools services .TryAddResolverTaskPool() + .TryAddBatchResolverTaskPool() .TryAddOperationContextPool() .TryAddSingleton>(new DocumentValidatorContextPool()); diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.Pooling.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.Pooling.cs index afe58a02392..bda57e0ba29 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.Pooling.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.Pooling.cs @@ -17,6 +17,13 @@ public MiddlewareContext() _childContext = new PureResolverContext(this); } + /// + /// Gets the execution branch identifier this context belongs to. + /// Set by the owning task (ResolverTask or BatchResolverTask) so that + /// value completion can read it without requiring it to be passed down. + /// + public int BranchId { get; set; } + public void Initialize( object? parent, Selection selection, @@ -61,6 +68,7 @@ public void Clean() ResultValue = default; HasErrors = false; Arguments = null!; + BranchId = 0; RequestAborted = CancellationToken.None; } } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.State.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.State.cs index cf50c859c87..f2adc30eb1c 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.State.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/MiddlewareContext.State.cs @@ -17,7 +17,6 @@ internal partial class MiddlewareContext public IImmutableDictionary LocalContextData { get; set; } = null!; - // TODO : Remove? public IType? ValueType { get; set; } public ResultElement ResultValue { get; private set; } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs index 7e8fbef5906..f4050980bed 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs @@ -12,12 +12,12 @@ namespace HotChocolate.Execution.Processing; public sealed partial class OperationCompiler { + private static readonly ArrayPool s_objectArrayPool = ArrayPool.Shared; private readonly Schema _schema; private readonly ObjectPool>> _fieldsPool; private readonly OperationCompilerOptimizers _optimizers; private readonly InlineFragmentOperationRewriter _documentRewriter; private readonly InputParser _inputValueParser; - private static readonly ArrayPool s_objectArrayPool = ArrayPool.Shared; internal OperationCompiler( Schema schema, @@ -197,8 +197,7 @@ internal SelectionSet CompileSelectionSet( } } - var path = selection.DeclaringSelectionSet.Path.Append(selection.ResponseName); - var selectionSet = BuildSelectionSet(path, fields, objectType, compilationContext, optimizers, ref lastId); + var selectionSet = BuildSelectionSet(selection.FieldSelectionPath, fields, objectType, compilationContext, optimizers, ref lastId); compilationContext.Register(selectionSet, selectionSet.Id); elementsById = compilationContext.ElementsById; selectionSet.Complete(operation); @@ -407,9 +406,12 @@ private SelectionSet BuildSelectionSet( arguments = CoerceArgumentValues(field, first.Node); } + var selectionPath = path.AppendField(field.Name); + var selection = new Selection( ++lastId, responseName, + selectionPath, field, nodes.ToArray(), includeFlags.Count > 0 ? includeFlags.ToArray() : [], @@ -418,7 +420,8 @@ private SelectionSet BuildSelectionSet( isInternal: isInternal, arguments: arguments, resolverPipeline: fieldDelegate, - pureResolver: pureFieldDelegate); + pureResolver: pureFieldDelegate, + batchResolverPipeline: field.BatchResolver); if (optimizers.Length > 0) { diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Execution.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Execution.cs index 47d5b3aa89c..d808befd2d5 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Execution.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Execution.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using HotChocolate.Execution.Processing.Tasks; using HotChocolate.Text.Json; +using HotChocolate.Types; namespace HotChocolate.Execution.Processing; @@ -71,6 +72,19 @@ public ResolverTask CreateResolverTask( return resolverTask; } + public BatchResolverTask CreateBatchResolverTask( + ObjectField field, + SelectionPath selectionPath, + int branchId, + DeferUsage? deferUsage = null) + { + AssertInitialized(); + + var batchTask = _batchResolverTaskFactory.Create(); + batchTask.Initialize(this, field, selectionPath, branchId, deferUsage); + return batchTask; + } + public DeferTask CreateDeferTask( SelectionSet selectionSet, Path selectionPath, diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs index c7fd8d6df80..a2929367ce9 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs @@ -15,6 +15,7 @@ namespace HotChocolate.Execution.Processing; internal sealed partial class OperationContext { private readonly IFactory _resolverTaskFactory; + private readonly IFactory _batchResolverTaskFactory; private readonly BranchTracker _branchTracker = new(); private readonly WorkScheduler _workScheduler; private readonly DeferExecutionCoordinator _deferExecutionCoordinator = new(); @@ -42,10 +43,12 @@ internal sealed partial class OperationContext public OperationContext( IFactory resolverTaskFactory, + IFactory batchResolverTaskFactory, ITypeConverter typeConverter, AggregateServiceScopeInitializer serviceScopeInitializer) { _resolverTaskFactory = resolverTaskFactory; + _batchResolverTaskFactory = batchResolverTaskFactory; _workScheduler = new WorkScheduler(this); _currentWorkScheduler = _workScheduler; _currentBranchTracker = _branchTracker; diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Selection.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Selection.cs index 88cb0bf17f3..05c8196bc05 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/Selection.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Selection.cs @@ -23,6 +23,7 @@ public sealed class Selection : ISelection, IFeatureProvider internal Selection( int id, string responseName, + SelectionPath fieldSelectionPath, ObjectField field, FieldSelectionNode[] syntaxNodes, ulong[] includeFlags, @@ -31,7 +32,8 @@ internal Selection( bool isInternal = false, ArgumentMap? arguments = null, FieldDelegate? resolverPipeline = null, - PureFieldDelegate? pureResolver = null) + PureFieldDelegate? pureResolver = null, + BatchFieldDelegate? batchResolverPipeline = null) { ArgumentNullException.ThrowIfNull(field); @@ -44,14 +46,17 @@ internal Selection( Id = id; ResponseName = responseName; + FieldSelectionPath = fieldSelectionPath; Field = field; Type = field.Type; Arguments = arguments ?? s_emptyArguments; ResolverPipeline = resolverPipeline; PureResolver = pureResolver; + BatchResolverPipeline = batchResolverPipeline; Strategy = InferStrategy( isSerial: !field.IsParallelExecutable, - hasPureResolver: pureResolver is not null); + hasPureResolver: pureResolver is not null, + hasBatchResolver: batchResolverPipeline is not null); _syntaxNodes = syntaxNodes; _includeFlags = includeFlags; _deferUsage = deferUsage ?? []; @@ -75,6 +80,7 @@ private Selection( int id, string responseName, byte[] utf8ResponseName, + SelectionPath fieldSelectionPath, ObjectField field, IType type, FieldSelectionNode[] syntaxNodes, @@ -85,15 +91,18 @@ private Selection( ArgumentMap? arguments, SelectionExecutionStrategy strategy, FieldDelegate? resolverPipeline, - PureFieldDelegate? pureResolver) + PureFieldDelegate? pureResolver, + BatchFieldDelegate? batchResolverPipeline) { Id = id; ResponseName = responseName; + FieldSelectionPath = fieldSelectionPath; Field = field; Type = type; Arguments = arguments ?? s_emptyArguments; ResolverPipeline = resolverPipeline; PureResolver = pureResolver; + BatchResolverPipeline = batchResolverPipeline; Strategy = strategy; _syntaxNodes = syntaxNodes; _includeFlags = includeFlags; @@ -111,6 +120,8 @@ private Selection( internal ReadOnlySpan Utf8ResponseName => _utf8ResponseName; + public SelectionPath FieldSelectionPath { get; } + /// public bool IsInternal => (_flags & Flags.Internal) == Flags.Internal; @@ -187,6 +198,13 @@ public SelectionSet DeclaringSelectionSet /// public PureFieldDelegate? PureResolver { get; private set; } + /// + /// Gets the batch resolver pipeline delegate for this selection. + /// When set, the field is resolved using a batch pipeline that receives + /// multiple parent contexts in a single invocation. + /// + public BatchFieldDelegate? BatchResolverPipeline { get; private set; } + /// /// Gets the syntax nodes that contributed to this selection. /// @@ -612,6 +630,7 @@ public Selection WithField(ObjectField field) Id, ResponseName, _utf8ResponseName, + FieldSelectionPath, field, field.Type, _syntaxNodes, @@ -622,7 +641,8 @@ public Selection WithField(ObjectField field) Arguments, Strategy, ResolverPipeline, - PureResolver); + PureResolver, + BatchResolverPipeline); selection._declaringSelectionSet = _declaringSelectionSet; @@ -637,6 +657,7 @@ public Selection WithType(IType type) Id, ResponseName, _utf8ResponseName, + FieldSelectionPath, Field, type, _syntaxNodes, @@ -647,7 +668,8 @@ public Selection WithType(IType type) Arguments, Strategy, ResolverPipeline, - PureResolver); + PureResolver, + BatchResolverPipeline); selection._declaringSelectionSet = _declaringSelectionSet; @@ -666,7 +688,8 @@ public override string ToString() internal void SetResolvers( FieldDelegate? resolverPipeline = null, - PureFieldDelegate? pureResolver = null) + PureFieldDelegate? pureResolver = null, + BatchFieldDelegate? batchResolverPipeline = null) { if ((_flags & Flags.Sealed) == Flags.Sealed) { @@ -675,7 +698,10 @@ internal void SetResolvers( ResolverPipeline = resolverPipeline; PureResolver = pureResolver; - Strategy = InferStrategy(hasPureResolver: pureResolver is not null); + BatchResolverPipeline = batchResolverPipeline; + Strategy = InferStrategy( + hasPureResolver: pureResolver is not null, + hasBatchResolver: batchResolverPipeline is not null); } /// @@ -696,8 +722,15 @@ internal void Complete(SelectionSet selectionSet) private SelectionExecutionStrategy InferStrategy( bool isSerial = false, - bool hasPureResolver = false) + bool hasPureResolver = false, + bool hasBatchResolver = false) { + // batch resolver takes precedence — it handles its own execution strategy. + if (hasBatchResolver) + { + return SelectionExecutionStrategy.Batch; + } + // once a field is marked serial it even with a pure resolver cannot become pure. if (Strategy is SelectionExecutionStrategy.Serial || isSerial) { diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionExecutionStrategy.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionExecutionStrategy.cs index 0ed4a6d4bc6..b2f7dfe0483 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionExecutionStrategy.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionExecutionStrategy.cs @@ -19,5 +19,11 @@ public enum SelectionExecutionStrategy /// /// Defines that the selection has a side-effect free pure resolver. /// - Pure + Pure, + + /// + /// Defines that the selection uses a batch resolver pipeline that + /// collects multiple parent contexts and resolves them in a single invocation. + /// + Batch } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionPath.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionPath.cs deleted file mode 100644 index 1d8372bb8dc..00000000000 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionPath.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Text; -using HotChocolate.Utilities; - -namespace HotChocolate.Execution.Processing; - -/// -/// Represents GraphQL selection path which is used in the operation compiler. -/// -public sealed class SelectionPath : IEquatable -{ - private SelectionPath(string name, SelectionPath? parent = null) - { - Name = name; - Parent = parent; - } - - /// - /// Gets the name of the current path segment. - /// - public string Name { get; } - - /// - /// Gets the parent path segment. - /// - public SelectionPath? Parent { get; } - - /// - /// Gets the root path segment. - /// - public static SelectionPath Root { get; } = new("$root"); - - /// - /// Creates a new path segment. - /// - /// - /// The name of the path segment. - /// - /// - /// Returns a new path segment. - /// - public SelectionPath Append(string name) => new(name, this); - - /// - /// Indicates whether the current path is equal to another path. - /// - /// A path to compare with this path. - /// - /// if the current path is equal to the - /// parameter; otherwise, . - /// - public bool Equals(SelectionPath? other) - { - if (other is null) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - if (Name.EqualsOrdinal(other.Name)) - { - if (ReferenceEquals(Parent, other.Parent)) - { - return true; - } - - if (ReferenceEquals(Parent, null)) - { - return false; - } - - return Parent.Equals(other.Parent); - } - - return false; - } - - /// - /// Indicates whether the current path is equal to another path. - /// - /// - /// An object to compare with this path. - /// - /// - /// if the current path is equal to the - /// parameter; otherwise, . - /// - public override bool Equals(object? obj) - => Equals(obj as SelectionPath); - - /// - /// Returns the hash code for this path. - /// - /// - public override int GetHashCode() - => HashCode.Combine(Name, Parent); - - /// - /// Returns a string that represents the current path. - /// - /// - /// A string that represents the current path. - /// - public override string ToString() - { - var path = new StringBuilder(); - var current = this; - - do - { - path.Insert(0, current.Name); - path.Insert(0, '/'); - current = current.Parent; - } while (current != null); - - return path.ToString(); - } -} diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ArgumentMapPoolPolicy.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ArgumentMapPoolPolicy.cs new file mode 100644 index 00000000000..98914fb7282 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ArgumentMapPoolPolicy.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.ObjectPool; +using HotChocolate.Resolvers; + +namespace HotChocolate.Execution.Processing.Tasks; + +internal sealed class ArgumentMapPoolPolicy : PooledObjectPolicy> +{ + public override Dictionary Create() + => new(StringComparer.Ordinal); + + public override bool Return(Dictionary obj) + { + obj.Clear(); + return true; + } +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/BatchResolverTask.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/BatchResolverTask.cs new file mode 100644 index 00000000000..9b5ee8e95b6 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/BatchResolverTask.cs @@ -0,0 +1,481 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Extensions.ObjectPool; +using HotChocolate.Execution.Instrumentation; +using HotChocolate.Execution.Internal; +using HotChocolate.Resolvers; +using HotChocolate.Text.Json; +using HotChocolate.Types; +using static HotChocolate.Execution.Processing.ValueCompletion; + +namespace HotChocolate.Execution.Processing.Tasks; + +/// +/// An execution task that collects multiple parent contexts for a batch resolver +/// and executes them in a single invocation. +/// +internal sealed class BatchResolverTask : IResolverTask +{ + private readonly List _resolverTasks = []; + private readonly List _entries = []; + private readonly List _taskBuffer = []; + private readonly List> _rentedArgs = []; + private readonly ObjectPool _objectPool; + private readonly ObjectPool _resolverTaskPool; + private readonly ObjectPool> _argumentMapPool; + private readonly HashSet _branchIds = []; + private ExecutionTaskStatus _completionStatus = ExecutionTaskStatus.Completed; + private OperationContext _operationContext = null!; + private ObjectField _field = null!; + private SelectionPath _selectionPath = null!; + private int _branchId; + + public BatchResolverTask( + ObjectPool objectPool, + ObjectPool resolverTaskPool, + ObjectPool> argumentMapPool) + { + _objectPool = objectPool; + _resolverTaskPool = resolverTaskPool; + _argumentMapPool = argumentMapPool; + } + + /// + /// Gets or sets the internal execution id. + /// + public uint Id { get; set; } + + /// + /// Gets the execution branch identifier this task belongs to. + /// + public int BranchId => _branchId; + + /// + /// Gets all branch identifiers that are associated with this task. + /// + public IReadOnlySet BranchIds => _branchIds; + + /// + /// Gets the primary defer usage for this batch. + /// + internal DeferUsage? DeferUsage { get; private set; } + + /// + public IExecutionTaskContext Context => _operationContext; + + private IExecutionDiagnosticEvents DiagnosticEvents => _operationContext.DiagnosticEvents; + + /// + /// Gets the selection path this batch task is associated with. + /// Used by the work scheduler to track active paths. + /// + public SelectionPath FieldSelectionPath => _selectionPath; + + /// + public ExecutionTaskKind Kind => ExecutionTaskKind.Parallel; + + /// + public ExecutionTaskStatus Status { get; private set; } + + /// + public IExecutionTask? Next { get; set; } + + /// + public IExecutionTask? Previous { get; set; } + + /// + public object? State { get; set; } + + /// + public bool IsSerial { get; set; } + + /// + public bool IsRegistered { get; set; } + + /// + public bool IsDeferred => DeferUsage is not null; + + /// + public void BeginExecute(CancellationToken cancellationToken) + { +#pragma warning disable CA2012 + Status = ExecutionTaskStatus.Running; + _ = ExecuteAsync(cancellationToken); +#pragma warning restore CA2012 + } + + /// + /// Adds a parent context entry to this batch. + /// Called during value completion when a batch field is encountered. + /// + internal bool AddEntry( + object? parent, + Selection selection, + ResultElement resultValue, + IImmutableDictionary scopedContextData, + int branchId) + { + _entries.Add(new BatchEntry(parent, selection, resultValue, scopedContextData, branchId)); + return _branchIds.Add(branchId); + } + + private async ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + var contexts = CreateContexts(); + + try + { + using (DiagnosticEvents.ResolveFieldValue(contexts[0])) + { + var success = await TryExecuteAsync(contexts, cancellationToken).ConfigureAwait(false); + CompleteValues(success, contexts, cancellationToken); + + switch (_taskBuffer.Count) + { + case 0: + break; + + case 1: + _operationContext.Scheduler.Register(_taskBuffer[0]); + break; + + default: + _operationContext.Scheduler.Register( + CollectionsMarshal.AsSpan(_taskBuffer)); + break; + } + } + + Status = _completionStatus; + } + catch + { + // If an exception occurs on this level it means that something was wrong with the + // operation context. + + // In this case we will mark the task as faulted and set the result to null. + + // However, we will not report or rethrow the exception since the context was already + // destroyed, and we would cause further exceptions. + + // The exception on this level is most likely caused by a cancellation of the request. + Status = ExecutionTaskStatus.Faulted; + } + finally + { + _operationContext.Scheduler.Complete(this); + + for (var i = 0; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + if (context.HasCleanupTasks) + { + await context.ExecuteCleanupTasksAsync().ConfigureAwait(false); + } + } + + ReturnResolverTasks(); + + _objectPool.Return(this); + } + } + + private async ValueTask TryExecuteAsync( + ImmutableArray contexts, + CancellationToken cancellationToken) + { + // We will pre-check if the request was already canceled and mark the task as faulted if + // this is the case. This essentially gives us a cheap and easy way out without any + // exceptions. + if (cancellationToken.IsCancellationRequested) + { + _completionStatus = ExecutionTaskStatus.Faulted; + return false; + } + + try + { + var allHaveErrors = true; + + for (var i = 0; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + + // If the arguments are already parsed and processed we can just process. + // Arguments need no pre-processing if they have no variables. + if (context.Selection.Arguments.IsFullyCoercedNoErrors) + { + context.Arguments = context.Selection.Arguments; + allHaveErrors = false; + continue; + } + + // if we have errors on the compiled execution plan we will report the errors and + // signal that this resolver task has errors and shall end. + if (context.Selection.Arguments.HasErrors) + { + foreach (var argument in context.Selection.Arguments.ArgumentValues) + { + if (argument.HasError) + { + context.ReportError(argument.Error!); + } + } + + continue; + } + + // if this field has arguments that contain variables we first need to coerce them + // before we can start executing the resolver. + var args = _argumentMapPool.Get(); + context.Selection.Arguments.CoerceArguments(context.Variables, args); + context.Arguments = args; + _rentedArgs.Add(args); + allHaveErrors = false; + } + + if (allHaveErrors) + { + return false; + } + + await ExecuteBatchPipelineAsync(contexts, cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + // If cancellation has not been requested for the request we assume this to + // be a GraphQL resolver error and report it as such. + // This will let the error handler produce a GraphQL error, and + // we set the result to null. + for (var i = 0; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + if (!context.HasErrors) + { + context.ReportError(ex); + context.Result = null; + } + } + } + } + + return false; + } + + private async ValueTask ExecuteBatchPipelineAsync( + ImmutableArray contexts, + CancellationToken cancellationToken) + { + // Create DI scopes for each context if needed. + if (_field.DependencyInjectionScope == DependencyInjectionScope.Resolver) + { + // we only use a single service scope for all contexts + // as they all run in the same resolver. + var first = Unsafe.As(contexts[0]); + var serviceScope = _operationContext.Services.CreateAsyncScope(); + first.Services = serviceScope.ServiceProvider; + first.RegisterForCleanup(serviceScope.DisposeAsync); + _operationContext.ServiceScopeInitializer.Initialize( + first, first.RequestServices, first.Services); + + for (var i = 1; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + context.Services = serviceScope.ServiceProvider; + } + } + + await _field.BatchResolver!(contexts).ConfigureAwait(false); + + // Post-process results for each context. + if (_field.ResultPostProcessor is { } postProcessor) + { + for (var i = 0; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + var result = context.Result; + + if (result is null) + { + continue; + } + + if (result is IError error) + { + context.ReportError(error); + context.Result = null; + continue; + } + + context.Result = await postProcessor + .ToCompletionResultAsync(result, cancellationToken) + .ConfigureAwait(false); + } + } + else + { + for (var i = 0; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + var result = context.Result; + + if (result is IError error) + { + context.ReportError(error); + context.Result = null; + } + } + } + } + + private void CompleteValues( + bool success, + ImmutableArray contexts, + CancellationToken cancellationToken) + { + for (var i = 0; i < contexts.Length; i++) + { + var context = Unsafe.As(contexts[i]); + var resultValue = context.ResultValue; + var result = context.Result; + + try + { + // we will only try to complete the resolver value if there are no known errors. + if (success) + { + var completionContext = + new ValueCompletionContext( + _operationContext, + context, + _taskBuffer, + context.BranchId); + + Complete(completionContext, context.Selection, resultValue, result); + } + } + catch (OperationCanceledException) + { + _completionStatus = ExecutionTaskStatus.Faulted; + context.Result = null; + return; + } + catch (Exception ex) + { + context.Result = null; + + if (!cancellationToken.IsCancellationRequested) + { + context.ReportError(ex); + resultValue.SetNullValue(); + } + } + + if (resultValue is { IsNullable: false, IsNullOrInvalidated: true }) + { + PropagateNullValues(resultValue); + _completionStatus = ExecutionTaskStatus.Faulted; + _operationContext.Result.AddNonNullViolation(context.Path); + _taskBuffer.Clear(); + } + } + } + + private ImmutableArray CreateContexts() + { + var builder = ImmutableArray.CreateBuilder(_entries.Count); + + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + var resolverTask = + _operationContext.CreateResolverTask( + entry.Parent, + entry.Selection, + entry.ResultValue, + entry.ScopedContextData, + entry.BranchId, + DeferUsage); + + var context = Unsafe.As(resolverTask.Context); + context.BranchId = entry.BranchId; + + _resolverTasks.Add(resolverTask); + builder.Add(context); + } + + return builder.MoveToImmutable(); + } + + private void ReturnResolverTasks() + { + foreach (var task in _resolverTasks) + { + _resolverTaskPool.Return(task); + } + + _resolverTasks.Clear(); + } + + /// + /// Initializes this batch task. + /// + public void Initialize( + OperationContext operationContext, + ObjectField field, + SelectionPath selectionPath, + int branchId, + DeferUsage? deferUsage) + { + _operationContext = operationContext; + _field = field; + _selectionPath = selectionPath; + _branchId = branchId; + DeferUsage = deferUsage; + } + + /// + /// Resets the batch task for reuse. + /// + internal bool Reset() + { + _completionStatus = ExecutionTaskStatus.Completed; + _resolverTasks.Clear(); + _entries.Clear(); + _taskBuffer.Clear(); + + foreach (var args in _rentedArgs) + { + _argumentMapPool.Return(args); + } + + _rentedArgs.Clear(); + _branchIds.Clear(); + _operationContext = null!; + _field = null!; + _selectionPath = null!; + _branchId = 0; + DeferUsage = null; + Status = ExecutionTaskStatus.WaitingToRun; + IsSerial = false; + IsRegistered = false; + Next = null; + Previous = null; + State = null; + return true; + } + + /// + /// Represents a single entry in the batch — one parent object and its result location. + /// + private readonly record struct BatchEntry( + object? Parent, + Selection Selection, + ResultElement ResultValue, + IImmutableDictionary ScopedContextData, + int BranchId); +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/BatchResolverTaskPoolPolicy.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/BatchResolverTaskPoolPolicy.cs new file mode 100644 index 00000000000..5bafbc9df94 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/BatchResolverTaskPoolPolicy.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.ObjectPool; +using HotChocolate.Resolvers; + +namespace HotChocolate.Execution.Processing.Tasks; + +internal sealed class BatchResolverTaskPoolPolicy( + ObjectPool resolverTaskPool, + ObjectPool> argumentMapPool) : ExecutionTaskPoolPolicy +{ + public override BatchResolverTask Create( + ObjectPool executionTaskPool) => + new(executionTaskPool, resolverTaskPool, argumentMapPool); + + public override bool Reset(BatchResolverTask executionTask) + => executionTask.Reset(); +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/IResolverTask.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/IResolverTask.cs new file mode 100644 index 00000000000..b6547be6345 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/IResolverTask.cs @@ -0,0 +1,6 @@ +namespace HotChocolate.Execution.Processing.Tasks; + +internal interface IResolverTask : IExecutionTask +{ + SelectionPath FieldSelectionPath { get; } +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Execute.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Execute.cs index f536c07f65c..b1fc4692fb5 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Execute.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Execute.cs @@ -128,7 +128,8 @@ private async ValueTask ExecuteResolverPipelineAsync(CancellationToken cancellat var serviceScope = _operationContext.Services.CreateAsyncScope(); _context.Services = serviceScope.ServiceProvider; _context.RegisterForCleanup(serviceScope.DisposeAsync); - _operationContext.ServiceScopeInitializer.Initialize(_context, _context.RequestServices, _context.Services); + _operationContext.ServiceScopeInitializer.Initialize( + _context, _context.RequestServices, _context.Services); } await _context.ResolverPipeline!(_context).ConfigureAwait(false); diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Pooling.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Pooling.cs index 34d3347da15..9366b429b92 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Pooling.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.Pooling.cs @@ -20,6 +20,7 @@ public void Initialize( _operationContext = operationContext; _selection = selection; _context.Initialize(parent, selection, resultValue, operationContext, deferUsage, scopedContextData); + _context.BranchId = executionBranchId; IsSerial = selection.Strategy is SelectionExecutionStrategy.Serial; BranchId = executionBranchId; DeferUsage = deferUsage; diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.cs index 1952a9e6afb..c68e8eb1139 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTask.cs @@ -4,7 +4,7 @@ namespace HotChocolate.Execution.Processing.Tasks; -internal sealed partial class ResolverTask(ObjectPool objectPool) : IExecutionTask +internal sealed partial class ResolverTask(ObjectPool objectPool) : IResolverTask { private readonly MiddlewareContext _context = new(); private readonly List _taskBuffer = []; @@ -55,6 +55,7 @@ public ExecutionTaskKind Kind SelectionExecutionStrategy.Default => ExecutionTaskKind.Parallel, SelectionExecutionStrategy.Serial => ExecutionTaskKind.Serial, SelectionExecutionStrategy.Pure => ExecutionTaskKind.Pure, + SelectionExecutionStrategy.Batch => ExecutionTaskKind.Parallel, _ => throw new NotSupportedException() }; @@ -79,10 +80,15 @@ public ExecutionTaskKind Kind /// public bool IsDeferred => DeferUsage is not null; + /// + public SelectionPath FieldSelectionPath => Selection.FieldSelectionPath; + /// public void BeginExecute(CancellationToken cancellationToken) { +#pragma warning disable CA2012 Status = ExecutionTaskStatus.Running; _ = ExecuteAsync(cancellationToken); +#pragma warning restore CA2012 } } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTaskFactory.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTaskFactory.cs index b11ad78d19e..82b9974d910 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTaskFactory.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/Tasks/ResolverTaskFactory.cs @@ -58,12 +58,24 @@ public static void EnqueueRootResolverTasks( continue; } - bufferedTasks[i++] = - operationContext.CreateResolverTask( - parent, + if (selection.Strategy is SelectionExecutionStrategy.Batch) + { + scheduler.RegisterBatchEntry( selection, + parent, field.Value, - scopedContext); + scopedContext, + mainBranchId); + } + else + { + bufferedTasks[i++] = + operationContext.CreateResolverTask( + parent, + selection, + field.Value, + scopedContext); + } } if (i == 0 && branches.IsEmpty) @@ -99,12 +111,26 @@ public static void EnqueueRootResolverTasks( { foreach (var field in data) { - bufferedTasks[i++] = - operationContext.CreateResolverTask( + var selection = field.AssertSelection(); + + if (selection.Strategy is SelectionExecutionStrategy.Batch) + { + scheduler.RegisterBatchEntry( + selection, parent, - field.AssertSelection(), field.Value, - scopedContext); + scopedContext, + mainBranchId); + } + else + { + bufferedTasks[i++] = + operationContext.CreateResolverTask( + parent, + selection, + field.Value, + scopedContext); + } } if (i == 0) @@ -200,6 +226,16 @@ public static void EnqueueOrInlineResolverTasks( field.Value, parent); } + else if (selection.Strategy is SelectionExecutionStrategy.Batch) + { + operationContext.Scheduler.RegisterBatchEntry( + selection, + parent, + field.Value, + context.ResolverContext.ScopedContextData, + context.ParentBranchId, + parentDeferUsage); + } else { context.Tasks.Add( @@ -228,6 +264,15 @@ public static void EnqueueOrInlineResolverTasks( field.Value, parent); } + else if (selection.Strategy is SelectionExecutionStrategy.Batch) + { + operationContext.Scheduler.RegisterBatchEntry( + selection, + parent, + field.Value, + context.ResolverContext.ScopedContextData, + context.ParentBranchId); + } else { context.Tasks.Add( @@ -236,8 +281,7 @@ public static void EnqueueOrInlineResolverTasks( selection, field.Value, context.ResolverContext.ScopedContextData, - context.ParentBranchId, - parentDeferUsage)); + context.ParentBranchId)); } } } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs new file mode 100644 index 00000000000..64d3ca71017 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs @@ -0,0 +1,136 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using HotChocolate.Execution.Processing.Tasks; +using HotChocolate.Text.Json; + +namespace HotChocolate.Execution.Processing; + +internal sealed partial class WorkScheduler +{ + private readonly Dictionary _activePaths = []; + private readonly Dictionary<(SelectionPath Path, DeferUsage? Defer), BatchResolverTask> _pendingBatches = []; + + /// + /// Registers work to be executed as part of a batch resolver task. + /// + public void RegisterBatchEntry( + Selection selection, + object? parent, + ResultElement resultValue, + IImmutableDictionary scopedContextData, + int branchId, + DeferUsage? deferUsage = null) + { + AssertNotPooled(); + + var key = (selection.FieldSelectionPath, deferUsage); + + lock (_sync) + { + if (!_pendingBatches.TryGetValue(key, out var batchTask)) + { + batchTask = + operationContext.CreateBatchResolverTask( + selection.Field, + selection.FieldSelectionPath, + branchId, + deferUsage); + _pendingBatches[key] = batchTask; + IncrementPathCountUnsafe(selection.FieldSelectionPath); + } + + if (batchTask.AddEntry(parent, selection, resultValue, scopedContextData, branchId)) + { + RegisterBranchTaskUnsafe(branchId); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void IncrementPathCountUnsafe(SelectionPath path) + { + ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(_activePaths, path, out _); + count++; + } + + /// + /// Decrements the active task count for the given path and checks if any + /// pending batch tasks can now be dispatched. + /// + private void DecrementPathCountUnsafe(SelectionPath path) + { + ref var count = ref CollectionsMarshal.GetValueRefOrNullRef(_activePaths, path); + + if (!Unsafe.IsNullRef(ref count) && --count <= 0) + { + _activePaths.Remove(path); + TryDispatchPendingBatchesUnsafe(); + } + } + + /// + /// Checks all pending batch tasks and dispatches any whose ancestor paths + /// all have zero active tasks. + /// + private void TryDispatchPendingBatchesUnsafe() + { + if (_pendingBatches.Count == 0) + { + return; + } + + List<(SelectionPath Path, DeferUsage? Defer)>? toRemove = null; + + foreach (var (key, batchTask) in _pendingBatches) + { + if (!CanDispatchBatchUnsafe(key.Path)) + { + continue; + } + + toRemove ??= []; + toRemove.Add(key); + + batchTask.Id = Interlocked.Increment(ref _nextId); + batchTask.IsRegistered = true; + _work.Push(batchTask); + } + + if (toRemove is null) + { + return; + } + + foreach (var key in toRemove) + { + _pendingBatches.Remove(key); + } + + _signal.Set(); + } + + /// + /// Determines whether a batch task at the given path can be dispatched. + /// A batch is dispatchable when all strict ancestor paths have zero active tasks. + /// Walks the cached parent chain on — no allocation. + /// + private bool CanDispatchBatchUnsafe(SelectionPath batchPath) + { + // Walk up ancestor paths. If any ancestor still has active tasks, + // more entries could still be added to this batch. + var ancestor = batchPath.Parent; + + while (ancestor is not null) + { + if (_activePaths.TryGetValue(ancestor, out var count) && count > 0) + { + return false; + } + + ancestor = ancestor.Parent; + } + + return true; + } +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs index 53ae2efe59d..0130a529276 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs @@ -219,7 +219,7 @@ private void TryDispatchOrComplete(bool isWaitingForTaskCompletion = false) } else { - if (!hasWork) + if (!hasWork && _pendingBatches.Count == 0) { _isCompleted = true; } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Pooling.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Pooling.cs index 0768be46150..e414aba7734 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Pooling.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Pooling.cs @@ -48,6 +48,8 @@ public void Clear() _serial.Clear(); _completed.Clear(); _activeBranches.Clear(); + _activePaths.Clear(); + _pendingBatches.Clear(); _signal.Reset(); _result = null!; diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs index d1b4508adf9..6506e8f4092 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace HotChocolate.Execution.Processing; /// @@ -39,6 +41,11 @@ public void Register(IExecutionTask task) { work.Push(task); RegisterBranchTaskUnsafe(task.BranchId); + + if (task is Tasks.ResolverTask rt) + { + IncrementPathCountUnsafe(rt.Selection.FieldSelectionPath); + } } _signal.Set(); @@ -69,6 +76,11 @@ public void Register(ReadOnlySpan tasks) } RegisterBranchTaskUnsafe(task.BranchId); + + if (task is Tasks.ResolverTask rt) + { + IncrementPathCountUnsafe(rt.Selection.FieldSelectionPath); + } } } @@ -82,23 +94,58 @@ public void Complete(IExecutionTask task) { AssertNotPooled(); - if (task.IsRegistered) + if (!task.IsRegistered) + { + return; + } + + var work = task.IsSerial ? _serial : _work; + + switch (task) { - var work = task.IsSerial ? _serial : _work; + case Tasks.ResolverTask resolverTask: + CompleteBranchTask(task.BranchId); - CompleteBranchTask(task.BranchId); + if (work.Complete()) + { + lock (_sync) + { + _completed.Add(resolverTask.Id); + DecrementPathCountUnsafe(resolverTask.FieldSelectionPath); + } + } + break; - // complete is thread-safe - if (work.Complete()) - { + case Tasks.BatchResolverTask batchResolverTask: lock (_sync) { - _completed.Add(task.Id); + foreach (var additionalBranchId in batchResolverTask.BranchIds) + { + CompleteBranchTaskUnsafe(additionalBranchId); + } + + if (work.Complete()) + { + _completed.Add(task.Id); + DecrementPathCountUnsafe(batchResolverTask.FieldSelectionPath); + } } + break; - _signal.Set(); - } + default: + CompleteBranchTask(task.BranchId); + + if (work.Complete()) + { + lock (_sync) + { + _completed.Add(task.Id); + } + } + break; } + + _signal.Set(); } private void RegisterBranchTaskUnsafe(int branchId) @@ -135,6 +182,21 @@ private void CompleteBranchTask(int branchId) } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CompleteBranchTaskUnsafe(int branchId) + { + if (branchId == BranchTracker.SystemBranchId) + { + return; + } + if (_activeBranches.TryGetValue(branchId, out var branch) + && branch.CompleteTask()) + { + _activeBranches.Remove(branchId); + branch.Complete(); + } + } + private sealed class Branch(int id) { private readonly AsyncManualResetEvent _signal = new(); diff --git a/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs b/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs index 620fac42dba..5af3e23332a 100644 --- a/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs +++ b/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs @@ -121,7 +121,34 @@ internal static string Base64StringType_Description { return ResourceManager.GetString("Base64StringType_Description", resourceCulture); } } - + + /// + /// Looks up a localized string similar to The parameter '{0}' on a batch resolver must be a list type .... + /// + internal static string BatchResolver_ArgumentMustBeList { + get { + return ResourceManager.GetString("BatchResolver_ArgumentMustBeList", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A batch resolver must return exactly one result per context. Expected {0} results but got {1}.. + /// + internal static string BatchResolver_ResultCountMismatch { + get { + return ResourceManager.GetString("BatchResolver_ResultCountMismatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The batch resolver method '{0}.{1}' must return a list type .... + /// + internal static string BatchResolver_ReturnTypeMustBeList { + get { + return ResourceManager.GetString("BatchResolver_ReturnTypeMustBeList", resourceCulture); + } + } + /// /// Looks up a localized string similar to The specified binding cannot be handled.. /// diff --git a/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx b/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx index a09a2d08b04..7e72e68b0cb 100644 --- a/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx +++ b/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx @@ -1038,4 +1038,13 @@ Type: `{0}` OutputPrecision must be less than or equal to 7. + + The parameter '{0}' on a batch resolver must be a list type (e.g. List<T>, IReadOnlyList<T>, ImmutableArray<T> or T[]). Batch resolvers receive one value per parent object, so all argument parameters must be collections. + + + A batch resolver must return exactly one result per context. Expected {0} results but got {1}. + + + The batch resolver method '{0}.{1}' must return a list type (e.g. List<T>, IReadOnlyList<T>, ImmutableArray<T> or T[]). Batch resolvers return one result per parent object, so the return type must be a collection. + diff --git a/src/HotChocolate/Core/src/Types/Resolvers/BatchFieldDelegate.cs b/src/HotChocolate/Core/src/Types/Resolvers/BatchFieldDelegate.cs new file mode 100644 index 00000000000..38b39295e54 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Resolvers/BatchFieldDelegate.cs @@ -0,0 +1,11 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Resolvers; + +/// +/// This delegate defines the interface of a batch field pipeline that the +/// execution engine invokes to resolve a field result for multiple parent +/// objects in a single invocation. +/// +/// The middleware contexts for all parent objects in the batch. +public delegate ValueTask BatchFieldDelegate(ImmutableArray contexts); diff --git a/src/HotChocolate/Core/src/Types/Resolvers/BatchFieldMiddleware.cs b/src/HotChocolate/Core/src/Types/Resolvers/BatchFieldMiddleware.cs new file mode 100644 index 00000000000..fa5de303544 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Resolvers/BatchFieldMiddleware.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Resolvers; + +/// +/// This delegate defines the factory to integrate a batch field middleware +/// into the batch field pipeline. +/// +/// +/// The next batch field middleware that has to be invoked after the middleware +/// that is created by this factory. +/// +/// +/// Returns the batch field middleware that is created by this factory. +/// +public delegate BatchFieldDelegate BatchFieldMiddleware(BatchFieldDelegate next); diff --git a/src/HotChocolate/Core/src/Types/Resolvers/BatchResolverCompiler.cs b/src/HotChocolate/Core/src/Types/Resolvers/BatchResolverCompiler.cs new file mode 100644 index 00000000000..f0c2164a4a7 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Resolvers/BatchResolverCompiler.cs @@ -0,0 +1,393 @@ +using System.Collections.Immutable; +using System.Linq.Expressions; +using System.Reflection; +using HotChocolate.Internal; +using static System.Linq.Expressions.Expression; + +namespace HotChocolate.Resolvers; + +/// +/// Compiles a batch resolver method into a +/// using expression trees. The compiled delegate has no reflection overhead +/// at execution time. +/// +internal static class BatchResolverCompiler +{ + private static readonly MethodInfo s_parent = + typeof(IResolverContext).GetMethod(nameof(IResolverContext.Parent))!; + + private static readonly MethodInfo s_argumentValue = + typeof(IResolverContext).GetMethods() + .First(m => m.Name == nameof(IResolverContext.ArgumentValue) && m.IsGenericMethod); + + private static readonly MethodInfo s_resolver = + typeof(IResolverContext).GetMethod(nameof(IResolverContext.Resolver))!; + + private static readonly PropertyInfo s_contextsLength = + typeof(ImmutableArray).GetProperty(nameof(ImmutableArray.Length))!; + + private static readonly MethodInfo s_contextsItem = + typeof(ImmutableArray).GetProperty("Item")!.GetMethod!; + + /// + /// Compiles a batch resolver method into a . + /// Produces a delegate shaped like: + /// + /// async contexts => + /// { + /// var parents = new List<User>(contexts.Length); + /// var arg1 = new List<string>(contexts.Length); + /// var svc = contexts[0].Service<MyService>(); + /// + /// for (int i = 0; i < contexts.Length; i++) + /// { + /// parents.Add(contexts[i].Parent<User>()); + /// arg1.Add(contexts[i].ArgumentValue<string>("arg1")); + /// } + /// + /// var result = resolverMethod(parents, arg1, svc); + /// DistributeList(contexts, result); + /// } + /// + /// + public static BatchFieldDelegate Compile( + MethodInfo method, + Type? sourceType, + Type? resolverType, + IReadOnlyDictionary argumentNames, + Func getBuilder) + { + var contextsParam = Parameter(typeof(ImmutableArray), "contexts"); + var parameters = method.GetParameters(); + var variables = new List(); + var preLoopStatements = new List(); + var loopBodyStatements = new List(); + var postLoopStatements = new List(); + + // Loop variable: int i + var indexVar = Variable(typeof(int), "i"); + variables.Add(indexVar); + + // contexts[i] cast to IMiddlewareContext + var contextAtIndex = Convert( + Call(contextsParam, s_contextsItem, indexVar), + typeof(IMiddlewareContext)); + + // Build parameter expressions. + var parameterVariables = new ParameterExpression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var builder = getBuilder(param); + var kind = builder.Kind; + + switch (kind) + { + case ArgumentKind.Source: + { + // Batched: collect Parent() from each context. + var (listVar, listInit, addExpr) = + CreateListCollector(contextsParam, contextAtIndex, param, ctx => + Call(ctx, s_parent.MakeGenericMethod(GetListElementType(param.ParameterType)!))); + + parameterVariables[i] = listVar; + variables.Add(listVar); + preLoopStatements.Add(listInit); + loopBodyStatements.Add(addExpr); + break; + } + + case ArgumentKind.Argument: + { + // Batched: collect ArgumentValue() from each context. + var elementType = GetListElementType(param.ParameterType)!; + var argName = argumentNames.TryGetValue(param, out var name) ? name : param.Name!; + + var (listVar, listInit, addExpr) = + CreateListCollector(contextsParam, contextAtIndex, param, ctx => + Call(ctx, s_argumentValue.MakeGenericMethod(elementType), Constant(argName))); + + parameterVariables[i] = listVar; + variables.Add(listVar); + preLoopStatements.Add(listInit); + loopBodyStatements.Add(addExpr); + break; + } + + default: + // Singular: inject from contexts[0]. + var paramVar = Variable(param.ParameterType, $"p{i}_{param.Name}"); + parameterVariables[i] = paramVar; + variables.Add(paramVar); + preLoopStatements.Add( + Assign(paramVar, BuildFirstContextValue(contextsParam, param, builder))); + break; + } + } + + // Build the collection loop (only if there are batched parameters). + if (loopBodyStatements.Count > 0) + { + var breakLabel = Label("break"); + + var loop = Block( + Assign(indexVar, Constant(0)), + Loop( + IfThenElse( + LessThan(indexVar, Property(contextsParam, s_contextsLength)), + Block( + loopBodyStatements.Append(PostIncrementAssign(indexVar))), + Break(breakLabel)), + breakLabel)); + + preLoopStatements.Add(loop); + } + + // Call the resolver method. + Expression callExpr; + + if (method.IsStatic) + { + callExpr = Call(method, parameterVariables); + } + else + { + var ownerExpr = BuildResolverOwner(contextsParam, method, sourceType, resolverType); + callExpr = Call(ownerExpr, method, parameterVariables); + } + + // Handle async vs sync return types. + var returnType = method.ReturnType; + var (unwrappedType, isAsync) = UnwrapAsyncType(returnType); + + if (isAsync) + { + return CompileAsync( + contextsParam, variables, preLoopStatements, callExpr, returnType, unwrappedType); + } + + // Sync: call method, distribute, return default ValueTask. + var resultVar = Variable(unwrappedType, "result"); + variables.Add(resultVar); + preLoopStatements.Add(Assign(resultVar, callExpr)); + preLoopStatements.Add(BuildDistributeResults(contextsParam, resultVar, unwrappedType)); + preLoopStatements.Add(Default(typeof(ValueTask))); + + var body = Block(typeof(ValueTask), variables, preLoopStatements); + return Lambda(body, contextsParam).Compile(); + } + + private static (ParameterExpression listVar, Expression listInit, Expression addExpr) CreateListCollector( + ParameterExpression contextsParam, + Expression contextAtIndex, + ParameterInfo parameter, + Func valueFactory) + { + var paramType = parameter.ParameterType; + var elementType = GetListElementType(paramType) + ?? throw new InvalidOperationException( + $"Batch resolver parameter '{parameter.Name}' must be a list type " + + $"(List, IReadOnlyList, T[], or ImmutableArray). Got: {paramType}."); + + var listType = typeof(List<>).MakeGenericType(elementType); + var listCtor = listType.GetConstructor([typeof(int)])!; + var addMethod = listType.GetMethod("Add")!; + + var listVar = Variable(listType, $"list_{parameter.Name}"); + var listInit = Assign(listVar, New(listCtor, Property(contextsParam, s_contextsLength))); + var addExpr = Call(listVar, addMethod, valueFactory(contextAtIndex)); + + return (listVar, listInit, addExpr); + } + + private static BatchFieldDelegate CompileAsync( + ParameterExpression contextsParam, + List variables, + List bodyStatements, + Expression callExpr, + Type returnType, + Type unwrappedType) + { + // Compile the argument-building + method call into a Func that returns the async result. + // Then wrap with a thin async delegate that awaits and distributes. + var resultVar = Variable(returnType, "asyncResult"); + variables.Add(resultVar); + bodyStatements.Add(Assign(resultVar, callExpr)); + bodyStatements.Add(resultVar); + + var body = Block(returnType, variables, bodyStatements); + + if (returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var funcType = typeof(Func<,>).MakeGenericType( + typeof(ImmutableArray), returnType); + var invoker = Lambda(funcType, body, contextsParam).Compile(); + + var wrapMethod = typeof(BatchResolverCompiler) + .GetMethod(nameof(WrapAsyncTask), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(unwrappedType); + + return (BatchFieldDelegate)wrapMethod.Invoke(null, [invoker])!; + } + else + { + var funcType = typeof(Func<,>).MakeGenericType( + typeof(ImmutableArray), returnType); + var invoker = Lambda(funcType, body, contextsParam).Compile(); + + var wrapMethod = typeof(BatchResolverCompiler) + .GetMethod(nameof(WrapAsyncValueTask), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(unwrappedType); + + return (BatchFieldDelegate)wrapMethod.Invoke(null, [invoker])!; + } + } + + private static BatchFieldDelegate WrapAsyncTask( + Func, Task> invoker) + { + return async contexts => + { + var result = await invoker(contexts).ConfigureAwait(false); + DistributeList(contexts, result); + }; + } + + private static BatchFieldDelegate WrapAsyncValueTask( + Func, ValueTask> invoker) + { + return async contexts => + { + var result = await invoker(contexts).ConfigureAwait(false); + DistributeList(contexts, result); + }; + } + + private static void DistributeList(ImmutableArray contexts, T result) + { + if (result is null) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = null; + } + + return; + } + + if (result is System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + else + { + throw new InvalidOperationException( + $"Batch resolver must return a list type. Got: {result.GetType()}."); + } + } + + /// + /// Gets a value from the first context using the existing expression builder. + /// + private static Expression BuildFirstContextValue( + ParameterExpression contextsParam, + ParameterInfo parameter, + IParameterExpressionBuilder builder) + { + var contextParam = Parameter(typeof(IResolverContext), "ctx"); + var buildContext = new ParameterExpressionBuilderContext( + parameter, + contextParam, + new Dictionary()); + var expr = builder.Build(buildContext); + + // Replace contextParam with (IResolverContext)contexts.ItemRef(0) + var firstContext = Convert( + Call(contextsParam, s_contextsItem, Constant(0)), + typeof(IResolverContext)); + + return new ParameterReplacer(contextParam, firstContext).Visit(expr); + } + + /// + /// Builds the expression to get the resolver owner instance from contexts[0]. + /// + private static Expression BuildResolverOwner( + ParameterExpression contextsParam, + MethodInfo method, + Type? sourceType, + Type? resolverType) + { + var firstContext = Convert( + Call(contextsParam, s_contextsItem, Constant(0)), + typeof(IResolverContext)); + + if (resolverType is not null && resolverType != sourceType) + { + return Call(firstContext, s_resolver.MakeGenericMethod(resolverType)); + } + + var parentType = sourceType ?? method.DeclaringType!; + return Call(firstContext, s_parent.MakeGenericMethod(parentType)); + } + + private static Expression BuildDistributeResults( + ParameterExpression contextsParam, + ParameterExpression resultVar, + Type resultType) + { + var distributeMethod = typeof(BatchResolverCompiler) + .GetMethod(nameof(DistributeList), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(resultType); + + return Call(distributeMethod, contextsParam, resultVar); + } + + private static (Type unwrapped, bool isAsync) UnwrapAsyncType(Type type) + { + if (type.IsGenericType) + { + var def = type.GetGenericTypeDefinition(); + + if (def == typeof(Task<>) || def == typeof(ValueTask<>)) + { + return (type.GetGenericArguments()[0], true); + } + } + + return (type, false); + } + + internal static Type? GetListElementType(Type type) + { + if (type.IsArray) + { + return type.GetElementType(); + } + + if (type.IsGenericType) + { + var def = type.GetGenericTypeDefinition(); + + if (def == typeof(List<>) + || def == typeof(IReadOnlyList<>) + || def == typeof(IList<>) + || def == typeof(ImmutableArray<>)) + { + return type.GetGenericArguments()[0]; + } + } + + return null; + } + + private sealed class ParameterReplacer(ParameterExpression from, Expression to) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) + => node == from ? to : base.VisitParameter(node); + } +} diff --git a/src/HotChocolate/Core/src/Types/Resolvers/BatchResolverDelegate.cs b/src/HotChocolate/Core/src/Types/Resolvers/BatchResolverDelegate.cs new file mode 100644 index 00000000000..3ccd8ec9edb --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Resolvers/BatchResolverDelegate.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Resolvers; + +/// +/// This delegate describes the batch resolver interface that the execution +/// engine uses to resolve a field for multiple parent objects in a single +/// invocation. +/// +/// The resolver contexts for all parent objects in the batch. +/// +/// Returns a list of resolver results, one per context, in the same order. +/// +public delegate ValueTask> BatchResolverDelegate( + IReadOnlyList contexts); diff --git a/src/HotChocolate/Core/src/Types/Resolvers/DefaultResolverCompiler.cs b/src/HotChocolate/Core/src/Types/Resolvers/DefaultResolverCompiler.cs index ede16b5876b..016df7141c7 100644 --- a/src/HotChocolate/Core/src/Types/Resolvers/DefaultResolverCompiler.cs +++ b/src/HotChocolate/Core/src/Types/Resolvers/DefaultResolverCompiler.cs @@ -284,6 +284,27 @@ public SubscribeResolverDelegate CompileSubscribe( nameof(member)); } + /// + public BatchFieldDelegate CompileBatchResolve( + MethodInfo method, + Type? sourceType = null, + Type? resolverType = null, + IReadOnlyDictionary? argumentNames = null, + IReadOnlyList? parameterExpressionBuilders = null) + { + ArgumentNullException.ThrowIfNull(method); + + argumentNames ??= _emptyLookup; + parameterExpressionBuilders ??= s_empty; + + return BatchResolverCompiler.Compile( + method, + sourceType, + resolverType, + argumentNames, + p => GetParameterExpressionBuilder(p, parameterExpressionBuilders)); + } + /// public IEnumerable GetArgumentParameters( ParameterInfo[] parameters, diff --git a/src/HotChocolate/Core/src/Types/Resolvers/IResolverCompiler.cs b/src/HotChocolate/Core/src/Types/Resolvers/IResolverCompiler.cs index 5790e5605b1..307c6642e0d 100644 --- a/src/HotChocolate/Core/src/Types/Resolvers/IResolverCompiler.cs +++ b/src/HotChocolate/Core/src/Types/Resolvers/IResolverCompiler.cs @@ -113,6 +113,34 @@ SubscribeResolverDelegate CompileSubscribe( IReadOnlyDictionary? argumentNames = null, IReadOnlyList? parameterExpressionBuilders = null); + /// + /// Compiles a batch resolver from a method. + /// + /// + /// The batch resolver method. + /// + /// + /// The source type. + /// + /// + /// The resolver type. + /// + /// + /// The parameter argument name lookup. + /// + /// + /// Field level parameter expression builders. + /// + /// + /// Returns the compiled batch field delegate. + /// + BatchFieldDelegate CompileBatchResolve( + MethodInfo method, + Type? sourceType = null, + Type? resolverType = null, + IReadOnlyDictionary? argumentNames = null, + IReadOnlyList? parameterExpressionBuilders = null); + /// /// Filters the specified arguments and returns only the parameters /// representing GraphQL field arguments. diff --git a/src/HotChocolate/Core/src/Types/Resolvers/ResolverResult.cs b/src/HotChocolate/Core/src/Types/Resolvers/ResolverResult.cs new file mode 100644 index 00000000000..6b28a0ddecb --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Resolvers/ResolverResult.cs @@ -0,0 +1,48 @@ +namespace HotChocolate.Resolvers; + +/// +/// Represents the result of a single resolver invocation within a batch. +/// +public readonly struct ResolverResult +{ + private ResolverResult(object? value, IError? error) + { + Value = value; + Error = error; + } + + /// + /// Gets the resolved value. + /// + public object? Value { get; } + + /// + /// Gets the error if the resolution failed. + /// + public IError? Error { get; } + + /// + /// Gets a value indicating whether this result represents an error. + /// + public bool IsError => Error is not null; + + /// + /// Creates a successful result. + /// + public static ResolverResult Ok(object? value) => new(value, null); + + /// + /// Creates an error result. + /// + public static ResolverResult Fail(IError error) => new(null, error); + + /// + /// Implicitly converts a value to a successful result. + /// + public static implicit operator ResolverResult(string? value) => Ok(value); + + /// + /// Implicitly converts an error to an error result. + /// + public static implicit operator ResolverResult(Error error) => Fail(error); +} diff --git a/src/HotChocolate/Core/src/Types/Types/Attributes/BatchResolverAttribute.cs b/src/HotChocolate/Core/src/Types/Types/Attributes/BatchResolverAttribute.cs new file mode 100644 index 00000000000..c47ba686fc7 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Attributes/BatchResolverAttribute.cs @@ -0,0 +1,35 @@ +namespace HotChocolate.Types; + +/// +/// Marks a method as a batch resolver. A batch resolver receives lists of +/// parent objects and arguments, resolves them in a single invocation, and +/// returns a list of results — one per parent. +/// +/// Unlike a normal resolver that is called once per parent object, a batch +/// resolver is called once for all sibling parent objects in a selection set. +/// This allows the resolver to perform a single operation (e.g. a database +/// query) for the entire batch instead of N individual operations. +/// +/// +/// The method's [Parent] parameter must be a list type (e.g. +/// List<T>, IReadOnlyList<T>, or T[]) +/// containing the parent objects. All argument parameters must also be list +/// types — one value per parent. The return type must be a list whose +/// element type becomes the GraphQL field type. +/// +/// +/// +/// [ObjectType<User>] +/// public class UserExtensions +/// { +/// [BatchResolver] +/// public List<string> GetGreeting([Parent] List<User> users) +/// { +/// return users.Select(u => $"Hello, {u.Name}!").ToList(); +/// } +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class BatchResolverAttribute : Attribute; diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/ArgumentDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/ArgumentDescriptor.cs index 9ac4ce0beea..1eb06d2afb9 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/ArgumentDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/ArgumentDescriptor.cs @@ -1,10 +1,10 @@ -#nullable disable - // ReSharper disable VirtualMemberCallInConstructor using System.Reflection; using HotChocolate.Language; +using HotChocolate.Resolvers; using HotChocolate.Types.Descriptors.Configurations; +using ThrowHelper = HotChocolate.Utilities.ThrowHelper; namespace HotChocolate.Types.Descriptors; @@ -45,14 +45,26 @@ protected internal ArgumentDescriptor( /// protected internal ArgumentDescriptor( IDescriptorContext context, - ParameterInfo parameter) + ParameterInfo parameter, + bool isBatchResolverArgument) : base(context) { Configuration.Name = context.Naming.GetArgumentName(parameter); Configuration.Description = context.Naming.GetArgumentDescription(parameter); - Configuration.Type = context.TypeInspector.GetArgumentTypeRef(parameter); Configuration.Parameter = parameter; + if (isBatchResolverArgument) + { + var elementType = BatchResolverCompiler.GetListElementType(parameter.ParameterType) + ?? throw ThrowHelper.BatchResolver_ArgumentMustBeList(parameter); + + Configuration.Type = context.TypeInspector.GetTypeRef(elementType, TypeContext.Input); + } + else + { + Configuration.Type = context.TypeInspector.GetArgumentTypeRef(parameter); + } + if (context.TypeInspector.TryGetDefaultValue(parameter, out var defaultValue)) { Configuration.RuntimeDefaultValue = defaultValue; @@ -96,7 +108,7 @@ protected override void OnCreateConfiguration(ArgumentConfiguration definition) } /// - public new IArgumentDescriptor Deprecated(string reason) + public new IArgumentDescriptor Deprecated(string? reason) { base.Deprecated(reason); return this; @@ -110,7 +122,7 @@ protected override void OnCreateConfiguration(ArgumentConfiguration definition) } /// - public new IArgumentDescriptor Description(string value) + public new IArgumentDescriptor Description(string? value) { base.Description(value); return this; @@ -147,14 +159,14 @@ protected override void OnCreateConfiguration(ArgumentConfiguration definition) } /// - public new IArgumentDescriptor DefaultValue(IValueNode value) + public new IArgumentDescriptor DefaultValue(IValueNode? value) { base.DefaultValue(value); return this; } /// - public new IArgumentDescriptor DefaultValue(object value) + public new IArgumentDescriptor DefaultValue(object? value) { base.DefaultValue(value); return this; @@ -214,11 +226,15 @@ public static ArgumentDescriptor New( /// /// The descriptor context /// The parameter this argument is used for + /// + /// Specifies if the argument type needs to be unwrapped as its part of a batch resolver. + /// /// An instance of public static ArgumentDescriptor New( IDescriptorContext context, - ParameterInfo parameter) => - new(context, parameter); + ParameterInfo parameter, + bool isBatchResolverArgument = false) => + new(context, parameter, isBatchResolverArgument); /// /// Creates a new instance of diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/BatchFieldMiddlewareConfiguration.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/BatchFieldMiddlewareConfiguration.cs new file mode 100644 index 00000000000..1e1af12237b --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/BatchFieldMiddlewareConfiguration.cs @@ -0,0 +1,48 @@ +using HotChocolate.Resolvers; + +namespace HotChocolate.Types.Descriptors.Configurations; + +/// +/// Represents a batch middleware configuration. +/// +public sealed class BatchFieldMiddlewareConfiguration : IRepeatableConfiguration +{ + /// + /// Initializes a new instance of . + /// + /// + /// The delegate representing the batch middleware. + /// + /// + /// Defines if the middleware is repeatable and + /// the same middleware is allowed to occur multiple times. + /// + /// + /// The key is optional and is used to identify a middleware. + /// + public BatchFieldMiddlewareConfiguration( + BatchFieldMiddleware middleware, + bool isRepeatable = true, + string? key = null) + { + Middleware = middleware; + IsRepeatable = isRepeatable; + Key = key; + } + + /// + /// Gets the delegate representing the batch middleware. + /// + public BatchFieldMiddleware Middleware { get; } + + /// + /// Defines if the middleware is repeatable and + /// the same middleware is allowed to occur multiple times. + /// + public bool IsRepeatable { get; } + + /// + /// The key is optional and is used to identify a middleware. + /// + public string? Key { get; } +} diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs index 54952edd856..ff132a166e7 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs @@ -45,5 +45,6 @@ internal enum CoreFieldFlags : long MutationQueryField = 1 << 29, WithRequirements = 1 << 30, UsesProjections = 1L << 31, - ImplicitField = 1L << 32 + ImplicitField = 1L << 32, + BatchResolver = 1L << 33 } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs index a8e4979f82f..8a981bf1949 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs @@ -117,6 +117,8 @@ public IReadOnlyList GetDirectives() public void SetSourceGeneratorFlags() => Flags |= CoreFieldFlags.SourceGenerator; + public void SetBatchResolverFlags() => Flags |= CoreFieldFlags.BatchResolver; + public void SetConnectionFlags() => Flags |= CoreFieldFlags.Connection; public void SetConnectionEdgesFieldFlags() => Flags |= CoreFieldFlags.ConnectionEdgesField; diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/InterfaceFieldConfiguration.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/InterfaceFieldConfiguration.cs index d41d271aabb..00e8e902556 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/InterfaceFieldConfiguration.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/InterfaceFieldConfiguration.cs @@ -14,9 +14,11 @@ namespace HotChocolate.Types.Descriptors.Configurations; public class InterfaceFieldConfiguration : OutputFieldConfiguration { private List? _middlewareDefinitions; + private List? _batchMiddlewareDefinitions; private List? _resultConverters; private List? _expressionBuilders; private bool _middlewareDefinitionsCleaned; + private bool _batchMiddlewareDefinitionsCleaned; private bool _resultConvertersCleaned; /// @@ -76,6 +78,23 @@ public InterfaceFieldConfiguration( /// public PureFieldDelegate? PureResolver { get; set; } + /// + /// The delegate that represents the batch resolver. + /// + public BatchFieldDelegate? BatchResolver { get; set; } + + /// + /// A list of batch middleware components which will be used to form the batch field pipeline. + /// + public IList BatchMiddlewareConfigurations + { + get + { + _batchMiddlewareDefinitionsCleaned = false; + return _batchMiddlewareDefinitions ??= []; + } + } + /// /// Gets or sets all resolvers at once. /// @@ -204,6 +223,21 @@ internal IReadOnlyList GetResultConverters() return _resultConverters; } + /// + /// A list of batch middleware components which will be used to form the batch field pipeline. + /// + internal IReadOnlyList GetBatchMiddlewareDefinitions() + { + if (_batchMiddlewareDefinitions is null) + { + return []; + } + + CleanRepeatableConfigurations(_batchMiddlewareDefinitions, ref _batchMiddlewareDefinitionsCleaned); + + return _batchMiddlewareDefinitions; + } + /// /// A list of parameter expression builders that shall be applied when compiling /// the resolver or when arguments are inferred from a method. @@ -231,6 +265,12 @@ internal void CopyTo(InterfaceFieldConfiguration target) _middlewareDefinitionsCleaned = false; } + if (_batchMiddlewareDefinitions is { Count: > 0 }) + { + target._batchMiddlewareDefinitions = [.. _batchMiddlewareDefinitions]; + _batchMiddlewareDefinitionsCleaned = false; + } + if (_resultConverters is { Count: > 0 }) { target._resultConverters = [.. _resultConverters]; @@ -250,6 +290,7 @@ internal void CopyTo(InterfaceFieldConfiguration target) target.ResultType = ResultType; target.Resolver = Resolver; target.PureResolver = PureResolver; + target.BatchResolver = BatchResolver; target.IsParallelExecutable = IsParallelExecutable; target.DependencyInjectionScope = DependencyInjectionScope; target.HasStreamResult = HasStreamResult; @@ -269,6 +310,15 @@ internal void CopyTo(ObjectFieldConfiguration target) _middlewareDefinitionsCleaned = false; } + if (_batchMiddlewareDefinitions is { Count: > 0 }) + { + foreach (var definition in _batchMiddlewareDefinitions) + { + target.BatchMiddlewareConfigurations.Add(definition); + } + _batchMiddlewareDefinitionsCleaned = false; + } + if (_resultConverters is { Count: > 0 }) { foreach (var definition in _resultConverters) @@ -293,6 +343,7 @@ internal void CopyTo(ObjectFieldConfiguration target) target.ResolverMember = ResolverMember; target.Resolver = Resolver; target.PureResolver = PureResolver; + target.BatchResolver = BatchResolver; target.IsParallelExecutable = IsParallelExecutable; target.DependencyInjectionScope = DependencyInjectionScope; target.HasStreamResult = HasStreamResult; @@ -310,6 +361,13 @@ internal void MergeInto(InterfaceFieldConfiguration target) _middlewareDefinitionsCleaned = false; } + if (_batchMiddlewareDefinitions is { Count: > 0 }) + { + target._batchMiddlewareDefinitions ??= []; + target._batchMiddlewareDefinitions.AddRange(_batchMiddlewareDefinitions); + _batchMiddlewareDefinitionsCleaned = false; + } + if (_resultConverters is { Count: > 0 }) { target._resultConverters ??= []; @@ -363,6 +421,11 @@ internal void MergeInto(InterfaceFieldConfiguration target) target.PureResolver = PureResolver; } + if (BatchResolver is not null) + { + target.BatchResolver = BatchResolver; + } + if (ResultPostProcessor is not null) { target.ResultPostProcessor = ResultPostProcessor; diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectFieldConfiguration.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectFieldConfiguration.cs index f002cf4acb1..d4b55ce5f8f 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectFieldConfiguration.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectFieldConfiguration.cs @@ -15,9 +15,11 @@ namespace HotChocolate.Types.Descriptors.Configurations; public class ObjectFieldConfiguration : OutputFieldConfiguration { private List? _middlewareDefinitions; + private List? _batchMiddlewareDefinitions; private List? _resultConverters; private List? _expressionBuilders; private bool _middlewareDefinitionsCleaned; + private bool _batchMiddlewareDefinitionsCleaned; private bool _resultConvertersCleaned; /// @@ -91,6 +93,23 @@ public ObjectFieldConfiguration( /// public PureFieldDelegate? PureResolver { get; set; } + /// + /// The delegate that represents the batch resolver. + /// + public BatchFieldDelegate? BatchResolver { get; set; } + + /// + /// A list of batch middleware components which will be used to form the batch field pipeline. + /// + public IList BatchMiddlewareConfigurations + { + get + { + _batchMiddlewareDefinitionsCleaned = false; + return _batchMiddlewareDefinitions ??= []; + } + } + /// /// Gets or sets all resolvers at once. /// @@ -244,6 +263,21 @@ internal IReadOnlyList GetResultConverters() return _resultConverters; } + /// + /// A list of batch middleware components which will be used to form the batch field pipeline. + /// + internal IReadOnlyList GetBatchMiddlewareDefinitions() + { + if (_batchMiddlewareDefinitions is null) + { + return Array.Empty(); + } + + CleanMiddlewareDefinitions(_batchMiddlewareDefinitions, ref _batchMiddlewareDefinitionsCleaned); + + return _batchMiddlewareDefinitions; + } + /// /// A list of parameter expression builders that shall be applied when compiling /// the resolver or when arguments are inferred from a method. @@ -271,6 +305,12 @@ internal void CopyTo(ObjectFieldConfiguration target) _middlewareDefinitionsCleaned = false; } + if (_batchMiddlewareDefinitions is { Count: > 0 }) + { + target._batchMiddlewareDefinitions = [.. _batchMiddlewareDefinitions]; + _batchMiddlewareDefinitionsCleaned = false; + } + if (_resultConverters is { Count: > 0 }) { target._resultConverters = [.. _resultConverters]; @@ -290,6 +330,7 @@ internal void CopyTo(ObjectFieldConfiguration target) target.Expression = Expression; target.Resolver = Resolver; target.PureResolver = PureResolver; + target.BatchResolver = BatchResolver; target.SubscribeResolver = SubscribeResolver; target.IsIntrospectionField = IsIntrospectionField; target.IsParallelExecutable = IsParallelExecutable; @@ -310,6 +351,13 @@ internal void MergeInto(ObjectFieldConfiguration target) _middlewareDefinitionsCleaned = false; } + if (_batchMiddlewareDefinitions is { Count: > 0 }) + { + target._batchMiddlewareDefinitions ??= []; + target._batchMiddlewareDefinitions.AddRange(_batchMiddlewareDefinitions); + _batchMiddlewareDefinitionsCleaned = false; + } + if (_resultConverters is { Count: > 0 }) { target._resultConverters ??= []; @@ -368,6 +416,11 @@ internal void MergeInto(ObjectFieldConfiguration target) target.PureResolver = PureResolver; } + if (BatchResolver is not null) + { + target.BatchResolver = BatchResolver; + } + if (SubscribeResolver is not null) { target.SubscribeResolver = SubscribeResolver; diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectTypeConfiguration.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectTypeConfiguration.cs index aff12900fe5..35d1f37a6ae 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectTypeConfiguration.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/ObjectTypeConfiguration.cs @@ -242,13 +242,10 @@ protected internal void MergeInto(ObjectTypeConfiguration target) var removeField = field.Ignore; // we skip fields that have an incompatible parent. - if (field.Member is MethodInfo p - && p.GetParameters() is { Length: > 0 } parameters) + if (field.Member is MethodInfo p && p.GetParameters() is { Length: > 0 } parameters) { - var parent = parameters.FirstOrDefault( - t => t.IsDefined(typeof(ParentAttribute), true)); - if (parent?.ParameterType.IsAssignableFrom(target.RuntimeType) == false - && !target.RuntimeType.IsAssignableFrom(parent.ParameterType)) + var parent = parameters.FirstOrDefault(t => t.IsDefined(typeof(ParentAttribute), true)); + if (parent is not null && !IsParentCompatible(parent.ParameterType, target.RuntimeType, field.Flags)) { continue; } @@ -299,4 +296,27 @@ private static void SetResolverMember( sourceField.Member = targetField?.Member; } } + + private static bool IsParentCompatible(Type parentType, Type targetType, CoreFieldFlags flags) + { + if (parentType.IsAssignableFrom(targetType) + || targetType.IsAssignableFrom(parentType)) + { + return true; + } + + // For batch resolvers, the parent parameter is a list of the target type. + if ((flags & CoreFieldFlags.BatchResolver) == CoreFieldFlags.BatchResolver) + { + var elementType = Resolvers.BatchResolverCompiler.GetListElementType(parentType); + if (elementType is not null + && (elementType.IsAssignableFrom(targetType) + || targetType.IsAssignableFrom(elementType))) + { + return true; + } + } + + return false; + } } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs index 01858cc135e..f1df22c1261 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs @@ -141,7 +141,7 @@ IInterfaceFieldDescriptor Argument( /// ]]> /// /// - /// + /// The descriptor IInterfaceFieldDescriptor Resolve(FieldResolverDelegate fieldResolver); /// @@ -161,7 +161,7 @@ IInterfaceFieldDescriptor Argument( /// ]]> /// /// - /// + /// The descriptor IInterfaceFieldDescriptor Resolve( FieldResolverDelegate fieldResolver, Type? resultType); @@ -195,7 +195,7 @@ IInterfaceFieldDescriptor Resolve( /// ]]> /// /// - /// + /// The descriptor IInterfaceFieldDescriptor ResolveWith( Expression> propertyOrMethod); @@ -228,9 +228,27 @@ IInterfaceFieldDescriptor ResolveWith( /// ]]> /// /// - /// + /// The descriptor IInterfaceFieldDescriptor ResolveWith(MemberInfo propertyOrMethod); + /// + /// Adds a batch resolver based on a method to the field. + /// The method must return a list type whose element type becomes the GraphQL field type. + /// + /// The type that contains the batch resolver method. + /// An expression selecting the batch resolver method. + /// The descriptor + IInterfaceFieldDescriptor ResolveBatchWith( + Expression> propertyOrMethod); + + /// + /// Adds a batch resolver based on a method to the field. + /// The method must return a list type whose element type becomes the GraphQL field type. + /// + /// The batch resolver member. + /// The descriptor + IInterfaceFieldDescriptor ResolveBatchWith(MemberInfo propertyOrMethod); + /// /// Registers a middleware on the field. The middleware is integrated in the resolver /// pipeline and is executed before the resolver itself diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IObjectFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IObjectFieldDescriptor.cs index 91e5db9849c..76ed0ecaa4a 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IObjectFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IObjectFieldDescriptor.cs @@ -260,6 +260,41 @@ IObjectFieldDescriptor ResolveWith( /// The descriptor IObjectFieldDescriptor Use(FieldMiddleware middleware); + /// + /// Adds a batch resolver to the field. A batch resolver receives multiple + /// parent contexts and resolves them in a single invocation. + /// + /// The batch resolver delegate. + IObjectFieldDescriptor ResolveBatch(BatchResolverDelegate batchResolver); + + /// + /// Adds a batch resolver based on a method to the field. + /// The method must be annotated with + /// and must return a list type. + /// + /// The type that contains the batch resolver method. + /// + /// An expression selecting the batch resolver method, + /// e.g. t => t.GetGreeting(default!, default!). + /// + IObjectFieldDescriptor ResolveBatchWith( + Expression> propertyOrMethod); + + /// + /// Adds a batch resolver based on a method to the field. + /// The method must be annotated with + /// and must return a list type. + /// + /// The batch resolver member. + IObjectFieldDescriptor ResolveBatchWith(MemberInfo propertyOrMethod); + + /// + /// Registers a batch middleware on the field. The middleware wraps the + /// batch resolver pipeline and is executed before the batch resolver itself. + /// + /// The batch middleware. + IObjectFieldDescriptor UseBatch(BatchFieldMiddleware middleware); + /// /// Registers a directive on the field /// diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs index f21a6717d91..40d286b59f7 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs @@ -7,6 +7,7 @@ using HotChocolate.Types.Helpers; using HotChocolate.Utilities; using static HotChocolate.Properties.TypeResources; +using ThrowHelper = HotChocolate.Utilities.ThrowHelper; namespace HotChocolate.Types.Descriptors; @@ -54,6 +55,11 @@ protected internal InterfaceFieldDescriptor( { _parameterInfos = context.TypeInspector.GetParameters(m); Parameters = _parameterInfos.ToDictionary(t => t.Name!, StringComparer.Ordinal); + + if (m.IsDefined(typeof(BatchResolverAttribute))) + { + Configuration.Flags |= CoreFieldFlags.BatchResolver; + } } } @@ -87,13 +93,17 @@ private void CompleteArguments(InterfaceFieldConfiguration definition) FieldDescriptorUtilities.DiscoverArguments( Context, definition.Arguments, - definition.Member, + definition.ResolverMember ?? definition.Member, _parameterInfos, - definition.GetParameterExpressionBuilders()); + definition.GetParameterExpressionBuilders(), + IsBatchResolver()); _argumentsInitialized = true; } } + private bool IsBatchResolver() + => (Configuration.Flags & CoreFieldFlags.BatchResolver) == CoreFieldFlags.BatchResolver; + public new IInterfaceFieldDescriptor Name(string name) { base.Name(name); @@ -257,6 +267,59 @@ private IInterfaceFieldDescriptor ResolveWithInternal( nameof(propertyOrMethod)); } + public IInterfaceFieldDescriptor ResolveBatchWith( + Expression> propertyOrMethod) + { + ArgumentNullException.ThrowIfNull(propertyOrMethod); + + return ResolveBatchWithInternal(propertyOrMethod.ExtractMember(), typeof(TResolver)); + } + + public IInterfaceFieldDescriptor ResolveBatchWith(MemberInfo propertyOrMethod) + { + ArgumentNullException.ThrowIfNull(propertyOrMethod); + + return ResolveBatchWithInternal(propertyOrMethod, propertyOrMethod.DeclaringType); + } + + private IInterfaceFieldDescriptor ResolveBatchWithInternal( + MemberInfo propertyOrMethod, + Type? resolverType) + { + if (resolverType?.IsAbstract is true) + { + throw new ArgumentException( + string.Format( + ObjectTypeDescriptor_ResolveWith_NonAbstract, + resolverType.FullName), + nameof(resolverType)); + } + + if (propertyOrMethod is not MethodInfo method) + { + throw new ArgumentException( + ObjectTypeDescriptor_MustBePropertyOrMethod, + nameof(propertyOrMethod)); + } + + var elementType = BatchResolverCompiler.GetListElementType(method.ReturnType) + ?? throw ThrowHelper.BatchResolver_ReturnTypeMustBeList(method); + + Configuration.Flags |= CoreFieldFlags.BatchResolver; + Configuration.SetMoreSpecificType( + Context.TypeInspector.GetType(elementType), + TypeContext.Output); + Configuration.ResolverType = resolverType; + Configuration.ResolverMember = propertyOrMethod; + Configuration.Resolver = null; + Configuration.ResultType = elementType; + + _parameterInfos = Context.TypeInspector.GetParameters(method); + Parameters = _parameterInfos.ToDictionary(t => t.Name!, StringComparer.Ordinal); + + return this; + } + public IInterfaceFieldDescriptor Use(FieldMiddleware middleware) { ArgumentNullException.ThrowIfNull(middleware); diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs index 35b0487e699..6e8ab58eae9 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs @@ -9,6 +9,7 @@ using HotChocolate.Utilities; using static System.Reflection.BindingFlags; using static HotChocolate.Properties.TypeResources; +using ThrowHelper = HotChocolate.Utilities.ThrowHelper; namespace HotChocolate.Types.Descriptors; @@ -46,7 +47,6 @@ protected ObjectFieldDescriptor( Configuration.Member = member ?? throw new ArgumentNullException(nameof(member)); Configuration.Name = naming.GetMemberName(member, MemberKind.ObjectField); Configuration.Description = naming.GetMemberDescription(member, MemberKind.ObjectField); - Configuration.Type = context.TypeInspector.GetOutputReturnTypeRef(member); Configuration.SourceType = sourceType; Configuration.ResolverType = resolverType == sourceType ? null : resolverType; Configuration.IsParallelExecutable = context.Options.DefaultResolverStrategy is ExecutionStrategy.Parallel; @@ -61,11 +61,28 @@ protected ObjectFieldDescriptor( case MethodInfo m: _parameterInfos = context.TypeInspector.GetParameters(m); Parameters = _parameterInfos.ToDictionary(t => t.Name!, StringComparer.Ordinal); - Configuration.ResultType = m.ReturnType; + if (m.IsDefined(typeof(BatchResolverAttribute))) + { + Configuration.SetBatchResolverFlags(); + var elementType = BatchResolverCompiler.GetListElementType(m.ReturnType) + ?? throw ThrowHelper.BatchResolver_ReturnTypeMustBeList(m); + Configuration.ResultType = elementType; + Configuration.Type = context.TypeInspector.GetTypeRef(elementType, TypeContext.Output); + } + else + { + Configuration.ResultType = m.ReturnType; + Configuration.Type = context.TypeInspector.GetOutputReturnTypeRef(member); + } break; case PropertyInfo p: Configuration.ResultType = p.PropertyType; + Configuration.Type = context.TypeInspector.GetOutputReturnTypeRef(member); + break; + + default: + Configuration.Type = context.TypeInspector.GetOutputReturnTypeRef(member); break; } } @@ -102,7 +119,18 @@ protected ObjectFieldDescriptor( switch (member) { case MethodInfo m: - Configuration.ResultType = m.ReturnType; + if (m.IsDefined(typeof(BatchResolverAttribute))) + { + Configuration.SetBatchResolverFlags(); + var elementType = BatchResolverCompiler.GetListElementType(m.ReturnType) + ?? throw ThrowHelper.BatchResolver_ReturnTypeMustBeList(m); + Configuration.Type = context.TypeInspector.GetTypeRef(elementType, TypeContext.Output); + Configuration.ResultType = elementType; + } + else + { + Configuration.ResultType = m.ReturnType; + } break; case PropertyInfo p: @@ -205,9 +233,10 @@ private void CompleteArguments(ObjectFieldConfiguration definition) FieldDescriptorUtilities.DiscoverArguments( Context, definition.Arguments, - definition.Member, + definition.ResolverMember ?? definition.Member, _parameterInfos, - definition.GetParameterExpressionBuilders()); + definition.GetParameterExpressionBuilders(), + IsBatchResolver()); foreach (var parameter in _parameterInfos) { @@ -425,6 +454,103 @@ public IObjectFieldDescriptor Use(FieldMiddleware middleware) return this; } + /// + public IObjectFieldDescriptor ResolveBatch(BatchResolverDelegate batchResolver) + { + ArgumentNullException.ThrowIfNull(batchResolver); + + Configuration.BatchResolver = + async contexts => + { + var results = await batchResolver(contexts).ConfigureAwait(false); + + if (results.Count != contexts.Length) + { + throw ThrowHelper.BatchResolver_ResultCountMismatch(contexts.Length, results.Count); + } + + for (var i = 0; i < contexts.Length; i++) + { + var result = results[i]; + + if (result.IsError) + { + contexts[i].ReportError(result.Error!); + contexts[i].Result = null; + } + else + { + contexts[i].Result = result.Value; + } + } + }; + return this; + } + + /// + public IObjectFieldDescriptor ResolveBatchWith( + Expression> propertyOrMethod) + { + ArgumentNullException.ThrowIfNull(propertyOrMethod); + + return ResolveBatchWithInternal(propertyOrMethod.ExtractMember(), typeof(TResolver)); + } + + /// + public IObjectFieldDescriptor ResolveBatchWith(MemberInfo propertyOrMethod) + { + ArgumentNullException.ThrowIfNull(propertyOrMethod); + + return ResolveBatchWithInternal(propertyOrMethod, propertyOrMethod.DeclaringType); + } + + private IObjectFieldDescriptor ResolveBatchWithInternal( + MemberInfo propertyOrMethod, + Type? resolverType) + { + if (resolverType?.IsAbstract is true) + { + throw new ArgumentException( + string.Format( + ObjectTypeDescriptor_ResolveWith_NonAbstract, + resolverType.FullName), + nameof(resolverType)); + } + + if (propertyOrMethod is not MethodInfo method) + { + throw new ArgumentException( + ObjectTypeDescriptor_MustBePropertyOrMethod, + nameof(propertyOrMethod)); + } + + var elementType = BatchResolverCompiler.GetListElementType(method.ReturnType) + ?? throw ThrowHelper.BatchResolver_ReturnTypeMustBeList(method); + + Configuration.SetBatchResolverFlags(); + Configuration.SetMoreSpecificType( + Context.TypeInspector.GetType(elementType), + TypeContext.Output); + Configuration.ResolverType = resolverType; + Configuration.ResolverMember = propertyOrMethod; + Configuration.Resolver = null; + Configuration.ResultType = elementType; + + _parameterInfos = Context.TypeInspector.GetParameters(method); + Parameters = _parameterInfos.ToDictionary(t => t.Name!, StringComparer.Ordinal); + + return this; + } + + /// + public IObjectFieldDescriptor UseBatch(BatchFieldMiddleware middleware) + { + ArgumentNullException.ThrowIfNull(middleware); + + Configuration.BatchMiddlewareConfigurations.Add(new BatchFieldMiddlewareConfiguration(middleware)); + return this; + } + /// public new IObjectFieldDescriptor Directive(T directiveInstance) where T : class @@ -483,6 +609,9 @@ public IObjectFieldDescriptor ParentRequires(string? requires) return this; } + private bool IsBatchResolver() + => (Configuration.Flags & CoreFieldFlags.BatchResolver) == CoreFieldFlags.BatchResolver; + /// /// Creates a new instance of /// diff --git a/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs b/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs index 23c682a6838..f6089c74004 100644 --- a/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs +++ b/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs @@ -96,7 +96,8 @@ public static void DiscoverArguments( ICollection arguments, MemberInfo? member, ParameterInfo[] parameters, - IReadOnlyList? parameterExpressionBuilders) + IReadOnlyList? parameterExpressionBuilders, + bool isBatchResolver = false) { ArgumentNullException.ThrowIfNull(arguments); @@ -121,7 +122,7 @@ public static void DiscoverArguments( { var argumentDefinition = ArgumentDescriptor - .New(context, parameter) + .New(context, parameter, isBatchResolver) .CreateConfiguration(); if (!string.IsNullOrEmpty(argumentDefinition.Name) diff --git a/src/HotChocolate/Core/src/Types/Types/ObjectField.cs b/src/HotChocolate/Core/src/Types/Types/ObjectField.cs index d26af35c2cb..a858561d87b 100644 --- a/src/HotChocolate/Core/src/Types/Types/ObjectField.cs +++ b/src/HotChocolate/Core/src/Types/Types/ObjectField.cs @@ -98,6 +98,11 @@ private set /// public SubscribeResolverDelegate? SubscribeResolver { get; private set; } + /// + /// Gets the batch resolver. + /// + public BatchFieldDelegate? BatchResolver { get; private set; } + /// /// Gets the result post-processor. /// @@ -205,7 +210,8 @@ options.FieldMiddleware is not FieldMiddlewareApplication.AllFields Resolver, skipMiddleware); - if (middleware is null) + if (middleware is null + && definition.BatchResolver is null) { context.ReportError( ObjectField_HasNoResolver( @@ -213,13 +219,21 @@ options.FieldMiddleware is not FieldMiddlewareApplication.AllFields Name, context.Type)); } - else + else if (middleware is not null) { Middleware = middleware; } ResultPostProcessor = definition.ResultPostProcessor; + // Compile the batch resolver pipeline if a batch resolver is configured. + if (definition.BatchResolver is not null) + { + BatchResolver = CompileBatchPipeline( + definition.GetBatchMiddlewareDefinitions(), + definition.BatchResolver); + } + // if the source generator has configured this field, we will not try to infer a post-processor with // reflection. if ((Flags & CoreFieldFlags.SourceGenerator) != CoreFieldFlags.SourceGenerator @@ -252,6 +266,25 @@ static Type GetResultType(ObjectFieldConfiguration definition, Type runtimeType) return definition.ResultType; } } + + private static BatchFieldDelegate CompileBatchPipeline( + IReadOnlyList middlewareComponents, + BatchFieldDelegate batchResolver) + { + if (middlewareComponents is not { Count: > 0 }) + { + return batchResolver; + } + + var next = batchResolver; + + for (var i = middlewareComponents.Count - 1; i >= 0; i--) + { + next = middlewareComponents[i].Middleware(next); + } + + return next; + } } file static class ResolverHelpers diff --git a/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs b/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs index 3fc1d5cbf53..e14facf7028 100644 --- a/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs +++ b/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs @@ -121,7 +121,7 @@ protected virtual ObjectFieldCollection OnCompleteFields( continue; } - if (field.Resolvers.HasResolvers) + if (field.Resolvers.HasResolvers || field.BatchResolver is not null) { interfaceFields.Add(field.Name, field); } @@ -131,10 +131,18 @@ protected virtual ObjectFieldCollection OnCompleteFields( foreach (var field in definition.Fields) { if (processed.Add(field.Name) - && !field.Resolvers.HasResolvers && interfaceFields.TryGetValue(field.Name, out var interfaceField)) { - field.Resolvers = interfaceField.Resolvers; + if (!field.Resolvers.HasResolvers) + { + field.Resolvers = interfaceField.Resolvers; + } + + if (field.BatchResolver is null && interfaceField.BatchResolver is not null) + { + field.BatchResolver = interfaceField.BatchResolver; + field.SetBatchResolverFlags(); + } } } diff --git a/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs b/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs index c66cc02803e..067c4fe08f5 100644 --- a/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs +++ b/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs @@ -30,6 +30,26 @@ public static GraphQLException EventMessage_NotFound() .SetMessage(ThrowHelper_EventMessage_NotFound) .Build()); + public static SchemaException BatchResolver_ArgumentMustBeList(ParameterInfo parameter) + => new SchemaException( + SchemaErrorBuilder.New() + .SetMessage( + TypeResources.BatchResolver_ArgumentMustBeList, + parameter.Name) + .Build()); + + public static InvalidOperationException BatchResolver_ResultCountMismatch(int expected, int actual) + => new(string.Format(TypeResources.BatchResolver_ResultCountMismatch, expected, actual)); + + public static SchemaException BatchResolver_ReturnTypeMustBeList(MethodInfo method) + => new SchemaException( + SchemaErrorBuilder.New() + .SetMessage( + TypeResources.BatchResolver_ReturnTypeMustBeList, + method.DeclaringType?.FullName ?? method.DeclaringType?.Name, + method.Name) + .Build()); + public static SchemaException SubscribeAttribute_MessageTypeUnspecified(MemberInfo member) => new SchemaException( SchemaErrorBuilder.New() diff --git a/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs index 97f35eac037..1def83b0a7c 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs @@ -1975,6 +1975,7 @@ public void OptimizeSelectionSet(SelectionSetOptimizerContext context) var compiledSelection = new Selection( context.NewSelectionId(), "someName", + SelectionPath.Root, baz, [new FieldSelectionNode(bazSelection, 0)], [], diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/ObjectTypeTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/ObjectTypeTests.cs index f216eeba572..c941025f3dc 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Tests/ObjectTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/ObjectTypeTests.cs @@ -920,4 +920,124 @@ internal static partial class BookNode } """).MatchMarkdownAsync(); } + + [Fact] + public async Task BatchResolver_Simple_MatchesSnapshot() + { + await TestHelper.GetGeneratedSourceSnapshot( + """ + using System.Collections.Generic; + using HotChocolate; + using HotChocolate.Types; + + namespace TestNamespace; + + public sealed class User + { + public int Id { get; set; } + public string Name { get; set; } + } + + [ObjectType] + public static partial class UserExtensions + { + [BatchResolver] + public static List GetGreeting([Parent] List users) + => default!; + } + """).MatchMarkdownAsync(); + } + + [Fact] + public async Task BatchResolver_Async_MatchesSnapshot() + { + await TestHelper.GetGeneratedSourceSnapshot( + """ + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using HotChocolate; + using HotChocolate.Types; + + namespace TestNamespace; + + public sealed class User + { + public int Id { get; set; } + public string Name { get; set; } + } + + [ObjectType] + public static partial class UserExtensions + { + [BatchResolver] + public static ValueTask> GetGreeting( + [Parent] List users, + CancellationToken cancellationToken) + => default!; + } + """).MatchMarkdownAsync(); + } + + [Fact] + public async Task BatchResolver_With_Service_MatchesSnapshot() + { + await TestHelper.GetGeneratedSourceSnapshot( + """ + using System.Collections.Generic; + using HotChocolate; + using HotChocolate.Types; + + namespace TestNamespace; + + public sealed class User + { + public int Id { get; set; } + public string Name { get; set; } + } + + public sealed class GreetingService + { + } + + [ObjectType] + public static partial class UserExtensions + { + [BatchResolver] + public static List GetGreeting( + [Parent] List users, + [Service] GreetingService greetingService) + => default!; + } + """).MatchMarkdownAsync(); + } + + [Fact] + public async Task BatchResolver_With_Argument_MatchesSnapshot() + { + await TestHelper.GetGeneratedSourceSnapshot( + """ + using System.Collections.Generic; + using HotChocolate; + using HotChocolate.Types; + + namespace TestNamespace; + + public sealed class User + { + public int Id { get; set; } + public string Name { get; set; } + } + + [ObjectType] + public static partial class UserExtensions + { + [BatchResolver] + public static List GetGreeting( + [Parent] List users, + List prefix) + => default!; + } + """).MatchMarkdownAsync(); + } } diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/ParentAttributeAnalyzerTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/ParentAttributeAnalyzerTests.cs index e1d741f5783..ab7479b779f 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Tests/ParentAttributeAnalyzerTests.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/ParentAttributeAnalyzerTests.cs @@ -228,6 +228,72 @@ public class BrandService enableAnalyzers: true).MatchMarkdownAsync(); } + [Fact] + public async Task ParentAttribute_BatchResolver_ListOfParentType_NoError() + { + await TestHelper.GetGeneratedSourceSnapshot( + [""" + using HotChocolate; + using HotChocolate.Types; + using System.Collections.Generic; + using System.Linq; + + namespace TestNamespace; + + [ObjectType] + public static partial class ProductNode + { + [BatchResolver] + public static List GetDisplayName( + [Parent] List products) + => products.Select(p => p.Name).ToList(); + } + + public class Product + { + public int Id { get; set; } + public string Name { get; set; } + } + """], + enableAnalyzers: true).MatchMarkdownAsync(); + } + + [Fact] + public async Task ParentAttribute_BatchResolver_ListOfWrongType_RaisesError() + { + await TestHelper.GetGeneratedSourceSnapshot( + [""" + using HotChocolate; + using HotChocolate.Types; + using System.Collections.Generic; + using System.Linq; + + namespace TestNamespace; + + [ObjectType] + public static partial class ProductNode + { + [BatchResolver] + public static List GetDisplayName( + [Parent] List brands) + => brands.Select(b => b.Name).ToList(); + } + + public class Product + { + public int Id { get; set; } + public string Name { get; set; } + } + + public class Brand + { + public int Id { get; set; } + public string Name { get; set; } + } + """], + enableAnalyzers: true).MatchMarkdownAsync(); + } + [Fact] public async Task ParentAttribute_WithRequires_TypeMismatch_RaisesError() { diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_Async_MatchesSnapshot.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_Async_MatchesSnapshot.md new file mode 100644 index 00000000000..e351161007b --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_Async_MatchesSnapshot.md @@ -0,0 +1,120 @@ +# BatchResolver_Async_MatchesSnapshot + +## HotChocolateTypeModule.735550c.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class TestsTypesRequestExecutorBuilderExtensions + { + public static IRequestExecutorBuilder AddTestsTypes(this IRequestExecutorBuilder builder) + { + builder.ConfigureDescriptorContext(ctx => ctx.TypeConfiguration.TryAdd( + "Tests::TestNamespace.UserExtensions", + () => global::TestNamespace.UserExtensions.Initialize)); + builder.AddType>(); + return builder; + } + } +} + +``` + +## UserExtensions.WaAdMHmlGJHjtEI4nqY7WA.hc.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Internal; + +namespace TestNamespace +{ + public static partial class UserExtensions + { + internal static void Initialize(global::HotChocolate.Types.IObjectTypeDescriptor descriptor) + { + var extension = descriptor.Extend(); + var configuration = extension.Configuration; + var thisType = typeof(global::TestNamespace.UserExtensions); + var bindingResolver = extension.Context.ParameterBindingResolver; + var resolvers = new __Resolvers(); + + var naming = descriptor.Extend().Context.Naming; + + descriptor + .Field(naming.GetMemberName("Greeting", global::HotChocolate.Types.MemberKind.ObjectField)) + .ExtendWith(static (field, context) => + { + var configuration = field.Configuration; + var typeInspector = field.Context.TypeInspector; + var bindingResolver = field.Context.ParameterBindingResolver; + var naming = field.Context.Naming; + + configuration.Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))); + configuration.ResultType = typeof(string); + + configuration.SetSourceGeneratorFlags(); + configuration.SetBatchResolverFlags(); + + configuration.BatchResolver = context.Resolvers.GetGreeting(); + }, + (Resolvers: resolvers, ThisType: thisType)); + + Configure(descriptor); + } + + static partial void Configure(global::HotChocolate.Types.IObjectTypeDescriptor descriptor); + + private sealed class __Resolvers + { + public HotChocolate.Resolvers.BatchFieldDelegate GetGreeting() + => GetGreeting; + + private async global::System.Threading.Tasks.ValueTask GetGreeting(global::System.Collections.Immutable.ImmutableArray contexts) + { + var args0 = new global::System.Collections.Generic.List(contexts.Length); + var args1 = contexts[0].RequestAborted; + + for (var i = 0; i < contexts.Length; i++) + { + args0.Add(contexts[i].Parent()); + } + + var result = await global::TestNamespace.UserExtensions.GetGreeting(args0, args1); + + if (result is global::System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + } + } + } +} + + +``` diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_Simple_MatchesSnapshot.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_Simple_MatchesSnapshot.md new file mode 100644 index 00000000000..f9679d5b9b2 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_Simple_MatchesSnapshot.md @@ -0,0 +1,120 @@ +# BatchResolver_Simple_MatchesSnapshot + +## HotChocolateTypeModule.735550c.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class TestsTypesRequestExecutorBuilderExtensions + { + public static IRequestExecutorBuilder AddTestsTypes(this IRequestExecutorBuilder builder) + { + builder.ConfigureDescriptorContext(ctx => ctx.TypeConfiguration.TryAdd( + "Tests::TestNamespace.UserExtensions", + () => global::TestNamespace.UserExtensions.Initialize)); + builder.AddType>(); + return builder; + } + } +} + +``` + +## UserExtensions.WaAdMHmlGJHjtEI4nqY7WA.hc.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Internal; + +namespace TestNamespace +{ + public static partial class UserExtensions + { + internal static void Initialize(global::HotChocolate.Types.IObjectTypeDescriptor descriptor) + { + var extension = descriptor.Extend(); + var configuration = extension.Configuration; + var thisType = typeof(global::TestNamespace.UserExtensions); + var bindingResolver = extension.Context.ParameterBindingResolver; + var resolvers = new __Resolvers(); + + var naming = descriptor.Extend().Context.Naming; + + descriptor + .Field(naming.GetMemberName("Greeting", global::HotChocolate.Types.MemberKind.ObjectField)) + .ExtendWith(static (field, context) => + { + var configuration = field.Configuration; + var typeInspector = field.Context.TypeInspector; + var bindingResolver = field.Context.ParameterBindingResolver; + var naming = field.Context.Naming; + + configuration.Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))); + configuration.ResultType = typeof(string); + + configuration.SetSourceGeneratorFlags(); + configuration.SetBatchResolverFlags(); + + configuration.BatchResolver = context.Resolvers.GetGreeting(); + }, + (Resolvers: resolvers, ThisType: thisType)); + + Configure(descriptor); + } + + static partial void Configure(global::HotChocolate.Types.IObjectTypeDescriptor descriptor); + + private sealed class __Resolvers + { + public HotChocolate.Resolvers.BatchFieldDelegate GetGreeting() + => GetGreeting; + + private global::System.Threading.Tasks.ValueTask GetGreeting(global::System.Collections.Immutable.ImmutableArray contexts) + { + var args0 = new global::System.Collections.Generic.List(contexts.Length); + + for (var i = 0; i < contexts.Length; i++) + { + args0.Add(contexts[i].Parent()); + } + + var result = global::TestNamespace.UserExtensions.GetGreeting(args0); + + if (result is global::System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + return default; + } + } + } +} + + +``` diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_With_Argument_MatchesSnapshot.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_With_Argument_MatchesSnapshot.md new file mode 100644 index 00000000000..59e2ab79f7e --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_With_Argument_MatchesSnapshot.md @@ -0,0 +1,154 @@ +# BatchResolver_With_Argument_MatchesSnapshot + +## HotChocolateTypeModule.735550c.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class TestsTypesRequestExecutorBuilderExtensions + { + public static IRequestExecutorBuilder AddTestsTypes(this IRequestExecutorBuilder builder) + { + builder.ConfigureDescriptorContext(ctx => ctx.TypeConfiguration.TryAdd( + "Tests::TestNamespace.UserExtensions", + () => global::TestNamespace.UserExtensions.Initialize)); + builder.AddType>(); + return builder; + } + } +} + +``` + +## UserExtensions.WaAdMHmlGJHjtEI4nqY7WA.hc.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Internal; + +namespace TestNamespace +{ + public static partial class UserExtensions + { + internal static void Initialize(global::HotChocolate.Types.IObjectTypeDescriptor descriptor) + { + var extension = descriptor.Extend(); + var configuration = extension.Configuration; + var thisType = typeof(global::TestNamespace.UserExtensions); + var bindingResolver = extension.Context.ParameterBindingResolver; + var resolvers = new __Resolvers(bindingResolver); + + var naming = descriptor.Extend().Context.Naming; + + descriptor + .Field(naming.GetMemberName("Greeting", global::HotChocolate.Types.MemberKind.ObjectField)) + .ExtendWith(static (field, context) => + { + var configuration = field.Configuration; + var typeInspector = field.Context.TypeInspector; + var bindingResolver = field.Context.ParameterBindingResolver; + var naming = field.Context.Naming; + + configuration.Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))); + configuration.ResultType = typeof(string); + + configuration.SetSourceGeneratorFlags(); + configuration.SetBatchResolverFlags(); + + var bindingInfo = field.Context.ParameterBindingResolver; + var parameter = context.Resolvers.CreateParameterDescriptor_GetGreeting_prefix(); + var parameterInfo = bindingInfo.GetBindingInfo(parameter); + + if(parameterInfo.Kind is global::HotChocolate.Internal.ArgumentKind.Argument) + { + var argumentConfiguration = new global::HotChocolate.Types.Descriptors.Configurations.ArgumentConfiguration + { + Name = naming.GetMemberName("prefix", global::HotChocolate.Types.MemberKind.Argument), + Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Input), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))), + RuntimeType = typeof(global::System.Collections.Generic.List) + }; + + configuration.Arguments.Add(argumentConfiguration); + } + + configuration.BatchResolver = context.Resolvers.GetGreeting(); + }, + (Resolvers: resolvers, ThisType: thisType)); + + Configure(descriptor); + } + + static partial void Configure(global::HotChocolate.Types.IObjectTypeDescriptor descriptor); + + private sealed class __Resolvers + { + private readonly global::HotChocolate.Internal.IParameterBinding _binding_GetGreeting_prefix; + + public __Resolvers(global::HotChocolate.Resolvers.ParameterBindingResolver bindingResolver) + { + _binding_GetGreeting_prefix = bindingResolver.GetBinding(CreateParameterDescriptor_GetGreeting_prefix()); + } + + public global::HotChocolate.Internal.ParameterDescriptor CreateParameterDescriptor_GetGreeting_prefix() + => new HotChocolate.Internal.ParameterDescriptor( + "prefix", + typeof(global::System.Collections.Generic.List), + isNullable: false, + []); + + public HotChocolate.Resolvers.BatchFieldDelegate GetGreeting() + => GetGreeting; + + private global::System.Threading.Tasks.ValueTask GetGreeting(global::System.Collections.Immutable.ImmutableArray contexts) + { + var args0 = new global::System.Collections.Generic.List(contexts.Length); + var args1 = new global::System.Collections.Generic.List(contexts.Length); + + for (var i = 0; i < contexts.Length; i++) + { + args0.Add(contexts[i].Parent()); + args1.Add(contexts[i].ArgumentValue("prefix")); + } + + var result = global::TestNamespace.UserExtensions.GetGreeting(args0, args1); + + if (result is global::System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + return default; + } + } + } +} + + +``` diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_With_Service_MatchesSnapshot.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_With_Service_MatchesSnapshot.md new file mode 100644 index 00000000000..6abb522c7df --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeTests.BatchResolver_With_Service_MatchesSnapshot.md @@ -0,0 +1,121 @@ +# BatchResolver_With_Service_MatchesSnapshot + +## HotChocolateTypeModule.735550c.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class TestsTypesRequestExecutorBuilderExtensions + { + public static IRequestExecutorBuilder AddTestsTypes(this IRequestExecutorBuilder builder) + { + builder.ConfigureDescriptorContext(ctx => ctx.TypeConfiguration.TryAdd( + "Tests::TestNamespace.UserExtensions", + () => global::TestNamespace.UserExtensions.Initialize)); + builder.AddType>(); + return builder; + } + } +} + +``` + +## UserExtensions.WaAdMHmlGJHjtEI4nqY7WA.hc.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Internal; + +namespace TestNamespace +{ + public static partial class UserExtensions + { + internal static void Initialize(global::HotChocolate.Types.IObjectTypeDescriptor descriptor) + { + var extension = descriptor.Extend(); + var configuration = extension.Configuration; + var thisType = typeof(global::TestNamespace.UserExtensions); + var bindingResolver = extension.Context.ParameterBindingResolver; + var resolvers = new __Resolvers(); + + var naming = descriptor.Extend().Context.Naming; + + descriptor + .Field(naming.GetMemberName("Greeting", global::HotChocolate.Types.MemberKind.ObjectField)) + .ExtendWith(static (field, context) => + { + var configuration = field.Configuration; + var typeInspector = field.Context.TypeInspector; + var bindingResolver = field.Context.ParameterBindingResolver; + var naming = field.Context.Naming; + + configuration.Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))); + configuration.ResultType = typeof(string); + + configuration.SetSourceGeneratorFlags(); + configuration.SetBatchResolverFlags(); + + configuration.BatchResolver = context.Resolvers.GetGreeting(); + }, + (Resolvers: resolvers, ThisType: thisType)); + + Configure(descriptor); + } + + static partial void Configure(global::HotChocolate.Types.IObjectTypeDescriptor descriptor); + + private sealed class __Resolvers + { + public HotChocolate.Resolvers.BatchFieldDelegate GetGreeting() + => GetGreeting; + + private global::System.Threading.Tasks.ValueTask GetGreeting(global::System.Collections.Immutable.ImmutableArray contexts) + { + var args0 = new global::System.Collections.Generic.List(contexts.Length); + var args1 = contexts[0].Service(); + + for (var i = 0; i < contexts.Length; i++) + { + args0.Add(contexts[i].Parent()); + } + + var result = global::TestNamespace.UserExtensions.GetGreeting(args0, args1); + + if (result is global::System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + return default; + } + } + } +} + + +``` diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ParentAttributeAnalyzerTests.ParentAttribute_BatchResolver_ListOfParentType_NoError.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ParentAttributeAnalyzerTests.ParentAttribute_BatchResolver_ListOfParentType_NoError.md new file mode 100644 index 00000000000..6e2ec099b7a --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ParentAttributeAnalyzerTests.ParentAttribute_BatchResolver_ListOfParentType_NoError.md @@ -0,0 +1,120 @@ +# ParentAttribute_BatchResolver_ListOfParentType_NoError + +## HotChocolateTypeModule.735550c.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class TestsTypesRequestExecutorBuilderExtensions + { + public static IRequestExecutorBuilder AddTestsTypes(this IRequestExecutorBuilder builder) + { + builder.ConfigureDescriptorContext(ctx => ctx.TypeConfiguration.TryAdd( + "Tests::TestNamespace.ProductNode", + () => global::TestNamespace.ProductNode.Initialize)); + builder.AddType>(); + return builder; + } + } +} + +``` + +## ProductNode.WaAdMHmlGJHjtEI4nqY7WA.hc.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Internal; + +namespace TestNamespace +{ + public static partial class ProductNode + { + internal static void Initialize(global::HotChocolate.Types.IObjectTypeDescriptor descriptor) + { + var extension = descriptor.Extend(); + var configuration = extension.Configuration; + var thisType = typeof(global::TestNamespace.ProductNode); + var bindingResolver = extension.Context.ParameterBindingResolver; + var resolvers = new __Resolvers(); + + var naming = descriptor.Extend().Context.Naming; + + descriptor + .Field(naming.GetMemberName("DisplayName", global::HotChocolate.Types.MemberKind.ObjectField)) + .ExtendWith(static (field, context) => + { + var configuration = field.Configuration; + var typeInspector = field.Context.TypeInspector; + var bindingResolver = field.Context.ParameterBindingResolver; + var naming = field.Context.Naming; + + configuration.Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))); + configuration.ResultType = typeof(string); + + configuration.SetSourceGeneratorFlags(); + configuration.SetBatchResolverFlags(); + + configuration.BatchResolver = context.Resolvers.GetDisplayName(); + }, + (Resolvers: resolvers, ThisType: thisType)); + + Configure(descriptor); + } + + static partial void Configure(global::HotChocolate.Types.IObjectTypeDescriptor descriptor); + + private sealed class __Resolvers + { + public HotChocolate.Resolvers.BatchFieldDelegate GetDisplayName() + => GetDisplayName; + + private global::System.Threading.Tasks.ValueTask GetDisplayName(global::System.Collections.Immutable.ImmutableArray contexts) + { + var args0 = new global::System.Collections.Generic.List(contexts.Length); + + for (var i = 0; i < contexts.Length; i++) + { + args0.Add(contexts[i].Parent()); + } + + var result = global::TestNamespace.ProductNode.GetDisplayName(args0); + + if (result is global::System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + return default; + } + } + } +} + + +``` diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ParentAttributeAnalyzerTests.ParentAttribute_BatchResolver_ListOfWrongType_RaisesError.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ParentAttributeAnalyzerTests.ParentAttribute_BatchResolver_ListOfWrongType_RaisesError.md new file mode 100644 index 00000000000..d7cdd98eb13 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ParentAttributeAnalyzerTests.ParentAttribute_BatchResolver_ListOfWrongType_RaisesError.md @@ -0,0 +1,138 @@ +# ParentAttribute_BatchResolver_ListOfWrongType_RaisesError + +## HotChocolateTypeModule.735550c.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class TestsTypesRequestExecutorBuilderExtensions + { + public static IRequestExecutorBuilder AddTestsTypes(this IRequestExecutorBuilder builder) + { + builder.ConfigureDescriptorContext(ctx => ctx.TypeConfiguration.TryAdd( + "Tests::TestNamespace.ProductNode", + () => global::TestNamespace.ProductNode.Initialize)); + builder.AddType>(); + return builder; + } + } +} + +``` + +## ProductNode.WaAdMHmlGJHjtEI4nqY7WA.hc.g.cs + +```csharp +// + +#nullable enable +#pragma warning disable + +using System; +using System.Runtime.CompilerServices; +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Execution.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Internal; + +namespace TestNamespace +{ + public static partial class ProductNode + { + internal static void Initialize(global::HotChocolate.Types.IObjectTypeDescriptor descriptor) + { + var extension = descriptor.Extend(); + var configuration = extension.Configuration; + var thisType = typeof(global::TestNamespace.ProductNode); + var bindingResolver = extension.Context.ParameterBindingResolver; + var resolvers = new __Resolvers(); + + var naming = descriptor.Extend().Context.Naming; + + descriptor + .Field(naming.GetMemberName("DisplayName", global::HotChocolate.Types.MemberKind.ObjectField)) + .ExtendWith(static (field, context) => + { + var configuration = field.Configuration; + var typeInspector = field.Context.TypeInspector; + var bindingResolver = field.Context.ParameterBindingResolver; + var naming = field.Context.Naming; + + configuration.Type = global::HotChocolate.Types.Descriptors.TypeReference.Create( + typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output), + new global::HotChocolate.Language.NonNullTypeNode(new global::HotChocolate.Language.NamedTypeNode("string"))); + configuration.ResultType = typeof(string); + + configuration.SetSourceGeneratorFlags(); + configuration.SetBatchResolverFlags(); + + configuration.BatchResolver = context.Resolvers.GetDisplayName(); + }, + (Resolvers: resolvers, ThisType: thisType)); + + Configure(descriptor); + } + + static partial void Configure(global::HotChocolate.Types.IObjectTypeDescriptor descriptor); + + private sealed class __Resolvers + { + public HotChocolate.Resolvers.BatchFieldDelegate GetDisplayName() + => GetDisplayName; + + private global::System.Threading.Tasks.ValueTask GetDisplayName(global::System.Collections.Immutable.ImmutableArray contexts) + { + var args0 = new global::System.Collections.Generic.List(contexts.Length); + + for (var i = 0; i < contexts.Length; i++) + { + args0.Add(contexts[i].Parent()); + } + + var result = global::TestNamespace.ProductNode.GetDisplayName(args0); + + if (result is global::System.Collections.IList list) + { + for (var i = 0; i < contexts.Length; i++) + { + contexts[i].Result = i < list.Count ? list[i] : null; + } + } + return default; + } + } + } +} + + +``` + +## Analyzer Diagnostics + +```json +[ + { + "Id": "HC0097", + "Title": "Parent Attribute Type Mismatch", + "Severity": "Error", + "WarningLevel": 0, + "Location": ": (12,17)-(12,28)", + "MessageFormat": "The parameter type '{0}' must be '{1}' or a base type/interface that '{1}' implements", + "Message": "The parameter type 'List' must be 'Product' or a base type/interface that 'Product' implements", + "Category": "TypeSystem", + "CustomTags": [] + } +] +``` diff --git a/src/HotChocolate/Core/test/Types.Tests/Execution/BatchResolverTests.cs b/src/HotChocolate/Core/test/Types.Tests/Execution/BatchResolverTests.cs new file mode 100644 index 00000000000..2769a6d9e20 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Execution/BatchResolverTests.cs @@ -0,0 +1,1201 @@ +using HotChocolate.Resolvers; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution; + +public class BatchResolverTests +{ + [Fact] + public async Task BatchResolver_Should_Resolve_Nested_Field() + { + // arrange & act + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .Type() + .ResolveBatch(contexts => + { + var results = new ResolverResult[contexts.Count]; + + for (var i = 0; i < contexts.Count; i++) + { + var user = contexts[i].Parent(); + results[i] = ResolverResult.Ok($"Hello, {user.Name}!"); + } + + return new ValueTask>(results); + }); + }) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveBatchWith_Expression_Should_Resolve() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!)); + }) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveBatchWith_MemberInfo_Should_Resolve() + { + var method = typeof(UserExtensions).GetMethod(nameof(UserExtensions.GetGreeting))!; + + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith(method); + }) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveBatchWith_Expression_With_Service() + { + var result = + await new ServiceCollection() + .AddSingleton() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveBatchWith_Expression_With_Argument() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .ExecuteRequestAsync( + """ + { + users { + name + greeting(prefix: "Hi") + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hi, Alice!" + }, + { + "name": "Bob", + "greeting": "Hi, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hi, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveBatchWith_Expression_With_GlobalState() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .ExecuteRequestAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + users { + name + greeting + } + } + """) + .SetGlobalState("prefix", "Hey") + .Build()); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hey, Alice!" + }, + { + "name": "Bob", + "greeting": "Hey, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hey, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveBatchWith_Expression_With_CancellationToken() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddObjectType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default)); + }) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Annotated_Should_Resolve_Nested_Field() + { + // arrange & act + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddTypeExtension() + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Annotated_With_Argument() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddTypeExtension() + .ExecuteRequestAsync( + """ + { + users { + name + greeting(prefix: "Hi") + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hi, Alice!" + }, + { + "name": "Bob", + "greeting": "Hi, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hi, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Annotated_With_Service() + { + var result = + await new ServiceCollection() + .AddSingleton() + .AddGraphQL() + .AddQueryType() + .AddTypeExtension() + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Annotated_With_GlobalState() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddTypeExtension() + .ExecuteRequestAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + users { + name + greeting + } + } + """) + .SetGlobalState("prefix", "Hey") + .Build()); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hey, Alice!" + }, + { + "name": "Bob", + "greeting": "Hey, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hey, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Annotated_With_ScopedState() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(ctx => + { + ctx.ScopedContextData = ctx.ScopedContextData.SetItem("suffix", "!!!"); + return new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }; + }); + }) + .AddTypeExtension() + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!!!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!!!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!!!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Annotated_With_CancellationToken() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddTypeExtension() + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Interface_Inherited_By_ObjectType() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddInterfaceType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!)); + }) + .AddObjectType(d => d.Implements>()) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Interface_With_Argument() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddInterfaceType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .AddObjectType(d => d.Implements>()) + .ExecuteRequestAsync( + """ + { + users { + name + greeting(prefix: "Hi") + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hi, Alice!" + }, + { + "name": "Bob", + "greeting": "Hi, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hi, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Interface_With_Service() + { + var result = + await new ServiceCollection() + .AddSingleton() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddInterfaceType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .AddObjectType(d => d.Implements>()) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Interface_With_GlobalState() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddInterfaceType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .AddObjectType(d => d.Implements>()) + .ExecuteRequestAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + users { + name + greeting + } + } + """) + .SetGlobalState("prefix", "Hey") + .Build()); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hey, Alice!" + }, + { + "name": "Bob", + "greeting": "Hey, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hey, Charlie!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Interface_With_ScopedState() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(ctx => + { + ctx.ScopedContextData = ctx.ScopedContextData.SetItem("suffix", "!!!"); + return new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }; + }); + }) + .AddInterfaceType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default!)); + }) + .AddObjectType(d => d.Implements>()) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!!!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!!!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!!!" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Interface_With_CancellationToken() + { + var result = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List + { + new(1, "Alice"), + new(2, "Bob"), + new(3, "Charlie") + }); + }) + .AddInterfaceType(d => + { + d.Field(u => u.Name); + d.Field("greeting") + .ResolveBatchWith( + t => t.GetGreeting(default!, default)); + }) + .AddObjectType(d => d.Implements>()) + .ExecuteRequestAsync( + """ + { + users { + name + greeting + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "users": [ + { + "name": "Alice", + "greeting": "Hello, Alice!" + }, + { + "name": "Bob", + "greeting": "Hello, Bob!" + }, + { + "name": "Charlie", + "greeting": "Hello, Charlie!" + } + ] + } + } + """); + } + + public interface IUser + { + string Name { get; } + } + + public record User(int Id, string Name) : IUser; + + public class AnnotatedQuery + { + public List GetUsers() + => + [ + new User(1, "Alice"), + new User(2, "Bob"), + new User(3, "Charlie") + ]; + } + + public class GreetingService + { + public string Greet(string name) => $"Hello, {name}!"; + } + + [ExtendObjectType] + public class UserExtensions + { + [BatchResolver] + public List GetGreeting([Parent] List users) + { + var result = new List(); + + foreach (var user in users) + { + result.Add($"Hello, {user.Name}!"); + } + + return result; + } + } + + [ExtendObjectType] + public class UserExtensionsWithArgument + { + [BatchResolver] + public List GetGreeting( + [Parent] List users, + List prefix) + { + var result = new List(); + + for (var i = 0; i < users.Count; i++) + { + result.Add($"{prefix[i]}, {users[i].Name}!"); + } + + return result; + } + } + + [ExtendObjectType] + public class UserExtensionsWithService + { + [BatchResolver] + public List GetGreeting( + [Parent] List users, + [Service] GreetingService greetingService) + { + var result = new List(); + + foreach (var user in users) + { + result.Add(greetingService.Greet(user.Name)); + } + + return result; + } + } + + [ExtendObjectType] + public class UserExtensionsWithGlobalState + { + [BatchResolver] + public List GetGreeting( + [Parent] List users, + [GlobalState("prefix")] string prefix) + { + var result = new List(); + + foreach (var user in users) + { + result.Add($"{prefix}, {user.Name}!"); + } + + return result; + } + } + + [ExtendObjectType] + public class UserExtensionsWithScopedState + { + [BatchResolver] + public List GetGreeting( + [Parent] List users, + [ScopedState("suffix")] string suffix) + { + var result = new List(); + + foreach (var user in users) + { + result.Add($"Hello, {user.Name}{suffix}"); + } + + return result; + } + } + + [ExtendObjectType] + public class UserExtensionsWithCancellationToken + { + [BatchResolver] + public List GetGreeting( + [Parent] List users, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = new List(); + + foreach (var user in users) + { + result.Add($"Hello, {user.Name}!"); + } + + return result; + } + } +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Resolvers/ResolverCompilerTests.cs b/src/HotChocolate/Core/test/Types.Tests/Resolvers/ResolverCompilerTests.cs index e1ed155a5ba..101c3024df1 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Resolvers/ResolverCompilerTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Resolvers/ResolverCompilerTests.cs @@ -483,6 +483,7 @@ public async Task Compile_Arguments_FieldSelection() var selection = new Selection( id: 1, "abc", + SelectionPath.Root, schema.Types.GetType("Query").Fields["abc"], [new FieldSelectionNode(fieldSyntax, 1)], []); @@ -525,6 +526,7 @@ public async Task Compile_Arguments_Selection() var selection = new Selection( id: 1, "abc", + SelectionPath.Root, schema.Types.GetType("Query").Fields["abc"], [new FieldSelectionNode(fieldSyntax, 1)], []); @@ -625,6 +627,7 @@ public async Task Compile_Arguments_ObjectField() var selection = new Selection( id: 1, "a", + SelectionPath.Root, queryType.Fields.First(), [new FieldSelectionNode(fieldSyntax, 1)], []); @@ -668,6 +671,7 @@ public async Task Compile_Arguments_IOutputField() var selection = new Selection( id: 1, "a", + SelectionPath.Root, queryType.Fields.First(), [new FieldSelectionNode(fieldSyntax, 1)], []); diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ArgumentDescriptorTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ArgumentDescriptorTests.cs index 7ad560d1e7f..1686c0fb4af 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ArgumentDescriptorTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ArgumentDescriptorTests.cs @@ -12,7 +12,7 @@ public void Create_TypeIsNull_ArgumentNullException() { // arrange // act - void Action() => new ArgumentDescriptor(Context, "Type", null); + void Action() => new ArgumentDescriptor(Context, "Type", null!); // assert Assert.Throws(Action); diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Optimizers/QueryablePagingProjectionOptimizer.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Optimizers/QueryablePagingProjectionOptimizer.cs index 10422d51f6c..acd94f0c25c 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Optimizers/QueryablePagingProjectionOptimizer.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Optimizers/QueryablePagingProjectionOptimizer.cs @@ -1,3 +1,4 @@ +using HotChocolate.Execution; using HotChocolate.Execution.Processing; using HotChocolate.Language; using HotChocolate.Language.Visitors; @@ -65,6 +66,7 @@ private Selection CreateCombinedSelection( return new Selection( context.NewSelectionId(), CombinedEdgeField, + SelectionPath.Root, nodesField, [new FieldSelectionNode(combinedField, 0)], [], diff --git a/src/HotChocolate/Data/src/Data/Projections/Optimizers/IsProjectedProjectionOptimizer.cs b/src/HotChocolate/Data/src/Data/Projections/Optimizers/IsProjectedProjectionOptimizer.cs index 8a9aceb0583..34160f1f113 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Optimizers/IsProjectedProjectionOptimizer.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Optimizers/IsProjectedProjectionOptimizer.cs @@ -1,3 +1,4 @@ +using HotChocolate.Execution; using HotChocolate.Execution.Processing; using HotChocolate.Language; using HotChocolate.Types; @@ -60,6 +61,7 @@ public Selection RewriteSelection( var compiledSelection = new Selection( context.NewSelectionId(), alias, + SelectionPath.Root, field, [new FieldSelectionNode(fieldNode, 0)], [], diff --git a/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs b/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs index b65fd84ee1e..cb045de0e26 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs @@ -1,4 +1,5 @@ using System.Reflection; +using HotChocolate.Execution; using HotChocolate.Execution.Processing; using HotChocolate.Execution.Requirements; using HotChocolate.Language; @@ -89,6 +90,7 @@ private static void UpsertInternalSelection( new Selection( existingSelection.Id, responseName, + SelectionPath.Root, field, [new FieldSelectionNode(fieldNode, 0)], [], @@ -103,6 +105,7 @@ [new FieldSelectionNode(fieldNode, 0)], new Selection( context.NewSelectionId(), responseName, + SelectionPath.Root, field, [new FieldSelectionNode(fieldNode, 0)], [], diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs index 19dcf5bf324..50d348f5f09 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs @@ -101,6 +101,30 @@ public async Task Query_Brands_First_2_And_Products_First_2() MatchSnapshot(result, interceptor); } + [Fact] + public async Task Query_Brands_With_BatchResolver_ProductCount() + { + // arrange + using var interceptor = new TestQueryInterceptor(); + + // act + var result = await ExecuteAsync( + """ + { + brands(first: 5) { + nodes { + id + name + productCount + } + } + } + """); + + // assert + MatchSnapshot(result, interceptor); + } + [Fact] public async Task Query_Brands_First_2_And_Products_First_2_Name_Desc() { diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/BrandNode.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/BrandNode.cs index a7c6af3f923..12cceaf20e9 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/BrandNode.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Types/Brands/BrandNode.cs @@ -1,9 +1,11 @@ using GreenDonut.Data; +using HotChocolate.Data.Data; using HotChocolate.Data.Models; using HotChocolate.Data.Services; using HotChocolate.Execution; using HotChocolate.Types; using HotChocolate.Types.Pagination; +using Microsoft.EntityFrameworkCore; namespace HotChocolate.Data.Types.Brands; @@ -31,4 +33,21 @@ public static async Task> GetProductsAsync( var page = await productService.GetProductsByBrandAsync(brand.Id, pagingArgs, query, cancellationToken); return new PageConnection(page); } + + [BatchResolver] + public static async Task> GetProductCountAsync( + [Parent(requires: nameof(Brand.Id))] List brands, + [Service] CatalogContext context, + CancellationToken cancellationToken) + { + var brandIds = brands.Select(b => b.Id).ToList(); + + var counts = await context.Products + .Where(p => brandIds.Contains(p.BrandId)) + .GroupBy(p => p.BrandId) + .Select(g => new { BrandId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.BrandId, g => g.Count, cancellationToken); + + return brands.Select(b => counts.GetValueOrDefault(b.Id, 0)).ToList(); + } } diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql index 575dc928716..dcfd7f1f998 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql @@ -18,6 +18,7 @@ type BillingStatementTransaction implements StatementTransaction { type Brand implements Node { products("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String where: ProductFilterInput @cost(weight: "10") order: [ProductSortInput!] @cost(weight: "10")): BrandProductsConnection! @listSize(assumedSize: 50, slicingArguments: ["first", "last"], slicingArgumentDefaultValue: 10, sizedFields: ["edges", "nodes"], requireOneSlicingArgument: false) @cost(weight: "10") + productCount: Int! @cost(weight: "10") id: ID! name: String! } diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET10_0.md b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET10_0.md new file mode 100644 index 00000000000..a19af0f51e4 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET10_0.md @@ -0,0 +1,49 @@ +# Query_Brands_With_BatchResolver_ProductCount + +## Result + +```json +{ + "data": { + "brands": { + "nodes": [ + { + "id": "QnJhbmQ6MTE=", + "name": "Zephyr", + "productCount": 6 + }, + { + "id": "QnJhbmQ6MTM=", + "name": "XE", + "productCount": 3 + }, + { + "id": "QnJhbmQ6Mw==", + "name": "WildRunner", + "productCount": 11 + }, + { + "id": "QnJhbmQ6Nw==", + "name": "Solstix", + "productCount": 9 + }, + { + "id": "QnJhbmQ6Ng==", + "name": "Raptor Elite", + "productCount": 11 + } + ] + } + } +} +``` + +## Query 1 + +```sql +-- @p='6' +SELECT b."Id", b."Name" +FROM "Brands" AS b +ORDER BY b."Name" DESC, b."Id" +LIMIT @p +``` diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET8_0.md b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET8_0.md new file mode 100644 index 00000000000..53d792e90cb --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET8_0.md @@ -0,0 +1,49 @@ +# Query_Brands_With_BatchResolver_ProductCount + +## Result + +```json +{ + "data": { + "brands": { + "nodes": [ + { + "id": "QnJhbmQ6MTE=", + "name": "Zephyr", + "productCount": 6 + }, + { + "id": "QnJhbmQ6MTM=", + "name": "XE", + "productCount": 3 + }, + { + "id": "QnJhbmQ6Mw==", + "name": "WildRunner", + "productCount": 11 + }, + { + "id": "QnJhbmQ6Nw==", + "name": "Solstix", + "productCount": 9 + }, + { + "id": "QnJhbmQ6Ng==", + "name": "Raptor Elite", + "productCount": 11 + } + ] + } + } +} +``` + +## Query 1 + +```sql +-- @__p_0='6' +SELECT b."Id", b."Name" +FROM "Brands" AS b +ORDER BY b."Name" DESC, b."Id" +LIMIT @__p_0 +``` diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET9_0.md b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET9_0.md new file mode 100644 index 00000000000..53d792e90cb --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.Query_Brands_With_BatchResolver_ProductCount_NET9_0.md @@ -0,0 +1,49 @@ +# Query_Brands_With_BatchResolver_ProductCount + +## Result + +```json +{ + "data": { + "brands": { + "nodes": [ + { + "id": "QnJhbmQ6MTE=", + "name": "Zephyr", + "productCount": 6 + }, + { + "id": "QnJhbmQ6MTM=", + "name": "XE", + "productCount": 3 + }, + { + "id": "QnJhbmQ6Mw==", + "name": "WildRunner", + "productCount": 11 + }, + { + "id": "QnJhbmQ6Nw==", + "name": "Solstix", + "productCount": 9 + }, + { + "id": "QnJhbmQ6Ng==", + "name": "Raptor Elite", + "productCount": 11 + } + ] + } + } +} +``` + +## Query 1 + +```sql +-- @__p_0='6' +SELECT b."Id", b."Name" +FROM "Brands" AS b +ORDER BY b."Name" DESC, b."Id" +LIMIT @__p_0 +``` diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs index 776f8e32be4..29a6d3862ba 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Collections.Immutable; using System.Runtime.InteropServices; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Clients; namespace HotChocolate.Fusion.Execution.Nodes; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs index ddfc2aeafc9..aae561d3d1d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Reactive.Disposables; using System.Runtime.InteropServices; +using HotChocolate.Execution; using HotChocolate.Fusion.Diagnostics; using HotChocolate.Fusion.Execution.Clients; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationRequirement.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationRequirement.cs index f6d9983011b..e8d2653c9b3 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationRequirement.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationRequirement.cs @@ -1,3 +1,4 @@ +using HotChocolate.Execution; using HotChocolate.Fusion.Language; using HotChocolate.Language; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs index c7a2fd2e662..64ae99b087f 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Text.Json; +using HotChocolate.Execution; using HotChocolate.Fusion.Language; using HotChocolate.Language; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs index 2b0fea9f836..fe6ed344502 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -266,8 +266,10 @@ private static Path ToResultPath(SelectionPath selectionSet) { var resultPath = Path.Root; - foreach (var segment in selectionSet.Segments) + for (var i = 0; i < selectionSet.Length; i++) { + var segment = selectionSet[i]; + if (segment.Kind is SelectionPathSegmentKind.Root or SelectionPathSegmentKind.Field) { resultPath = resultPath.Append(segment.Name); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index c2cb17bc526..931ecfa84c2 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -503,9 +503,9 @@ public ImmutableArray CreateVariableValueSets( next.Clear(); current.Add(_result.Data); - for (var i = 0; i < selectionSet.Segments.Length; i++) + for (var i = 0; i < selectionSet.Length; i++) { - var segment = selectionSet.Segments[i]; + var segment = selectionSet[i]; if (segment.Kind is SelectionPathSegmentKind.InlineFragment) { @@ -1262,14 +1262,14 @@ private static SourceResultElement GetDataElement(SelectionPath sourcePath, Sour var current = data; - for (var i = 0; i < sourcePath.Segments.Length; i++) + for (var i = 0; i < sourcePath.Length; i++) { if (current.ValueKind != JsonValueKind.Object) { return default; } - var segment = sourcePath.Segments[i]; + var segment = sourcePath[i]; switch (segment.Kind) { @@ -1310,9 +1310,9 @@ private static SourceResultElement GetDataElement(SelectionPath sourcePath, Sour var current = errors; - for (var i = 0; i < sourcePath.Segments.Length; i++) + for (var i = 0; i < sourcePath.Length; i++) { - var segment = sourcePath.Segments[i]; + var segment = sourcePath[i]; if (!current.TryGetValue(segment.Name, out current)) { diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/FieldSelection.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/FieldSelection.cs index 793166164cc..6593f7615c7 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/FieldSelection.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/FieldSelection.cs @@ -1,4 +1,4 @@ -using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Execution; using HotChocolate.Fusion.Types; using HotChocolate.Language; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs index 68b03e58e2c..ab979bb8057 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs @@ -1,4 +1,4 @@ -using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Execution; using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; using HotChocolate.Types; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs index 79d3898300c..d9765f1c811 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; using HotChocolate.Language.Visitors; @@ -820,9 +821,9 @@ private static string[] GetResponseNamesFromPath( return current; } - for (var i = 0; i < path.Segments.Length; i++) + for (var i = 0; i < path.Length; i++) { - var segment = path.Segments[i]; + var segment = path[i]; switch (segment.Kind) { diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.PlanBase.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.PlanBase.cs index 197d8fc8a71..6a48ca12151 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.PlanBase.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.PlanBase.cs @@ -1,5 +1,5 @@ using System.Collections.Immutable; -using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Execution; using HotChocolate.Fusion.Planning.Partitioners; using HotChocolate.Language; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs index e3d0663ad9d..ab4eb45402d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Diagnostics; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Planning.Partitioners; using HotChocolate.Fusion.Rewriters; @@ -2337,7 +2338,8 @@ public static int NextId(this ImmutableList steps) } var selectionSetIndexBuilder = planNodeTemplate.SelectionSetIndex.ToBuilder(); - var segments = selectionSet.Path.Segments; + var path = selectionSet.Path; + var segmentLength = path.Length; var finalSelectionSet = selectionSet.Node; var fieldsMovedUp = 0; var viewerFallbackToQueryRoot = false; @@ -2388,7 +2390,7 @@ public static int NextId(this ImmutableList steps) if (pathItem is not InlineFragmentPathItem { TypeCondition: null }) { - segments = segments.RemoveAt(segments.Length - 1); + segmentLength--; } if (pathItems.TryPeek(out var parentPathItem)) @@ -2419,7 +2421,7 @@ public static int NextId(this ImmutableList steps) selectionSetIndexBuilder.GetId(finalSelectionSet), finalSelectionSet, parentType, - SelectionPath.From(segments)); + path.Slice(segmentLength)); var newWorkItem = workItem with { SelectionSet = newSelectionSet, Lookup = lookup }; @@ -2486,8 +2488,10 @@ private static bool HasViewerQueryRoot( var items = new Stack(); - foreach (var segment in path.Segments) + for (var i = 0; i < path.Length; i++) { + var segment = path[i]; + switch (segment.Kind) { case SelectionPathSegmentKind.Root or SelectionPathSegmentKind.Field: diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs index e5ee6ca4359..00610ad8664 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types; using HotChocolate.Language; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/SelectionSet.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/SelectionSet.cs index 41685cdf6c6..e525e700126 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/SelectionSet.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/SelectionSet.cs @@ -1,5 +1,5 @@ using System.Diagnostics; -using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Execution; using HotChocolate.Language; using HotChocolate.Types; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs index 9bc6ff68c14..0f68cb3f3e4 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Diagnostics.Tests/__snapshots__/QueryInstrumentationTests.Source_Schema_Transport_Error.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.Diagnostics.Tests/__snapshots__/QueryInstrumentationTests.Source_Schema_Transport_Error.snap index 88d2aba887c..d33e8a023b2 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Diagnostics.Tests/__snapshots__/QueryInstrumentationTests.Source_Schema_Transport_Error.snap +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Diagnostics.Tests/__snapshots__/QueryInstrumentationTests.Source_Schema_Transport_Error.snap @@ -112,7 +112,7 @@ }, { "Key": "exception.stacktrace", - "Value": "System.Net.Http.HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).\n at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()\n at HotChocolate.Fusion.Transport.Http.GraphQLHttpResponse.ReadAsResultAsync(CancellationToken cancellationToken) in GraphQLHttpResponse.cs:line 150\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+MoveNext() in SourceSchemaHttpClient.cs:line 577\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+System.Threading.Tasks.Sources.IValueTaskSource.GetResult()\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs:line 158\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs:line 158" + "Value": "System.Net.Http.HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).\n at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()\n at HotChocolate.Fusion.Transport.Http.GraphQLHttpResponse.ReadAsResultAsync(CancellationToken cancellationToken) in GraphQLHttpResponse.cs:line 150\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+MoveNext() in SourceSchemaHttpClient.cs:line 577\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+System.Threading.Tasks.Sources.IValueTaskSource.GetResult()\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs:line 159\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs:line 159" }, { "Key": "exception.message", diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs index 0897258a4be..d38dcedc4b7 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs @@ -3,6 +3,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Nodes.Serialization; using HotChocolate.Fusion.Rewriters; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs index a8088090b66..83868a80a6c 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; using Microsoft.Extensions.ObjectPool; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetByTypePartitionerTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetByTypePartitionerTests.cs index 66362cd6cf4..20330ede157 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetByTypePartitionerTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetByTypePartitionerTests.cs @@ -1,5 +1,5 @@ using System.Text; -using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Execution; using HotChocolate.Fusion.Planning.Partitioners; using HotChocolate.Fusion.Rewriters; using HotChocolate.Fusion.Types; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetPartitionerTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetPartitionerTests.cs index 2288fb71a7c..76f44e22917 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetPartitionerTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/Planning/SelectionSetPartitionerTests.cs @@ -1,4 +1,4 @@ -using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Execution; using HotChocolate.Fusion.Planning.Partitioners; using HotChocolate.Fusion.Rewriters; using HotChocolate.Fusion.Types;