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
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