diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs
index 468a373857a..15929e41820 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs
@@ -7,6 +7,12 @@ namespace HotChocolate.Fusion.Execution.Nodes;
///
public sealed class IncrementalPlan : IOperationPlan
{
+ ///
+ /// The of a plan anchored at the operation root of an
+ /// enclosing plan that has no execution node.
+ ///
+ public const int NoParentNodeId = 0;
+
private readonly ExecutionNode?[] _nodesById;
///
@@ -88,7 +94,8 @@ public IncrementalPlan(
/// Gets the of the node that produces the
/// result object where this incremental plan is anchored. The identifier is
/// scoped to the root for top-level plans, or
- /// to the enclosing for nested plans.
+ /// to the enclosing for nested plans. The value is
+ /// when the enclosing plan has no execution node.
///
public int ParentNodeId { get; internal set; }
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs
index 5e00f92e8b3..0c04d2b59b7 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs
@@ -28,7 +28,7 @@ public sealed class JsonOperationPlanFormatter(JsonWriterOptions? options = null
///
/// The version of the operation plan JSON format written by this formatter.
///
- private const string FormatVersion = "1.0.0";
+ private const string FormatVersion = "1.1.0";
private readonly JsonWriterOptions _writerOptions = options ?? new JsonWriterOptions
{
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs
index 5ee093cb665..9c3bf678f99 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using HotChocolate.Execution;
+using HotChocolate.Fusion.Planning;
using HotChocolate.Fusion.Properties;
using HotChocolate.Language;
@@ -22,6 +23,16 @@ public static InvalidOperationException IncrementalPlanParentNotFound(SelectionP
FusionExecutionResources.OperationPlan_IncrementalPlanParentNotFound,
path));
+ public static InvalidOperationException DeferredPlanNotFound(SelectionPath path)
+ => new($"No plan was found for the @defer fragment at path '{path}'.");
+
+ public static OperationPlannerGuardrailException PlanningGuardrailExceeded(
+ string operationId,
+ OperationPlannerGuardrailReason reason,
+ long limit,
+ long observed)
+ => new(operationId, reason, limit, observed);
+
public static InvalidOperationException MissingBatchResult(int operationId)
=> new(string.Format(
FusionExecutionResources.OperationBatchExecutionNode_MissingBatchResult,
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
index 795581cbcff..93a843a9934 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
@@ -42,7 +42,18 @@ private OperationPlan BuildExecutionPlan(
var nodes = ImmutableArray.Create(introspectionNode);
- return OperationPlan.Create(operation, nodes, nodes, [], [], searchSpace, expandedNodes);
+ var introspectionPlan = OperationPlan.Create(
+ operation,
+ nodes,
+ nodes,
+ deliveryGroups,
+ incrementalPlans,
+ searchSpace,
+ expandedNodes);
+
+ AssignIncrementalPlanAnchors(introspectionPlan, nodes, incrementalPlans);
+
+ return introspectionPlan;
}
var ctx = new ExecutionPlanBuildContext(nextNodeId);
@@ -90,28 +101,53 @@ private OperationPlan BuildExecutionPlan(
searchSpace,
expandedNodes);
- // Assign parent node ids and stable ids after the root plan and
- // incremental plan nodes have been built. Nested incremental plans are
- // associated with the plan that owns their parent delivery group.
- if (!incrementalPlans.IsDefaultOrEmpty)
+ AssignIncrementalPlanAnchors(operationPlan, allNodes, incrementalPlans);
+
+ return operationPlan;
+ }
+
+ ///
+ /// Assigns the stable id and the parent node id of every incremental plan after the
+ /// root plan has been created. Nested incremental plans are associated with the plan
+ /// that owns their parent delivery group.
+ ///
+ private static void AssignIncrementalPlanAnchors(
+ OperationPlan operationPlan,
+ ImmutableArray allNodes,
+ ImmutableArray incrementalPlans)
+ {
+ if (incrementalPlans.IsDefaultOrEmpty)
{
- // Plan-time id: the parent id is known here because the root plan
- // was just created above, and OperationPlan.Create's content hash
- // does not include incremental plan ids.
- for (var i = 0; i < incrementalPlans.Length; i++)
- {
- var incrementalPlan = incrementalPlans[i];
- incrementalPlan.Id = $"{operationPlan.Id}#{i}";
+ return;
+ }
- var path = ResolveIncrementalPlanPath(incrementalPlan);
- var parent = ResolveIncrementalPlanParent(incrementalPlan, incrementalPlans);
- var owningNodes = parent is null ? allNodes : parent.AllNodes;
- incrementalPlan.ParentNodeId = ResolveDeferParentNodeId(owningNodes, path)
- ?? throw ThrowHelper.IncrementalPlanParentNotFound(path);
+ // Plan-time id: the parent id is known here because the root plan
+ // was just created, and OperationPlan.Create's content hash
+ // does not include incremental plan ids.
+ for (var i = 0; i < incrementalPlans.Length; i++)
+ {
+ var incrementalPlan = incrementalPlans[i];
+ incrementalPlan.Id = $"{operationPlan.Id}#{i}";
+
+ var path = ResolveIncrementalPlanPath(incrementalPlan);
+ var parent = ResolveIncrementalPlanParent(incrementalPlan, incrementalPlans);
+ var owningNodes = parent is null ? allNodes : parent.AllNodes;
+
+ if (ResolveDeferParentNodeId(owningNodes, path) is { } parentNodeId)
+ {
+ incrementalPlan.ParentNodeId = parentNodeId;
+ }
+ else if (path.IsRoot && owningNodes.IsDefaultOrEmpty)
+ {
+ // The owning plan has no execution node, so the plan is anchored
+ // directly at the operation root and no node id applies.
+ incrementalPlan.ParentNodeId = IncrementalPlan.NoParentNodeId;
+ }
+ else
+ {
+ throw ThrowHelper.IncrementalPlanParentNotFound(path);
}
}
-
- return operationPlan;
}
///
@@ -188,6 +224,7 @@ private static SelectionPath ResolveIncrementalPlanPath(IncrementalPlan incremen
var bestDepth = -1;
int? fallbackMatch = null;
var fallbackDepth = -1;
+ int? introspectionMatch = null;
for (var i = 0; i < owningNodes.Length; i++)
{
@@ -196,6 +233,12 @@ private static SelectionPath ResolveIncrementalPlanPath(IncrementalPlan incremen
switch (owningNodes[i])
{
+ case IntrospectionExecutionNode when deferPath.IsRoot:
+ // Introspection results live in the root result object, so a root
+ // defer can anchor there when no fetch node produces it.
+ introspectionMatch ??= owningNodes[i].Id;
+ continue;
+
case OperationExecutionNode op:
target = op.Target;
resultSelectionSet = op.ResultSelectionSet;
@@ -241,7 +284,7 @@ private static SelectionPath ResolveIncrementalPlanPath(IncrementalPlan incremen
}
}
- return match ?? fallbackMatch;
+ return match ?? fallbackMatch ?? introspectionMatch;
}
///
@@ -280,168 +323,215 @@ private static bool ResolvesDeferAnchorField(
return true;
}
- private static ImmutableList TransformPlanSteps(
- ImmutableList planSteps,
- OperationDefinitionNode originalOperation)
+ ///
+ /// Counts the operation steps that remain after , using
+ /// and .
+ ///
+ private static int CountSurvivingOperationSteps(ImmutableList planSteps)
{
- var updatedPlanSteps = planSteps;
- var forwardVariableContext = new ForwardVariableRewriter.Context();
-
- foreach (var variableDef in originalOperation.VariableDefinitions)
- {
- forwardVariableContext.Variables[variableDef.Variable.Name.Value] = variableDef;
- }
+ var remaining = ImmutableList.CreateBuilder();
foreach (var step in planSteps)
{
- if (step is not OperationPlanStep operationPlanStep)
+ if (step is OperationPlanStep operationPlanStep)
{
- continue;
+ if (TryTransformOperationStep(operationPlanStep, out var transformed))
+ {
+ remaining.Add(transformed);
+ }
}
-
- // Requirement rewriting can leave behind empty child selection sets.
- // We remove them here so later stages do not treat them as real selections.
- operationPlanStep = RemoveEmptySelectionSets(operationPlanStep);
-
- if (!ReferenceEquals(step, operationPlanStep))
+ else
{
- updatedPlanSteps = updatedPlanSteps.Replace(step, operationPlanStep);
+ remaining.Add(step);
}
+ }
- // Discard steps that have no meaningful selections left.
- if (IsEmptyOperation(operationPlanStep))
- {
- updatedPlanSteps = updatedPlanSteps.Remove(operationPlanStep);
- continue;
- }
+ var count = 0;
- // When a @skip or @include directive gates every selection of the
- // operation, we promote it to a node-level condition. This lets the
- // executor skip the entire network call when the condition is not
- // met, rather than sending a request that returns nothing.
- // Directives that gate only some selections stay in the document and
- // are evaluated by the source schema.
- if (TryExtractCommonConditionsAndRewrite(operationPlanStep, out var updated))
+ foreach (var step in RemoveKeyOnlyLookupSteps(remaining.ToImmutable()))
+ {
+ if (step is OperationPlanStep)
{
- updatedPlanSteps = updatedPlanSteps.Replace(operationPlanStep, updated);
- operationPlanStep = updated;
+ count++;
}
-
- // Strip @defer directives from subgraph operations. The gateway
- // manages deferral itself and subgraphs should not see @defer.
- operationPlanStep = StripDeferDirectivesFromStep(operationPlanStep);
-
- // Attach variable definitions so the operation is syntactically valid
- // when sent to the downstream service.
- updatedPlanSteps = updatedPlanSteps.Replace(
- operationPlanStep,
- AddVariableDefinitions(operationPlanStep, forwardVariableContext));
}
- return updatedPlanSteps;
+ return count;
+ }
- static bool IsEmptyOperation(OperationPlanStep step)
+ private static bool IsEmptyOperation(OperationPlanStep step)
+ {
+ if (step.Definition.SelectionSet.Selections.Count == 0)
{
- if (step.Definition.SelectionSet.Selections.Count == 0)
- {
- return true;
- }
+ return true;
+ }
- return step.Definition.SelectionSet.Selections is
- [
+ return step.Definition.SelectionSet.Selections is
+ [
#pragma warning disable format
- FieldNode
- {
- Alias: null,
- Name.Value: IntrospectionFieldNames.TypeName,
- Directives: [{ Name.Value: "fusion__empty" }]
- }
+ FieldNode
+ {
+ Alias: null,
+ Name.Value: IntrospectionFieldNames.TypeName,
+ Directives: [{ Name.Value: "fusion__empty" }]
+ }
#pragma warning restore format
- ];
- }
+ ];
+ }
+
+ private static OperationPlanStep RemoveEmptySelectionSets(OperationPlanStep step)
+ {
+ var updatedDefinition = RemoveEmptySelections(step.Definition);
+ return ReferenceEquals(updatedDefinition, step.Definition)
+ ? step
+ : step with { Definition = updatedDefinition };
+ }
- static OperationPlanStep RemoveEmptySelectionSets(OperationPlanStep step)
+ ///
+ /// Applies the step-level transforms shared by and
+ /// . Returns false when the step has no
+ /// meaningful selections left and must be discarded.
+ ///
+ private static bool TryTransformOperationStep(
+ OperationPlanStep step,
+ out OperationPlanStep transformed)
+ {
+ // Requirement rewriting can leave behind empty child selection sets.
+ // We remove them here so later stages do not treat them as real selections.
+ step = RemoveEmptySelectionSets(step);
+
+ // Discard steps that have no meaningful selections left.
+ if (IsEmptyOperation(step))
{
- var updatedDefinition = RemoveEmptySelections(step.Definition);
- return ReferenceEquals(updatedDefinition, step.Definition)
- ? step
- : step with { Definition = updatedDefinition };
+ transformed = step;
+ return false;
}
- static OperationPlanStep StripDeferDirectivesFromStep(OperationPlanStep step)
+ // When a @skip or @include directive gates every selection of the
+ // operation, we promote it to a node-level condition. This lets the
+ // executor skip the entire network call when the condition is not
+ // met, rather than sending a request that returns nothing.
+ // Directives that gate only some selections stay in the document and
+ // are evaluated by the source schema.
+ if (TryExtractCommonConditionsAndRewrite(step, out var updated))
{
- var updated = StripDeferFromSelectionSet(step.Definition.SelectionSet);
+ step = updated;
+ }
- if (ReferenceEquals(updated, step.Definition.SelectionSet))
- {
- return step;
- }
+ // Strip @defer directives from subgraph operations. The gateway
+ // manages deferral itself and subgraphs should not see @defer.
+ transformed = StripDeferDirectivesFromStep(step);
+ return true;
+ }
+
+ private static OperationPlanStep StripDeferDirectivesFromStep(OperationPlanStep step)
+ {
+ var updated = StripDeferFromSelectionSet(step.Definition.SelectionSet);
- return step with { Definition = step.Definition.WithSelectionSet(updated) };
+ if (ReferenceEquals(updated, step.Definition.SelectionSet))
+ {
+ return step;
}
- static SelectionSetNode StripDeferFromSelectionSet(SelectionSetNode selectionSet)
+ return step with { Definition = step.Definition.WithSelectionSet(updated) };
+ }
+
+ private static SelectionSetNode StripDeferFromSelectionSet(SelectionSetNode selectionSet)
+ {
+ List? rewritten = null;
+
+ for (var i = 0; i < selectionSet.Selections.Count; i++)
{
- List? rewritten = null;
+ var selection = selectionSet.Selections[i];
- for (var i = 0; i < selectionSet.Selections.Count; i++)
+ if (selection is InlineFragmentNode inlineFragment)
{
- var selection = selectionSet.Selections[i];
+ var strippedDirectives = StripDeferDirective(inlineFragment.Directives);
+ var strippedInner = StripDeferFromSelectionSet(inlineFragment.SelectionSet);
- if (selection is InlineFragmentNode inlineFragment)
+ if (!ReferenceEquals(strippedDirectives, inlineFragment.Directives)
+ || !ReferenceEquals(strippedInner, inlineFragment.SelectionSet))
{
- var strippedDirectives = StripDeferDirective(inlineFragment.Directives);
- var strippedInner = StripDeferFromSelectionSet(inlineFragment.SelectionSet);
-
- if (!ReferenceEquals(strippedDirectives, inlineFragment.Directives)
- || !ReferenceEquals(strippedInner, inlineFragment.SelectionSet))
- {
- rewritten ??= [.. selectionSet.Selections];
- rewritten[i] = inlineFragment
- .WithDirectives(strippedDirectives)
- .WithSelectionSet(strippedInner);
- }
+ rewritten ??= [.. selectionSet.Selections];
+ rewritten[i] = inlineFragment
+ .WithDirectives(strippedDirectives)
+ .WithSelectionSet(strippedInner);
}
- else if (selection is FieldNode { SelectionSet: not null } field)
+ }
+ else if (selection is FieldNode { SelectionSet: not null } field)
+ {
+ var strippedInner = StripDeferFromSelectionSet(field.SelectionSet);
+
+ if (!ReferenceEquals(strippedInner, field.SelectionSet))
{
- var strippedInner = StripDeferFromSelectionSet(field.SelectionSet);
+ rewritten ??= [.. selectionSet.Selections];
+ rewritten[i] = field.WithSelectionSet(strippedInner);
+ }
+ }
+ }
+
+ return rewritten is null ? selectionSet : new SelectionSetNode(rewritten);
+ }
- if (!ReferenceEquals(strippedInner, field.SelectionSet))
+ private static IReadOnlyList StripDeferDirective(IReadOnlyList directives)
+ {
+ for (var i = 0; i < directives.Count; i++)
+ {
+ if (directives[i].Name.Value.Equals(
+ DirectiveNames.Defer.Name,
+ StringComparison.Ordinal))
+ {
+ var result = new List(directives.Count - 1);
+
+ for (var j = 0; j < directives.Count; j++)
+ {
+ if (j != i)
{
- rewritten ??= [.. selectionSet.Selections];
- rewritten[i] = field.WithSelectionSet(strippedInner);
+ result.Add(directives[j]);
}
}
+
+ return result;
}
+ }
+
+ return directives;
+ }
+
+ private static ImmutableList TransformPlanSteps(
+ ImmutableList planSteps,
+ OperationDefinitionNode originalOperation)
+ {
+ var updatedPlanSteps = planSteps;
+ var forwardVariableContext = new ForwardVariableRewriter.Context();
- return rewritten is null ? selectionSet : new SelectionSetNode(rewritten);
+ foreach (var variableDef in originalOperation.VariableDefinitions)
+ {
+ forwardVariableContext.Variables[variableDef.Variable.Name.Value] = variableDef;
}
- static IReadOnlyList StripDeferDirective(IReadOnlyList directives)
+ foreach (var step in planSteps)
{
- for (var i = 0; i < directives.Count; i++)
+ if (step is not OperationPlanStep operationPlanStep)
{
- if (directives[i].Name.Value.Equals(
- DirectiveNames.Defer.Name,
- StringComparison.Ordinal))
- {
- var result = new List(directives.Count - 1);
-
- for (var j = 0; j < directives.Count; j++)
- {
- if (j != i)
- {
- result.Add(directives[j]);
- }
- }
+ continue;
+ }
- return result;
- }
+ if (!TryTransformOperationStep(operationPlanStep, out var transformed))
+ {
+ updatedPlanSteps = updatedPlanSteps.Remove(step);
+ continue;
}
- return directives;
+ // Attach variable definitions so the operation is syntactically valid
+ // when sent to the downstream service.
+ updatedPlanSteps = updatedPlanSteps.Replace(
+ step,
+ AddVariableDefinitions(transformed, forwardVariableContext));
}
+ return RemoveKeyOnlyLookupSteps(updatedPlanSteps);
+
static OperationPlanStep AddVariableDefinitions(
OperationPlanStep step,
ForwardVariableRewriter.Context forwardVariableContext)
@@ -472,6 +562,121 @@ static OperationPlanStep AddVariableDefinitions(
}
}
+ ///
+ /// Removes lookup steps that select nothing but the entry key a step they depend on already
+ /// selects, and makes their dependents depend on the steps the removed step depended on.
+ ///
+ private static ImmutableList RemoveKeyOnlyLookupSteps(ImmutableList planSteps)
+ {
+ var removed = true;
+
+ while (removed)
+ {
+ removed = false;
+
+ for (var i = 0; i < planSteps.Count; i++)
+ {
+ if (planSteps[i] is not OperationPlanStep { Lookup: not null } candidate
+ || !IsKeyOnlyLookup(candidate)
+ || IsReferencedByNodeStep(planSteps, candidate.Id)
+ || !TryGetKeyProducers(planSteps, candidate, out var producerIndexes))
+ {
+ continue;
+ }
+
+ foreach (var producerIndex in producerIndexes)
+ {
+ var producer = (OperationPlanStep)planSteps[producerIndex];
+
+ planSteps = planSteps.SetItem(
+ producerIndex,
+ producer with
+ {
+ Dependents = producer.Dependents.Remove(candidate.Id).Union(candidate.Dependents)
+ });
+ }
+
+ planSteps = planSteps.RemoveAt(i);
+ removed = true;
+ break;
+ }
+ }
+
+ return planSteps;
+
+ static bool IsKeyOnlyLookup(OperationPlanStep step)
+ {
+ return step.Definition.SelectionSet.Selections is
+ [FieldNode { Alias: null, SelectionSet: { } entrySelectionSet }]
+ && SyntaxComparer.BySyntax.Equals(entrySelectionSet, step.Lookup!.Requirements);
+ }
+
+ static bool IsReferencedByNodeStep(ImmutableList planSteps, int stepId)
+ {
+ foreach (var step in planSteps)
+ {
+ if (step is not NodeFieldPlanStep nodeStep)
+ {
+ continue;
+ }
+
+ if (nodeStep.FallbackQuery.Id == stepId)
+ {
+ return true;
+ }
+
+ foreach (var branch in nodeStep.Branches.Values)
+ {
+ if (branch.Id == stepId)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ static bool TryGetKeyProducers(
+ ImmutableList planSteps,
+ OperationPlanStep candidate,
+ out List producerIndexes)
+ {
+ producerIndexes = [];
+ var providesKey = false;
+
+ for (var i = 0; i < planSteps.Count; i++)
+ {
+ if (planSteps[i] is not OperationPlanStep producer
+ || !producer.Dependents.Contains(candidate.Id))
+ {
+ continue;
+ }
+
+ producerIndexes.Add(i);
+
+ if (producer.Target.IsParentOfOrSame(candidate.Target)
+ && ContainsSelectionsAtPath(
+ GetEntrySelectionSet(producer),
+ candidate.Target.RelativeTo(producer.Target),
+ candidate.Lookup!.Requirements))
+ {
+ providesKey = true;
+ }
+ }
+
+ return providesKey;
+ }
+
+ static SelectionSetNode GetEntrySelectionSet(OperationPlanStep step)
+ {
+ return step.Lookup is not null
+ && step.Definition.SelectionSet.Selections is [FieldNode { SelectionSet: { } entrySelectionSet }]
+ ? entrySelectionSet
+ : step.Definition.SelectionSet;
+ }
+ }
+
private static void IndexDependencies(
ImmutableList planSteps,
ExecutionPlanBuildContext ctx)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs
index 19e2cedd455..0413096e320 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs
@@ -6,6 +6,7 @@
using HotChocolate.Fusion.Types.Rewriters;
using HotChocolate.Language;
using HotChocolate.Types;
+using ThrowHelper = HotChocolate.Fusion.Execution.ThrowHelper;
namespace HotChocolate.Fusion.Planning;
@@ -19,7 +20,7 @@ private ImmutableArray RouteIncrementalPlans(
string id,
DeferSplitResult splitResult,
PlanContextGraph contextGraph,
- bool emitPlannerEvents,
+ PlanningBudget budget,
CancellationToken cancellationToken)
{
if (splitResult.IncrementalPlanDescriptors.IsEmpty)
@@ -53,7 +54,7 @@ private ImmutableArray RouteIncrementalPlans(
id,
descriptor,
i,
- emitPlannerEvents,
+ budget,
cancellationToken);
var rewrittenIncrementalPlan = ApplyDeferRequirementsToParent(
@@ -145,7 +146,8 @@ private ImmutableArray BuildIncrementalPlans(
var (rootNodes, allNodes) = BuildDeferredExecutionNodes(
registeredInternalOp,
finalSteps,
- finalSteps.NextId());
+ finalSteps.NextId(),
+ cancellationToken);
var compiledOp = AddTypeNameToAbstractSelections(
registeredInternalOp,
@@ -187,7 +189,7 @@ private DeferIncrementalPlanResult PlanIncrementalPlan(
string operationId,
IncrementalPlanDescriptor descriptor,
int incrementalPlanId,
- bool emitPlannerEvents,
+ PlanningBudget budget,
CancellationToken cancellationToken)
{
var deferredOperation = descriptor.Operation;
@@ -219,6 +221,7 @@ private DeferIncrementalPlanResult PlanIncrementalPlan(
SelectionSet selectionSet;
(node, selectionSet) = CreateQueryPlanBase(deferredOperation, "defer", index);
+ // Nothing to fetch: the deferred operation selects no field that needs a source.
if (node.Backlog.IsEmpty)
{
return new DeferIncrementalPlanResult([], null);
@@ -274,6 +277,7 @@ node with
SelectionSet mutationSelectionSet;
(node, mutationSelectionSet) = CreateMutationPlanBase(deferredOperation, "defer", index);
+ // Nothing to fetch: the deferred operation selects no field that needs a source.
if (node.Backlog.IsEmpty)
{
return new DeferIncrementalPlanResult([], null);
@@ -317,7 +321,7 @@ node with
possiblePlans.Enqueue(node);
}
- var plan = Plan(operationId + "#defer_" + incrementalPlanId, possiblePlans, emitPlannerEvents, cancellationToken);
+ var plan = Plan(operationId + "#defer_" + incrementalPlanId, possiblePlans, budget, cancellationToken);
if (!plan.HasValue)
{
@@ -330,7 +334,7 @@ node with
throw new DeferredMutationLookupRequiredException(descriptor.Path, mutationAnchorType.Name);
}
- return new DeferIncrementalPlanResult([], null);
+ throw ThrowHelper.DeferredPlanNotFound(descriptor.Path);
}
return new DeferIncrementalPlanResult(
@@ -402,24 +406,31 @@ private bool TryLocateNodeFieldAnchor(
{
nodeField = null!;
- if (path.Length != 1
- || path[0].Kind != SelectionPathSegmentKind.Field
- || !_schema.QueryType.Fields.TryGetField(
- path[0].Name,
- allowInaccessibleFields: true,
- out var field)
- || field is not { Name: "node", Type: IInterfaceTypeDefinition { Name: "Node" } })
+ if (path.Length != 1 || path[0].Kind != SelectionPathSegmentKind.Field)
{
return false;
}
+ // A path segment carries the response name, so the AST field is resolved by
+ // alias (or name when there is no alias) before its schema field is checked.
+ var responseName = path[0].Name;
+
foreach (var selection in operation.SelectionSet.Selections)
{
if (selection is FieldNode candidate
- && (candidate.Alias?.Value == path[0].Name || candidate.Name.Value == path[0].Name))
+ && (candidate.Alias?.Value ?? candidate.Name.Value) == responseName)
{
- nodeField = candidate;
- return true;
+ if (_schema.QueryType.Fields.TryGetField(
+ candidate.Name.Value,
+ allowInaccessibleFields: true,
+ out var field)
+ && field is { Name: "node", Type: IInterfaceTypeDefinition { Name: "Node" } })
+ {
+ nodeField = candidate;
+ return true;
+ }
+
+ return false;
}
}
@@ -569,13 +580,21 @@ ScopeState ScopeStateFor(ParentPlanContext scope)
schemaHint,
resolver,
scopeState,
- out var parentStepId))
+ out var parentStepId,
+ out var isPartiallyResolvable))
{
lifted.Add(new LiftedDeferRequirement(requirement, downstreamStepId, parentStepId));
resolved = true;
break;
}
+ // A partially hostable requirement lifts nothing, so the incremental plan
+ // stays self-contained.
+ if (isPartiallyResolvable)
+ {
+ return incrementalPlanSteps;
+ }
+
// When no existing parent-scope step can supply the
// requirement, add a dedicated step to the enclosing scope.
var (stepsAfterPromotion, newStepId, promotedIncrementalPlanStepId) =
@@ -607,6 +626,13 @@ ScopeState ScopeStateFor(ParentPlanContext scope)
if (!resolved)
{
+ // A requirement whose map names no single root field has no provider the
+ // scope walker can lift, so the incremental plan stays self-contained.
+ if ((requirement.InternalAlias ?? ExtractRootFieldName(requirement.Map.ToString())) is null)
+ {
+ return incrementalPlanSteps;
+ }
+
throw CreateUnsatisfiableDeferRequirementException(
producers[0],
requirement,
@@ -666,7 +692,8 @@ is not { } providerStep
candidateSchema!,
resolver,
scopeState,
- out var parentStepId))
+ out var parentStepId,
+ out _))
{
resolvedParentStepId = parentStepId;
break;
@@ -1190,16 +1217,19 @@ private static SelectionSetNode ReplaceSelectionSetAtPath(
/// Mirrors the inline onto the parent's internal operation so the
/// compiled parent Operation carries the field (the runtime composite
/// result document relies on that field being preserved during result
- /// merging).
+ /// merging). Nothing is injected and is set
+ /// when the step can host only part of the requirement.
///
private bool TryInlineDeferRequirementInScope(
OperationRequirement requirement,
string schemaName,
ValueSelectionToSelectionSetRewriter resolver,
ScopeState scopeState,
- out int parentStepId)
+ out int parentStepId,
+ out bool isPartiallyResolvable)
{
parentStepId = 0;
+ isPartiallyResolvable = false;
for (var i = 0; i < scopeState.Steps.Count; i++)
{
@@ -1246,6 +1276,8 @@ private bool TryInlineDeferRequirementInScope(
var dependentsBeforeInline = parentStep.Dependents;
+ RegisterRequirementSelectionSets(injectionSelections, stepIndex);
+
if (!TryInlineSelectionSetIntoStep(
parentStep,
targetId,
@@ -1256,11 +1288,18 @@ private bool TryInlineDeferRequirementInScope(
stepIndex,
new RequirementAliasContext([], RequirementAliasRegistry.Empty),
out var updatedParentStep,
- out _))
+ out var unresolvable,
+ out var fieldsWithRequirements))
{
continue;
}
+ if (!unresolvable.IsEmpty || !fieldsWithRequirements.IsEmpty)
+ {
+ isPartiallyResolvable = true;
+ return false;
+ }
+
updatedParentStep = updatedParentStep with
{
Dependents = dependentsBeforeInline,
@@ -2135,7 +2174,8 @@ private readonly record struct LiftedDeferRequirement(
private (ImmutableArray RootNodes, ImmutableArray AllNodes) BuildDeferredExecutionNodes(
OperationDefinitionNode deferredOperation,
ImmutableList planSteps,
- int nextNodeId)
+ int nextNodeId,
+ CancellationToken cancellationToken)
{
if (planSteps.Count == 0)
{
@@ -2147,7 +2187,7 @@ private readonly record struct LiftedDeferRequirement(
planSteps = TransformPlanSteps(planSteps, deferredOperation);
IndexDependencies(planSteps, ctx);
- BuildExecutionNodes(planSteps, ctx, _schema, hasVariables, CancellationToken.None);
+ BuildExecutionNodes(planSteps, ctx, _schema, hasVariables, cancellationToken);
MergeAndBatchOperations(ctx, _options.EnableRequestGrouping, _options.MergePolicy, _schema);
WireExecutionDependencies(ctx);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs
index 86a19d4bf60..0fa413cc9b9 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs
@@ -56,7 +56,7 @@ public OperationPlanner(
_options = options;
}
- public static Version Version { get; } = new(2, 0, 0);
+ public static Version Version { get; } = new(2, 1, 0);
internal OperationPlannerOptions Options => _options;
@@ -90,6 +90,7 @@ public OperationPlan CreatePlan(
var operationType = operationDefinition.Operation.ToString();
var rootSelectionCount = operationDefinition.SelectionSet.Selections.Count;
var startedAt = eventSourceEnabled ? Stopwatch.GetTimestamp() : 0L;
+ var budget = new PlanningBudget(_options, eventSourceEnabled);
var searchSpace = 0;
var expandedNodes = 0;
var stepCount = 0;
@@ -174,7 +175,7 @@ public OperationPlan CreatePlan(
id,
node,
subscriptionField,
- eventSourceEnabled,
+ budget,
cancellationToken);
internalOperationDefinition = eventStreamPlan.InternalOperationDefinition;
@@ -208,7 +209,7 @@ node with
}
// Now that we have seeded the possible plans we can start planning.
- var plan = Plan(id, possiblePlans, eventSourceEnabled, cancellationToken);
+ var plan = Plan(id, possiblePlans, budget, cancellationToken);
if (!plan.HasValue)
{
@@ -246,7 +247,7 @@ node with
id,
deferSplit.Value,
deferContextGraph,
- eventSourceEnabled,
+ budget,
cancellationToken);
// Any parent-scope transformations applied while routing
@@ -421,17 +422,16 @@ static bool HasCycle(
private PlanResult? Plan(
string operationId,
PlanQueue possiblePlans,
- bool emitPlannerEvents,
+ PlanningBudget budget,
CancellationToken cancellationToken)
{
var eventSource = PlannerEventSource.Log;
+ var emitPlannerEvents = budget.EmitPlannerEvents;
var searchSpace = possiblePlans.Count;
var expandedNodes = 0;
- var maxPlanningTime = _options.MaxPlanningTime;
- var maxExpandedNodes = _options.MaxExpandedNodes;
- var maxQueueSize = _options.MaxQueueSize;
- var maxGeneratedOptionsPerWorkItem = _options.MaxGeneratedOptionsPerWorkItem;
- var planningStartedAt = maxPlanningTime.HasValue ? Stopwatch.GetTimestamp() : 0L;
+
+ // The seeded queue is checked before any plan is expanded.
+ budget.EnsureQueueSize(operationId, searchSpace);
// TryBuildGreedyCompletePlan quickly builds one full plan by always choosing the currently
// cheapest next option at each step.
@@ -439,7 +439,7 @@ static bool HasCycle(
// It gives the planner an initial best known complete cost, so the main search can skip branches
// that are already worse. If it cannot finish a full plan, it returns null and the planner
// continues without that early shortcut.
- var bestCompletePlan = TryBuildGreedyCompletePlan(possiblePlans, cancellationToken);
+ var bestCompletePlan = TryBuildGreedyCompletePlan(operationId, possiblePlans, budget, cancellationToken);
// A plan whose step-dependency graph is cyclic cannot be scheduled, so it must never win.
// We discard a cyclic greedy plan here and reject cyclic candidates during the search below.
@@ -449,6 +449,7 @@ static bool HasCycle(
}
var bestCompletePlanCost = bestCompletePlan?.PathCost ?? double.PositiveInfinity;
+ var bestSurvivingStepCount = -1;
while (possiblePlans.TryDequeue(out var current, out _))
{
@@ -461,10 +462,8 @@ static bool HasCycle(
searchSpace = Math.Max(possiblePlansCount, searchSpace);
// before we get into another planning iteration, we check if we have
- // exceeded any of the configured guardrails and throw if so.
- EnsurePlanningTimeGuardrail();
- EnsureExpandedNodesGuardrail(expandedNodes);
- EnsureQueueSizeGuardrail(possiblePlansCount);
+ // exceeded the planning time or expanded node guardrail and throw if so.
+ budget.CountExpansion(operationId);
var backlog = current.Backlog;
@@ -478,10 +477,9 @@ static bool HasCycle(
current.SchemaName);
}
- // If the current plan is already at least as expensive as the
- // best complete plan, we can skip it and don't need to evaluate
- // it any further.
- if (current.BestCaseCost >= bestCompletePlanCost)
+ // Skip plans whose best-case cost exceeds the best complete plan.
+ // Plans of equal cost continue to the tie-break.
+ if (current.BestCaseCost > bestCompletePlanCost)
{
continue;
}
@@ -501,10 +499,11 @@ static bool HasCycle(
if (completeCost < bestCompletePlanCost
|| (completeCost.Equals(bestCompletePlanCost)
&& bestCompletePlan is not null
- && ComparePlansForTieBreak(current, bestCompletePlan) < 0))
+ && ComparePlansForTieBreak(current, bestCompletePlan, ref bestSurvivingStepCount) < 0))
{
bestCompletePlan = current;
bestCompletePlanCost = completeCost;
+ bestSurvivingStepCount = -1;
}
continue;
@@ -558,10 +557,11 @@ static bool HasCycle(
// after we have expanded the current plan node into possible next steps,
// we check how many new plans we have created and if we have exceeded
- // the guardrail for generated options per work item.
+ // the guardrails for generated options per work item and queue size.
var queueCountAfterExpansion = possiblePlans.Count;
searchSpace = Math.Max(queueCountAfterExpansion, searchSpace);
- EnsureGeneratedOptionsGuardrail(queueCountBeforeExpansion, queueCountAfterExpansion);
+ budget.EnsureGeneratedOptions(operationId, queueCountAfterExpansion - queueCountBeforeExpansion);
+ budget.EnsureQueueSize(operationId, queueCountAfterExpansion);
}
if (bestCompletePlan is null)
@@ -588,99 +588,13 @@ static string FormatWorkItemName(WorkItem workItem)
NodeLookupWorkItem => "NodeLookupBound",
_ => "Unknown"
};
-
- void EnsurePlanningTimeGuardrail()
- {
- if (maxPlanningTime is not { } planningTimeLimit)
- {
- return;
- }
-
- var elapsed = Stopwatch.GetElapsedTime(planningStartedAt);
- if (elapsed < planningTimeLimit)
- {
- return;
- }
-
- ThrowGuardrailExceeded(
- OperationPlannerGuardrailReason.MaxPlanningTimeExceeded,
- ToGuardrailMilliseconds(planningTimeLimit),
- ToGuardrailMilliseconds(elapsed));
- }
-
- void EnsureExpandedNodesGuardrail(int currentExpandedNodes)
- {
- if (maxExpandedNodes is not { } expandedNodesLimit
- || currentExpandedNodes <= expandedNodesLimit)
- {
- return;
- }
-
- ThrowGuardrailExceeded(
- OperationPlannerGuardrailReason.MaxExpandedNodesExceeded,
- expandedNodesLimit,
- currentExpandedNodes);
- }
-
- void EnsureQueueSizeGuardrail(int queueSize)
- {
- if (maxQueueSize is not { } queueSizeLimit
- || queueSize <= queueSizeLimit)
- {
- return;
- }
-
- ThrowGuardrailExceeded(
- OperationPlannerGuardrailReason.MaxQueueSizeExceeded,
- queueSizeLimit,
- queueSize);
- }
-
- void EnsureGeneratedOptionsGuardrail(int queueCountBeforeExpansion, int queueCountAfterExpansion)
- {
- if (maxGeneratedOptionsPerWorkItem is not { } generatedOptionsLimit)
- {
- return;
- }
-
- var generatedOptions = queueCountAfterExpansion - queueCountBeforeExpansion;
- if (generatedOptions <= generatedOptionsLimit)
- {
- return;
- }
-
- ThrowGuardrailExceeded(
- OperationPlannerGuardrailReason.MaxGeneratedOptionsPerWorkItemExceeded,
- generatedOptionsLimit,
- generatedOptions);
- }
-
- void ThrowGuardrailExceeded(
- OperationPlannerGuardrailReason reason,
- long limit,
- long observed)
- {
- if (emitPlannerEvents)
- {
- eventSource.PlanGuardrailExceeded(
- operationId,
- reason.ToString(),
- limit,
- observed);
- }
-
- throw new OperationPlannerGuardrailException(
- operationId,
- reason,
- limit,
- observed);
- }
-
- static long ToGuardrailMilliseconds(TimeSpan value)
- => checked((long)Math.Ceiling(value.TotalMilliseconds));
}
- private PlanNode? TryBuildGreedyCompletePlan(PlanQueue possiblePlans, CancellationToken cancellationToken)
+ private PlanNode? TryBuildGreedyCompletePlan(
+ string operationId,
+ PlanQueue possiblePlans,
+ PlanningBudget budget,
+ CancellationToken cancellationToken)
{
if (!possiblePlans.TryPeek(out var current, out _))
{
@@ -700,6 +614,8 @@ static long ToGuardrailMilliseconds(TimeSpan value)
return current;
}
+ budget.CountExpansion(operationId);
+
backlog = backlog.Pop(out var workItem);
switch (workItem)
@@ -742,6 +658,8 @@ static long ToGuardrailMilliseconds(TimeSpan value)
"The work item type is not supported.");
}
+ budget.EnsureGeneratedOptions(operationId, candidates.Count);
+
if (!candidates.TryDequeue(out current, out _))
{
return null;
@@ -777,8 +695,26 @@ private static int GetOperationStepDepth(PlanNode current, int stepId)
? stepDepth
: 1;
- private static int ComparePlansForTieBreak(PlanNode left, PlanNode right)
+ ///
+ /// Orders two complete plans of equal cost: fewer operation steps that survive the plan step
+ /// transforms first, then fewer operation steps, then step ids and schema names.
+ ///
+ internal static int ComparePlansForTieBreak(
+ PlanNode left,
+ PlanNode right,
+ ref int rightSurvivingStepCount)
{
+ if (rightSurvivingStepCount < 0)
+ {
+ rightSurvivingStepCount = CountSurvivingOperationSteps(right.Steps);
+ }
+
+ var survivingStepComparison = CountSurvivingOperationSteps(left.Steps).CompareTo(rightSurvivingStepCount);
+ if (survivingStepComparison != 0)
+ {
+ return survivingStepComparison;
+ }
+
var stepCountComparison = left.OperationStepCount.CompareTo(right.OperationStepCount);
if (stepCountComparison != 0)
{
@@ -902,7 +838,7 @@ private EventStreamPlanningResult PlanEventStreamSubscription(
string operationId,
PlanNode seed,
SubscriptionField subscriptionField,
- bool emitPlannerEvents,
+ PlanningBudget budget,
CancellationToken cancellationToken)
{
var possiblePlans = new PlanQueue(_schema);
@@ -914,7 +850,7 @@ seed with
ResolutionCost = 0
});
- var plan = Plan(operationId, possiblePlans, emitPlannerEvents, cancellationToken);
+ var plan = Plan(operationId, possiblePlans, budget, cancellationToken);
if (!plan.HasValue)
{
@@ -1077,6 +1013,7 @@ private void PlanLookupSelections(
workItem.Conditions,
workItem.AllowSourceSchemaReentry,
workItem.SourceSchemaNodePolicy,
+ consumerStep: null,
out var unresolvedRequirements);
// A self-cyclic lookup can proceed only when an existing step supplies its key.
@@ -1284,6 +1221,7 @@ private PlanNode InlineLookupRequirements(
ExecutionNodeCondition[]? conditions,
bool allowSourceSchemaReentry,
SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy,
+ OperationPlanStep? consumerStep,
out SelectionSetNode? unresolvedRequirements)
{
var processed = new HashSet();
@@ -1336,6 +1274,13 @@ private PlanNode InlineLookupRequirements(
continue;
}
+ // The lookup step inherits the dependents of the step that consumes its field, so
+ // a step that already depends on the consumer cannot provide the lookup's key.
+ if (consumerStep is not null && step.DependsOn(consumerStep, steps))
+ {
+ continue;
+ }
+
if (!processed.Add(schemaName)
|| (!allowSourceSchemaReentry && lookup.SchemaName.Equals(schemaName)))
{
@@ -1371,7 +1316,7 @@ private PlanNode InlineLookupRequirements(
: null
};
- var (resolvable, unresolvable, _, _) = _partitioner.Partition(input);
+ var (resolvable, unresolvable, fieldsWithRequirements, _) = _partitioner.Partition(input);
if (resolvable is { Selections.Count: > 0 })
{
@@ -1400,6 +1345,14 @@ private PlanNode InlineLookupRequirements(
selectionSet = null;
+ // fields of the inlined requirement that carry their own requirements are not
+ // resolvable in the step yet, so they are planned on behalf of the step.
+ backlog = backlog.PushRequirements(
+ fieldsWithRequirements,
+ new StepConsumer(step.Id),
+ GetOperationStepDepth(current, step.Id),
+ descendantPolicy);
+
if (!unresolvable.IsEmpty)
{
var top = unresolvable.Peek();
@@ -1434,6 +1387,7 @@ private PlanNode InlineLookupRequirements(
index,
workItemSelectionSet.Id,
workItemSelectionSet.Path) is { } ancestorMatch
+ && (consumerStep is null || !ancestorMatch.Step.DependsOn(consumerStep, steps))
&& processed.Add(ancestorMatch.Step.SchemaName!)
&& (allowSourceSchemaReentry || !lookup.SchemaName.Equals(ancestorMatch.Step.SchemaName)))
{
@@ -1445,10 +1399,17 @@ private PlanNode InlineLookupRequirements(
index,
ref steps,
out var unresolvable,
+ out var ancestorFieldsWithRequirements,
treatSourceExternalAsUnresolvable: sourceSchemaNodePolicy is not null))
{
selectionSet = null;
+ backlog = backlog.PushRequirements(
+ ancestorFieldsWithRequirements,
+ new StepConsumer(ancestorMatch.Step.Id),
+ GetOperationStepDepth(current, ancestorMatch.Step.Id),
+ descendantPolicy);
+
if (!unresolvable.IsEmpty)
{
var top = unresolvable.Peek();
@@ -1809,6 +1770,7 @@ private void PlanFieldWithRequirement(
workItem.Conditions,
allowSourceSchemaReentry: false,
workItem.SourceSchemaNodePolicy,
+ currentStep,
out _);
backlog = current.Backlog;
@@ -1917,17 +1879,40 @@ private void PlanFieldWithRequirement(
ref backlog,
workItem.SourceSchemaNodePolicy);
- var selectionSetNode = new SelectionSetNode(
- [workItem.Selection.Node.WithArguments(arguments).WithSelectionSet(childSelections)]);
- indexBuilder.Register(workItem.Selection.SelectionSetId, selectionSetNode);
+ var selectionNode =
+ workItem.Selection.Node.WithArguments(arguments).WithSelectionSet(childSelections);
+ OperationPlanStep? refreshedExistingStep = null;
if (mergeWithExistingStep)
{
- if (steps[existingStepIndex] is not OperationPlanStep refreshedExistingStep)
+ if (steps[existingStepIndex] is not OperationPlanStep refreshedStep)
{
return;
}
+ refreshedExistingStep = refreshedStep;
+
+ // Two merged lookups that select the same response name with different
+ // argument variables must not share a response key in the merged document.
+ var existingSelectionSet = FindSelectionSet(
+ refreshedExistingStep.Definition.SelectionSet,
+ indexBuilder,
+ refreshedExistingStep.RootSelectionSetId);
+
+ if (existingSelectionSet is not null
+ && HasArgumentConflict(selectionNode, existingSelectionSet.Selections))
+ {
+ selectionNode = CreateFieldWithAlias(
+ selectionNode,
+ requirementAliases.MintAlias(selectionNode));
+ }
+ }
+
+ var selectionSetNode = new SelectionSetNode([selectionNode]);
+ indexBuilder.Register(workItem.Selection.SelectionSetId, selectionSetNode);
+
+ if (refreshedExistingStep is not null)
+ {
var operation = InlineSelections(
refreshedExistingStep.Definition,
indexBuilder,
@@ -2867,6 +2852,7 @@ private Backlog PushRequirementLeftover(
requirementAliases,
out var updatedStep,
out var unresolvable,
+ out var nestedFieldsWithRequirements,
treatSourceExternalAsUnresolvable: workItem.SourceSchemaNodePolicy is not null))
{
// if we cannot resolve any selection with the current source we cannot inline the
@@ -2877,6 +2863,16 @@ private Backlog PushRequirementLeftover(
steps = steps.SetItem(stepIndex, updatedStep);
requirements = null;
+ // fields of the inlined requirement that carry their own requirements are not
+ // resolvable in the step yet, so they are planned on behalf of the step.
+ backlog = backlog.PushRequirements(
+ nestedFieldsWithRequirements,
+ new StepConsumer(step.Id),
+ GetOperationStepDepth(current, step.Id),
+ workItem.SourceSchemaNodePolicy is null
+ ? null
+ : SourceSchemaNodePlanningPolicy.Descendant);
+
if (!unresolvable.IsEmpty)
{
// if we have unresolvable parts of the requirements we will take the top level
@@ -2948,10 +2944,19 @@ private Backlog PushRequirementLeftover(
if (TryInlineIntoAncestorStep(
ancestorMatch, requirements, workItem.Selection.Path,
dependentStepId, index, ref steps, out var unresolvable,
+ out var nestedAncestorFieldsWithRequirements,
treatSourceExternalAsUnresolvable: workItem.SourceSchemaNodePolicy is not null))
{
requirements = null;
+ backlog = backlog.PushRequirements(
+ nestedAncestorFieldsWithRequirements,
+ new StepConsumer(ancestorMatch.Step.Id),
+ GetOperationStepDepth(current, ancestorMatch.Step.Id),
+ workItem.SourceSchemaNodePolicy is null
+ ? null
+ : SourceSchemaNodePlanningPolicy.Descendant);
+
if (!unresolvable.IsEmpty)
{
var top = unresolvable.Peek();
@@ -2995,6 +3000,7 @@ private bool TryInlineSelectionSetIntoStep(
RequirementAliasContext requirementAliases,
out OperationPlanStep updatedStep,
out ImmutableStack unresolvable,
+ out ImmutableStack fieldsWithRequirements,
bool treatSourceExternalAsUnresolvable = false)
{
index.Register(targetSelectionSetId, requirementSelections);
@@ -3017,15 +3023,19 @@ private bool TryInlineSelectionSetIntoStep(
: null
};
- var (resolvable, partitionUnresolvable, _, _) = _partitioner.Partition(input);
+ var (resolvable, partitionUnresolvable, partitionFieldsWithRequirements, _) =
+ _partitioner.Partition(input);
if (resolvable is not { Selections.Count: > 0 })
{
updatedStep = step;
unresolvable = [];
+ fieldsWithRequirements = [];
return false;
}
+ fieldsWithRequirements = partitionFieldsWithRequirements;
+
var existingSelectionSet = FindSelectionSet(
step.Definition.SelectionSet,
index,
@@ -3135,56 +3145,56 @@ private bool TryInlineSelectionSetIntoStep(
unresolvable = partitionUnresolvable;
return true;
+ }
- static SelectionSetNode? FindSelectionSet(
- SelectionSetNode selectionSet,
- SelectionSetIndexBuilder index,
- uint targetSelectionSetId)
+ private static SelectionSetNode? FindSelectionSet(
+ SelectionSetNode selectionSet,
+ SelectionSetIndexBuilder index,
+ uint targetSelectionSetId)
+ {
+ if (index.IsRegistered(selectionSet)
+ && index.GetId(selectionSet) == targetSelectionSetId)
{
- if (index.IsRegistered(selectionSet)
- && index.GetId(selectionSet) == targetSelectionSetId)
- {
- return selectionSet;
- }
+ return selectionSet;
+ }
- foreach (var selection in selectionSet.Selections)
+ foreach (var selection in selectionSet.Selections)
+ {
+ switch (selection)
{
- switch (selection)
+ case FieldNode { SelectionSet: not null } field:
{
- case FieldNode { SelectionSet: not null } field:
- {
- var result = FindSelectionSet(
- field.SelectionSet,
- index,
- targetSelectionSetId);
-
- if (result is not null)
- {
- return result;
- }
+ var result = FindSelectionSet(
+ field.SelectionSet,
+ index,
+ targetSelectionSetId);
- break;
+ if (result is not null)
+ {
+ return result;
}
- case InlineFragmentNode inlineFragment:
- {
- var result = FindSelectionSet(
- inlineFragment.SelectionSet,
- index,
- targetSelectionSetId);
+ break;
+ }
- if (result is not null)
- {
- return result;
- }
+ case InlineFragmentNode inlineFragment:
+ {
+ var result = FindSelectionSet(
+ inlineFragment.SelectionSet,
+ index,
+ targetSelectionSetId);
- break;
+ if (result is not null)
+ {
+ return result;
}
+
+ break;
}
}
-
- return null;
}
+
+ return null;
}
private static FieldNode CreateFieldWithAlias(FieldNode field, string internalAlias)
@@ -4199,9 +4209,11 @@ private bool TryInlineIntoAncestorStep(
SelectionSetIndexBuilder index,
ref ImmutableList steps,
out ImmutableStack unresolvable,
+ out ImmutableStack fieldsWithRequirements,
bool treatSourceExternalAsUnresolvable = false)
{
unresolvable = [];
+ fieldsWithRequirements = [];
var input = new SelectionSetPartitionerInput
{
@@ -4222,7 +4234,8 @@ match.Step.Lookup is not null
: null
};
- var (resolvable, partitionUnresolvable, _, _) = _partitioner.Partition(input);
+ var (resolvable, partitionUnresolvable, partitionFieldsWithRequirements, _) =
+ _partitioner.Partition(input);
if (resolvable is not { Selections.Count: > 0 })
{
@@ -4254,6 +4267,7 @@ match.Step.Lookup is not null
steps = steps.SetItem(match.StepIndex, updatedStep);
unresolvable = partitionUnresolvable;
+ fieldsWithRequirements = partitionFieldsWithRequirements;
return true;
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanningBudget.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanningBudget.cs
new file mode 100644
index 00000000000..4932a1600b8
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanningBudget.cs
@@ -0,0 +1,120 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using ThrowHelper = HotChocolate.Fusion.Execution.ThrowHelper;
+
+namespace HotChocolate.Fusion.Planning;
+
+///
+/// Tracks the configured planner guardrails for a single operation across the greedy pass,
+/// the main search and every deferred search.
+///
+internal sealed class PlanningBudget
+{
+ private readonly TimeSpan? _maxPlanningTime;
+ private readonly int? _maxExpandedNodes;
+ private readonly int? _maxQueueSize;
+ private readonly int? _maxGeneratedOptionsPerWorkItem;
+ private readonly long _startedAt;
+ private int _expandedNodes;
+
+ public PlanningBudget(OperationPlannerOptions options, bool emitPlannerEvents)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ _maxPlanningTime = options.MaxPlanningTime;
+ _maxExpandedNodes = options.MaxExpandedNodes;
+ _maxQueueSize = options.MaxQueueSize;
+ _maxGeneratedOptionsPerWorkItem = options.MaxGeneratedOptionsPerWorkItem;
+ _startedAt = _maxPlanningTime.HasValue ? Stopwatch.GetTimestamp() : 0L;
+ EmitPlannerEvents = emitPlannerEvents;
+ }
+
+ ///
+ /// Gets a value indicating whether planner events are written for this operation.
+ ///
+ public bool EmitPlannerEvents { get; }
+
+ ///
+ /// Counts one expanded node and throws when the planning time or the expanded node limit is exceeded.
+ ///
+ public void CountExpansion(string operationId)
+ {
+ _expandedNodes++;
+
+ if (_maxPlanningTime is { } planningTimeLimit)
+ {
+ var elapsed = Stopwatch.GetElapsedTime(_startedAt);
+
+ if (elapsed >= planningTimeLimit)
+ {
+ ThrowExceeded(
+ operationId,
+ OperationPlannerGuardrailReason.MaxPlanningTimeExceeded,
+ ToMilliseconds(planningTimeLimit),
+ ToMilliseconds(elapsed));
+ }
+ }
+
+ if (_maxExpandedNodes is { } expandedNodesLimit && _expandedNodes > expandedNodesLimit)
+ {
+ ThrowExceeded(
+ operationId,
+ OperationPlannerGuardrailReason.MaxExpandedNodesExceeded,
+ expandedNodesLimit,
+ _expandedNodes);
+ }
+ }
+
+ ///
+ /// Throws when the queue size exceeds the configured limit.
+ ///
+ public void EnsureQueueSize(string operationId, int queueSize)
+ {
+ if (_maxQueueSize is { } queueSizeLimit && queueSize > queueSizeLimit)
+ {
+ ThrowExceeded(
+ operationId,
+ OperationPlannerGuardrailReason.MaxQueueSizeExceeded,
+ queueSizeLimit,
+ queueSize);
+ }
+ }
+
+ ///
+ /// Throws when a single work item generated more options than the configured limit.
+ ///
+ public void EnsureGeneratedOptions(string operationId, int generatedOptions)
+ {
+ if (_maxGeneratedOptionsPerWorkItem is { } generatedOptionsLimit
+ && generatedOptions > generatedOptionsLimit)
+ {
+ ThrowExceeded(
+ operationId,
+ OperationPlannerGuardrailReason.MaxGeneratedOptionsPerWorkItemExceeded,
+ generatedOptionsLimit,
+ generatedOptions);
+ }
+ }
+
+ [DoesNotReturn]
+ private void ThrowExceeded(
+ string operationId,
+ OperationPlannerGuardrailReason reason,
+ long limit,
+ long observed)
+ {
+ if (EmitPlannerEvents)
+ {
+ PlannerEventSource.Log.PlanGuardrailExceeded(
+ operationId,
+ reason.ToString(),
+ limit,
+ observed);
+ }
+
+ throw ThrowHelper.PlanningGuardrailExceeded(operationId, reason, limit, observed);
+ }
+
+ private static long ToMilliseconds(TimeSpan value)
+ => checked((long)Math.Ceiling(value.TotalMilliseconds));
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs
index e3fa576bc30..105911aaef3 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs
@@ -1,3 +1,4 @@
+using System.Buffers;
using System.Collections.Immutable;
using HotChocolate.Execution;
using HotChocolate.Fusion.Execution.Nodes;
@@ -9,6 +10,8 @@ namespace HotChocolate.Fusion.Planning;
public record OperationPlanStep : PlanStep
{
+ private const int StackAllocWordLimit = 32;
+
public required OperationDefinitionNode Definition { get; init; }
public required ITypeDefinition Type { get; init; }
@@ -41,19 +44,53 @@ public record OperationPlanStep : PlanStep
internal EventStreamPlan? EventStreamPlan { get; init; }
public bool DependsOn(OperationPlanStep otherStep, ImmutableList allSteps)
- => DependsOnRecursive(otherStep, Id, allSteps, []);
+ {
+ if (otherStep.Dependents.Contains(Id))
+ {
+ return true;
+ }
+
+ if (otherStep.Dependents.IsEmpty)
+ {
+ return false;
+ }
+
+ var wordCount = (Math.Max(allSteps.Count, otherStep.Id) >> 6) + 1;
+ ulong[]? rented = null;
+ var visited = wordCount <= StackAllocWordLimit
+ ? stackalloc ulong[wordCount]
+ : (rented = ArrayPool.Shared.Rent(wordCount)).AsSpan(0, wordCount);
+ visited.Clear();
+
+ try
+ {
+ return DependsOnRecursive(otherStep, Id, allSteps, visited);
+ }
+ finally
+ {
+ if (rented is not null)
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+ }
+ }
private static bool DependsOnRecursive(
OperationPlanStep currentStep,
int targetId,
ImmutableList allSteps,
- HashSet visited)
+ Span visited)
{
- if (!visited.Add(currentStep.Id))
+ var word = currentStep.Id >> 6;
+ var bit = 1UL << (currentStep.Id & 63);
+
+ if ((visited[word] & bit) != 0)
{
return false;
}
+ visited[word] |= bit;
+
if (currentStep.Dependents.Contains(targetId))
{
return true;
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs
index f770526b31c..4a020ad1e1e 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs
@@ -3,6 +3,9 @@
using System.Text.Json;
using HotChocolate.Transport;
using HotChocolate.Transport.Http;
+using HotChocolate.Types.Composite;
+using HotChocolate.Types.Relay;
+using Microsoft.Extensions.DependencyInjection;
namespace HotChocolate.Fusion;
@@ -2036,4 +2039,298 @@ ... @defer {
// The incremental payload carries both birthdate and reviewCount, with birthdate non-null.
await MatchSnapshotAsync(gateway, request, result, stableStream: true);
}
+
+ [Fact]
+ public async Task Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsNothingElse()
+ {
+ // arrange
+ using var server1 = CreateSourceSchema(
+ "A",
+ """
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ name: String!
+ }
+ """);
+
+ using var gateway = await CreateCompositeSchemaAsync([("A", server1)]);
+
+ // act
+ using var client = GraphQLHttpClient.Create(gateway.CreateClient());
+
+ var request = new OperationRequest(
+ """
+ {
+ ... @defer {
+ users {
+ id
+ name
+ }
+ }
+ }
+ """);
+
+ using var result = await client.PostAsync(
+ request,
+ new Uri("http://localhost:5000/graphql"),
+ TestContext.Current.CancellationToken);
+
+ // assert
+ await MatchSnapshotAsync(gateway, request, result, stableStream: true);
+ }
+
+ [Fact]
+ public async Task Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsOnlyTypename()
+ {
+ // arrange
+ using var server1 = CreateSourceSchema(
+ "A",
+ """
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ name: String!
+ }
+ """);
+
+ using var gateway = await CreateCompositeSchemaAsync([("A", server1)]);
+
+ // act
+ using var client = GraphQLHttpClient.Create(gateway.CreateClient());
+
+ var request = new OperationRequest(
+ """
+ {
+ __typename
+ ... @defer {
+ users {
+ id
+ name
+ }
+ }
+ }
+ """);
+
+ using var result = await client.PostAsync(
+ request,
+ new Uri("http://localhost:5000/graphql"),
+ TestContext.Current.CancellationToken);
+
+ // assert
+ await MatchSnapshotAsync(gateway, request, result, stableStream: true);
+ }
+
+ [Fact]
+ public async Task Defer_Should_ResolveAllPrerequisites_When_RequirementMixesNativeAndComputedFields()
+ {
+ // arrange
+ using var server1 = CreateSourceSchema(
+ "A",
+ b => b.AddQueryType());
+
+ using var server2 = CreateSourceSchema(
+ "B",
+ b => b.AddQueryType());
+
+ using var server3 = CreateSourceSchema(
+ "C",
+ b => b.AddQueryType());
+
+ using var gateway = await CreateCompositeSchemaAsync(
+ [
+ ("A", server1),
+ ("B", server2),
+ ("C", server3)
+ ]);
+
+ // act
+ using var client = GraphQLHttpClient.Create(gateway.CreateClient());
+
+ var request = new OperationRequest(
+ """
+ {
+ product {
+ id
+ ... @defer {
+ total
+ }
+ }
+ }
+ """);
+
+ using var result = await client.PostAsync(
+ request,
+ new Uri("http://localhost:5000/graphql"),
+ TestContext.Current.CancellationToken);
+
+ // assert
+ await MatchSnapshotAsync(gateway, request, result, stableStream: true);
+ }
+
+ [Fact]
+ public async Task Defer_Should_ResolveAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement()
+ {
+ // arrange
+ using var server1 = CreateSourceSchema(
+ "A",
+ b => b.AddQueryType());
+
+ using var server2 = CreateSourceSchema(
+ "B",
+ b => b.AddQueryType());
+
+ using var server3 = CreateSourceSchema(
+ "C",
+ b => b.AddQueryType());
+
+ using var gateway = await CreateCompositeSchemaAsync(
+ [
+ ("A", server1),
+ ("B", server2),
+ ("C", server3)
+ ]);
+
+ // act
+ using var client = GraphQLHttpClient.Create(gateway.CreateClient());
+
+ var request = new OperationRequest(
+ """
+ {
+ product {
+ id
+ ... @defer {
+ total
+ }
+ }
+ }
+ """);
+
+ using var result = await client.PostAsync(
+ request,
+ new Uri("http://localhost:5000/graphql"),
+ TestContext.Current.CancellationToken);
+
+ // assert
+ await MatchSnapshotAsync(gateway, request, result, stableStream: true);
+ }
+
+ private static class MixedRequirementSourceA
+ {
+ public class Query
+ {
+ public Product GetProduct() => new(1);
+
+ [Lookup]
+ [Internal]
+ public Product? GetProductById([ID] int id) => new(id);
+ }
+
+ public record Product([property: ID] int Id)
+ {
+ public int Raw => 7;
+
+ public int GetComputed([Require("value")] int value) => value * 10;
+ }
+ }
+
+ private static class MixedRequirementSourceB
+ {
+ public class Query
+ {
+ [Lookup]
+ [Internal]
+ public Product? GetProductById([ID] int id) => new(id);
+ }
+
+ public record Product([property: ID] int Id)
+ {
+ public int Value => 3;
+ }
+ }
+
+ private static class MixedRequirementSourceC
+ {
+ public class Query
+ {
+ [Lookup]
+ [Internal]
+ public Product? GetProductById([ID] int id) => new(id);
+ }
+
+ public record Product([property: ID] int Id)
+ {
+ public int GetTotal([Require("{ raw computed }")] TotalInput input)
+ => input.Raw + input.Computed;
+ }
+
+ public record TotalInput(int Raw, int Computed);
+ }
+
+ private static class NestedRequirementSourceA
+ {
+ public class Query
+ {
+ public Product GetProduct() => new(1);
+
+ [Lookup]
+ [Internal]
+ public Product? GetProductById([ID] int id) => new(id);
+
+ [Lookup]
+ [Internal]
+ public User? GetUserById([ID] int id) => new(id);
+ }
+
+ public record Product([property: ID] int Id)
+ {
+ public User Owner => new(2);
+ }
+
+ public record User([property: ID] int Id)
+ {
+ public int Raw => 5;
+
+ public int GetComputed([Require("value")] int value) => value * 10;
+ }
+ }
+
+ private static class NestedRequirementSourceB
+ {
+ public class Query
+ {
+ [Lookup]
+ [Internal]
+ public User? GetUserById([ID] int id) => new(id);
+ }
+
+ public record User([property: ID] int Id)
+ {
+ public int Value => 4;
+ }
+ }
+
+ private static class NestedRequirementSourceC
+ {
+ public class Query
+ {
+ [Lookup]
+ [Internal]
+ public Product? GetProductById([ID] int id) => new(id);
+ }
+
+ public record Product([property: ID] int Id)
+ {
+ public int GetTotal([Require("owner.{ raw computed }")] OwnerInput input)
+ => input.Raw * 1000 + input.Computed;
+ }
+
+ public record OwnerInput(int Raw, int Computed);
+ }
}
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap
index 9d71b2a795f..a74676d6b37 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap
@@ -4,4 +4,4 @@ Content-Type: application/graphql-response+json; charset=utf-8
-------------------------->
Status Code: OK
-------------------------->
-{"data":{"field":"Query"},"extensions":{"fusion":{"operationPlan":{"version":"1.0.0","id":"6cdf0f2d4d954102b038ac59a63a0d7861ab395053f7b9d7aa9bb95641f6b72a","operation":{"kind":"Query","document":"{\n field\n}","id":"388c5f9c43fb589b1c5d1e144121613b","hash":"388c5f9c43fb589b1c5d1e144121613b","shortHash":"388c5f9c"},"searchSpace":1,"expandedNodes":1,"nodes":[{"id":1,"type":"Operation","schema":"A","operation":{"name":"Op_388c5f9c_1","kind":"Query","document":"query Op_388c5f9c_1 {\n field\n}","hash":"d243d6aa03056cfbc2ae6272df4c2dbbafb78f04901fe6faa816dcd979e1d239","shortHash":"d243d6aa","xxHash":6910290631799881210},"resultSelectionSet":"{ field }"}]}}}}
+{"data":{"field":"Query"},"extensions":{"fusion":{"operationPlan":{"version":"1.1.0","id":"6cdf0f2d4d954102b038ac59a63a0d7861ab395053f7b9d7aa9bb95641f6b72a","operation":{"kind":"Query","document":"{\n field\n}","id":"388c5f9c43fb589b1c5d1e144121613b","hash":"388c5f9c43fb589b1c5d1e144121613b","shortHash":"388c5f9c"},"searchSpace":1,"expandedNodes":1,"nodes":[{"id":1,"type":"Operation","schema":"A","operation":{"name":"Op_388c5f9c_1","kind":"Query","document":"query Op_388c5f9c_1 {\n field\n}","hash":"d243d6aa03056cfbc2ae6272df4c2dbbafb78f04901fe6faa816dcd979e1d239","shortHash":"d243d6aa","xxHash":6910290631799881210},"resultSelectionSet":"{ field }"}]}}}}
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap
index 9d71b2a795f..a74676d6b37 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap
@@ -4,4 +4,4 @@ Content-Type: application/graphql-response+json; charset=utf-8
-------------------------->
Status Code: OK
-------------------------->
-{"data":{"field":"Query"},"extensions":{"fusion":{"operationPlan":{"version":"1.0.0","id":"6cdf0f2d4d954102b038ac59a63a0d7861ab395053f7b9d7aa9bb95641f6b72a","operation":{"kind":"Query","document":"{\n field\n}","id":"388c5f9c43fb589b1c5d1e144121613b","hash":"388c5f9c43fb589b1c5d1e144121613b","shortHash":"388c5f9c"},"searchSpace":1,"expandedNodes":1,"nodes":[{"id":1,"type":"Operation","schema":"A","operation":{"name":"Op_388c5f9c_1","kind":"Query","document":"query Op_388c5f9c_1 {\n field\n}","hash":"d243d6aa03056cfbc2ae6272df4c2dbbafb78f04901fe6faa816dcd979e1d239","shortHash":"d243d6aa","xxHash":6910290631799881210},"resultSelectionSet":"{ field }"}]}}}}
+{"data":{"field":"Query"},"extensions":{"fusion":{"operationPlan":{"version":"1.1.0","id":"6cdf0f2d4d954102b038ac59a63a0d7861ab395053f7b9d7aa9bb95641f6b72a","operation":{"kind":"Query","document":"{\n field\n}","id":"388c5f9c43fb589b1c5d1e144121613b","hash":"388c5f9c43fb589b1c5d1e144121613b","shortHash":"388c5f9c"},"searchSpace":1,"expandedNodes":1,"nodes":[{"id":1,"type":"Operation","schema":"A","operation":{"name":"Op_388c5f9c_1","kind":"Query","document":"query Op_388c5f9c_1 {\n field\n}","hash":"d243d6aa03056cfbc2ae6272df4c2dbbafb78f04901fe6faa816dcd979e1d239","shortHash":"d243d6aa","xxHash":6910290631799881210},"resultSelectionSet":"{ field }"}]}}}}
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsNothingElse.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsNothingElse.yaml
new file mode 100644
index 00000000000..77a23f1c551
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsNothingElse.yaml
@@ -0,0 +1,144 @@
+title: Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsNothingElse
+request:
+ document: |
+ {
+ ... @defer {
+ users {
+ id
+ name
+ }
+ }
+ }
+stableResponseStream: |
+ {
+ "kind": "stable-stream",
+ "payloadCount": 2,
+ "initial": {
+ "data": {}
+ },
+ "pending": [
+ {
+ "id": "0",
+ "path": []
+ }
+ ],
+ "incremental": [
+ {
+ "id": "0",
+ "path": [],
+ "data": {
+ "users": [
+ {
+ "id": "VXNlcjox",
+ "name": "User: VXNlcjox"
+ },
+ {
+ "id": "VXNlcjoy",
+ "name": "User: VXNlcjoy"
+ },
+ {
+ "id": "VXNlcjoz",
+ "name": "User: VXNlcjoz"
+ }
+ ]
+ }
+ }
+ ],
+ "completed": [
+ {
+ "id": "0"
+ }
+ ],
+ "final": {
+ "data": {
+ "users": [
+ {
+ "id": "VXNlcjox",
+ "name": "User: VXNlcjox"
+ },
+ {
+ "id": "VXNlcjoy",
+ "name": "User: VXNlcjoy"
+ },
+ {
+ "id": "VXNlcjoz",
+ "name": "User: VXNlcjoz"
+ }
+ ]
+ }
+ }
+ }
+sourceSchemas:
+ - name: A
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ name: String!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_1 {
+ users {
+ id
+ name
+ }
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "users": [
+ {
+ "id": "VXNlcjox",
+ "name": "User: VXNlcjox"
+ },
+ {
+ "id": "VXNlcjoy",
+ "name": "User: VXNlcjoy"
+ },
+ {
+ "id": "VXNlcjoz",
+ "name": "User: VXNlcjoz"
+ }
+ ]
+ }
+ }
+operationPlan:
+ operation:
+ - document: |
+ {
+ __typename @fusion__empty
+ }
+ hash: dd1bd23fc345ecaee364e3c2d04c95ae
+ searchSpace: 1
+ expandedNodes: 1
+ nodes:
+ deliveryGroups:
+ - id: 0
+ path: $
+ incrementalPlans:
+ - deliveryGroupIds:
+ - 0
+ parentNodeId: 0
+ nodes:
+ - id: 1
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_1 {
+ users {
+ id
+ name
+ }
+ }
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsOnlyTypename.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsOnlyTypename.yaml
new file mode 100644
index 00000000000..33d51fc4080
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsOnlyTypename.yaml
@@ -0,0 +1,154 @@
+title: Defer_RootFragment_Should_DeliverDeferredFetch_When_RootSelectsOnlyTypename
+request:
+ document: |
+ {
+ __typename
+ ... @defer {
+ users {
+ id
+ name
+ }
+ }
+ }
+stableResponseStream: |
+ {
+ "kind": "stable-stream",
+ "payloadCount": 2,
+ "initial": {
+ "data": {
+ "__typename": "Query"
+ }
+ },
+ "pending": [
+ {
+ "id": "0",
+ "path": []
+ }
+ ],
+ "incremental": [
+ {
+ "id": "0",
+ "path": [],
+ "data": {
+ "users": [
+ {
+ "id": "VXNlcjox",
+ "name": "User: VXNlcjox"
+ },
+ {
+ "id": "VXNlcjoy",
+ "name": "User: VXNlcjoy"
+ },
+ {
+ "id": "VXNlcjoz",
+ "name": "User: VXNlcjoz"
+ }
+ ]
+ }
+ }
+ ],
+ "completed": [
+ {
+ "id": "0"
+ }
+ ],
+ "final": {
+ "data": {
+ "__typename": "Query",
+ "users": [
+ {
+ "id": "VXNlcjox",
+ "name": "User: VXNlcjox"
+ },
+ {
+ "id": "VXNlcjoy",
+ "name": "User: VXNlcjoy"
+ },
+ {
+ "id": "VXNlcjoz",
+ "name": "User: VXNlcjoz"
+ }
+ ]
+ }
+ }
+ }
+sourceSchemas:
+ - name: A
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ name: String!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_1 {
+ users {
+ id
+ name
+ }
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "users": [
+ {
+ "id": "VXNlcjox",
+ "name": "User: VXNlcjox"
+ },
+ {
+ "id": "VXNlcjoy",
+ "name": "User: VXNlcjoy"
+ },
+ {
+ "id": "VXNlcjoz",
+ "name": "User: VXNlcjoz"
+ }
+ ]
+ }
+ }
+operationPlan:
+ operation:
+ - document: |
+ {
+ __typename
+ }
+ hash: 66ec3dd11cfd686e09ab6f8f7e2cf968
+ searchSpace: 1
+ expandedNodes: 1
+ nodes:
+ - id: 1
+ type: Introspection
+ selections:
+ - id: 2
+ responseName: __typename
+ fieldName: __typename
+ deliveryGroups:
+ - id: 0
+ path: $
+ incrementalPlans:
+ - deliveryGroupIds:
+ - 0
+ parentNodeId: 1
+ nodes:
+ - id: 1
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_1 {
+ users {
+ id
+ name
+ }
+ }
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Should_ResolveAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Should_ResolveAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml
new file mode 100644
index 00000000000..b88a44b4576
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Should_ResolveAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml
@@ -0,0 +1,355 @@
+title: Defer_Should_ResolveAllPrerequisites_When_RequirementMixesNativeAndComputedFields
+request:
+ document: |
+ {
+ product {
+ id
+ ... @defer {
+ total
+ }
+ }
+ }
+stableResponseStream: |
+ {
+ "kind": "stable-stream",
+ "payloadCount": 2,
+ "initial": {
+ "data": {
+ "product": {
+ "id": "1"
+ }
+ }
+ },
+ "pending": [
+ {
+ "id": "0",
+ "path": [
+ "product"
+ ]
+ }
+ ],
+ "incremental": [
+ {
+ "id": "0",
+ "path": [
+ "product"
+ ],
+ "data": {
+ "total": 37
+ }
+ }
+ ],
+ "completed": [
+ {
+ "id": "0"
+ }
+ ],
+ "final": {
+ "data": {
+ "product": {
+ "id": "1",
+ "total": 37
+ }
+ }
+ }
+ }
+sourceSchemas:
+ - name: A
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ product: Product!
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ computed(value: Int! @require(field: "value")): Int!
+ id: ID!
+ raw: Int!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_3f99ee57_1 {
+ product {
+ id
+ }
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "product": {
+ "id": "1"
+ }
+ }
+ }
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_3($__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ raw
+ }
+ }
+ variables: |
+ {
+ "__fusion_4_id": "1"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "productById": {
+ "raw": 7
+ }
+ }
+ }
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_4($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ productById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ variables: |
+ {
+ "__fusion_5_value": 3,
+ "__fusion_6_id": "1"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "productById": {
+ "computed": 30
+ }
+ }
+ }
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_7 {
+ product {
+ id
+ }
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "product": {
+ "id": "1"
+ }
+ }
+ }
+ - name: B
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ value: Int!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_5($__fusion_7_id: ID!) {
+ productById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ variables: |
+ {
+ "__fusion_7_id": "1"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "productById": {
+ "value": 3
+ }
+ }
+ }
+ - name: C
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ total(input: TotalInput! @require(field: "{\n raw\n computed\n}")): Int!
+ id: ID!
+ }
+
+ input TotalInput {
+ raw: Int!
+ computed: Int!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_2($__fusion_2_input: TotalInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ variables: |
+ {
+ "__fusion_2_input": {
+ "raw": 7,
+ "computed": 30
+ },
+ "__fusion_3_id": "1"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "productById": {
+ "total": 37
+ }
+ }
+ }
+operationPlan:
+ operation:
+ - document: |
+ {
+ product {
+ id
+ }
+ }
+ hash: 5772a5cdca9030403458eb8799fd0718
+ searchSpace: 1
+ expandedNodes: 1
+ nodes:
+ - id: 1
+ type: Operation
+ schema: A
+ operation: |
+ query Op_3f99ee57_1 {
+ product {
+ id
+ }
+ }
+ deliveryGroups:
+ - id: 0
+ path: $.product
+ incrementalPlans:
+ - deliveryGroupIds:
+ - 0
+ parentNodeId: 1
+ nodes:
+ - id: 2
+ type: Operation
+ schema: C
+ operation: |
+ query Op_defer_2($__fusion_2_input: TotalInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_2_input
+ selectionMap: >-
+ {
+ raw
+ computed
+ }
+ - name: __fusion_3_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 4
+ - id: 7
+ - id: 3
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_3($__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ raw
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_4_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 7
+ - id: 4
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_4($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ productById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_5_value
+ selectionMap: >-
+ value
+ - name: __fusion_6_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 5
+ - id: 7
+ - id: 5
+ type: Operation
+ schema: B
+ operation: |
+ query Op_defer_5($__fusion_7_id: ID!) {
+ productById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_7_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 7
+ - id: 7
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_7 {
+ product {
+ id
+ }
+ }
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Should_ResolveAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Should_ResolveAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement.yaml
new file mode 100644
index 00000000000..c1030341c3f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Should_ResolveAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement.yaml
@@ -0,0 +1,370 @@
+title: Defer_Should_ResolveAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement
+request:
+ document: |
+ {
+ product {
+ id
+ ... @defer {
+ total
+ }
+ }
+ }
+stableResponseStream: |
+ {
+ "kind": "stable-stream",
+ "payloadCount": 2,
+ "initial": {
+ "data": {
+ "product": {
+ "id": "1"
+ }
+ }
+ },
+ "pending": [
+ {
+ "id": "0",
+ "path": [
+ "product"
+ ]
+ }
+ ],
+ "incremental": [
+ {
+ "id": "0",
+ "path": [
+ "product"
+ ],
+ "data": {
+ "total": 5040
+ }
+ }
+ ],
+ "completed": [
+ {
+ "id": "0"
+ }
+ ],
+ "final": {
+ "data": {
+ "product": {
+ "id": "1",
+ "total": 5040
+ }
+ }
+ }
+ }
+sourceSchemas:
+ - name: A
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ product: Product!
+ productById(id: ID!): Product @lookup @internal
+ userById(id: ID!): User @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ owner: User!
+ }
+
+ type User @key(fields: "id") {
+ computed(value: Int! @require(field: "value")): Int!
+ id: ID!
+ raw: Int!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_3f99ee57_1 {
+ product {
+ id
+ }
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "product": {
+ "id": "1"
+ }
+ }
+ }
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_3($__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ owner {
+ raw
+ id
+ }
+ }
+ }
+ variables: |
+ {
+ "__fusion_4_id": "1"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "productById": {
+ "owner": {
+ "raw": 5,
+ "id": "2"
+ }
+ }
+ }
+ }
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_4($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ userById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ variables: |
+ {
+ "__fusion_5_value": 4,
+ "__fusion_6_id": "2"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "userById": {
+ "computed": 40
+ }
+ }
+ }
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_8 {
+ product {
+ id
+ }
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "product": {
+ "id": "1"
+ }
+ }
+ }
+ - name: B
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ userById(id: ID!): User @lookup @internal
+ }
+
+ type User @key(fields: "id") {
+ id: ID!
+ value: Int!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_5($__fusion_7_id: ID!) {
+ userById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ variables: |
+ {
+ "__fusion_7_id": "2"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "userById": {
+ "value": 4
+ }
+ }
+ }
+ - name: C
+ schema: |
+ schema {
+ query: Query
+ }
+
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ total(input: OwnerInput! @require(field: "owner.{\n raw\n computed\n}")): Int!
+ id: ID!
+ }
+
+ input OwnerInput {
+ raw: Int!
+ computed: Int!
+ }
+ interactions:
+ - request:
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_defer_2($__fusion_2_input: OwnerInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ variables: |
+ {
+ "__fusion_2_input": {
+ "raw": 5,
+ "computed": 40
+ },
+ "__fusion_3_id": "1"
+ }
+ response:
+ results:
+ - |
+ {
+ "data": {
+ "productById": {
+ "total": 5040
+ }
+ }
+ }
+operationPlan:
+ operation:
+ - document: |
+ {
+ product {
+ id
+ }
+ }
+ hash: 5772a5cdca9030403458eb8799fd0718
+ searchSpace: 1
+ expandedNodes: 1
+ nodes:
+ - id: 1
+ type: Operation
+ schema: A
+ operation: |
+ query Op_3f99ee57_1 {
+ product {
+ id
+ }
+ }
+ deliveryGroups:
+ - id: 0
+ path: $.product
+ incrementalPlans:
+ - deliveryGroupIds:
+ - 0
+ parentNodeId: 1
+ nodes:
+ - id: 2
+ type: Operation
+ schema: C
+ operation: |
+ query Op_defer_2($__fusion_2_input: OwnerInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_2_input
+ selectionMap: >-
+ owner.{
+ raw
+ computed
+ }
+ - name: __fusion_3_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 4
+ - id: 8
+ - id: 3
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_3($__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ owner {
+ raw
+ id
+ }
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_4_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 8
+ - id: 4
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_4($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ userById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ source: $.userById
+ target: $.product.owner
+ requirements:
+ - name: __fusion_5_value
+ selectionMap: >-
+ value
+ - name: __fusion_6_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 5
+ - id: 5
+ type: Operation
+ schema: B
+ operation: |
+ query Op_defer_5($__fusion_7_id: ID!) {
+ userById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ source: $.userById
+ target: $.product.owner
+ requirements:
+ - name: __fusion_7_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 8
+ type: Operation
+ schema: A
+ operation: |
+ query Op_defer_8 {
+ product {
+ id
+ }
+ }
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml
index 04ded635699..b4d98a2d8c5 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml
@@ -104,34 +104,21 @@ sourceSchemas:
}
interactions:
- request:
- accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8
- kind: OperationBatch
- items:
- - document: |
- query Op_640ade6c_3($__fusion_3_id: ID!) {
- cartById(id: $__fusion_3_id) {
- promoCode {
- discountPercent
- isExpired
- }
- }
- }
- variables: |
- {
- "__fusion_3_id": "1"
- }
- - document: |
- query Op_640ade6c_4($__fusion_4_id: ID!) {
- cartById(id: $__fusion_4_id) {
- id
- }
- }
- variables: |
- {
- "__fusion_4_id": "1"
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_640ade6c_3($__fusion_3_id: ID!) {
+ cartById(id: $__fusion_3_id) {
+ promoCode {
+ discountPercent
+ isExpired
}
+ }
+ }
+ variables: |
+ {
+ "__fusion_3_id": "1"
+ }
response:
- contentType: application/jsonl; charset=utf-8
results:
- |
{
@@ -144,14 +131,6 @@ sourceSchemas:
}
}
}
- - |
- {
- "data": {
- "cartById": {
- "id": "1"
- }
- }
- }
operationPlan:
operation:
- document: |
@@ -200,7 +179,8 @@ operationPlan:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: promotions
@@ -215,28 +195,9 @@ operationPlan:
}
source: $.cartById
target: $.cartById
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: promotions
- operation: |
- query Op_640ade6c_4($__fusion_4_id: ID!) {
- cartById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.cartById
- target: $.cartById
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml
index 1993304c1ba..88726921d00 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml
@@ -101,34 +101,21 @@ sourceSchemas:
}
interactions:
- request:
- accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8
- kind: OperationBatch
- items:
- - document: |
- query Op_5ebdd92d_3($__fusion_3_id: ID!) {
- cartById(id: $__fusion_3_id) {
- promoCode {
- discountPercent
- isExpired
- }
- }
- }
- variables: |
- {
- "__fusion_3_id": "2"
- }
- - document: |
- query Op_5ebdd92d_4($__fusion_4_id: ID!) {
- cartById(id: $__fusion_4_id) {
- id
- }
- }
- variables: |
- {
- "__fusion_4_id": "2"
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_5ebdd92d_3($__fusion_3_id: ID!) {
+ cartById(id: $__fusion_3_id) {
+ promoCode {
+ discountPercent
+ isExpired
}
+ }
+ }
+ variables: |
+ {
+ "__fusion_3_id": "2"
+ }
response:
- contentType: application/jsonl; charset=utf-8
results:
- |
{
@@ -138,14 +125,6 @@ sourceSchemas:
}
}
}
- - |
- {
- "data": {
- "cartById": {
- "id": "2"
- }
- }
- }
operationPlan:
operation:
- document: |
@@ -194,7 +173,8 @@ operationPlan:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: promotions
@@ -209,28 +189,9 @@ operationPlan:
}
source: $.cartById
target: $.cartById
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: promotions
- operation: |
- query Op_5ebdd92d_4($__fusion_4_id: ID!) {
- cartById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.cartById
- target: $.cartById
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Leaf_Field.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Leaf_Field.yaml
index 3e92b6ed987..774cd3d31a0 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Leaf_Field.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Leaf_Field.yaml
@@ -32,31 +32,18 @@ sourceSchemas:
}
interactions:
- request:
- accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8
- kind: OperationBatch
- items:
- - document: |
- query Op_b05a9c48_3($__fusion_3_id: ID!) {
- productById(id: $__fusion_3_id) {
- nullableField
- }
- }
- variables: |
- {
- "__fusion_3_id": "1"
- }
- - document: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- variables: |
- {
- "__fusion_4_id": "1"
- }
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_b05a9c48_3($__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ nullableField
+ }
+ }
+ variables: |
+ {
+ "__fusion_3_id": "1"
+ }
response:
- contentType: application/jsonl; charset=utf-8
results:
- |
{
@@ -66,14 +53,6 @@ sourceSchemas:
}
}
}
- - |
- {
- "data": {
- "productById": {
- "id": "1"
- }
- }
- }
- name: B
schema: |
schema {
@@ -174,7 +153,8 @@ operationPlan:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: A
@@ -186,28 +166,9 @@ operationPlan:
}
source: $.productById
target: $.productById
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: A
- operation: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.productById
- target: $.productById
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Nullable_Leaf_Field_Returning_Null.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Nullable_Leaf_Field_Returning_Null.yaml
index b9d8ecfd181..4bef430910f 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Nullable_Leaf_Field_Returning_Null.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Nullable_Leaf_Field_Returning_Null.yaml
@@ -32,31 +32,18 @@ sourceSchemas:
}
interactions:
- request:
- accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8
- kind: OperationBatch
- items:
- - document: |
- query Op_b05a9c48_3($__fusion_3_id: ID!) {
- productById(id: $__fusion_3_id) {
- nullableField
- }
- }
- variables: |
- {
- "__fusion_3_id": "1"
- }
- - document: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- variables: |
- {
- "__fusion_4_id": "1"
- }
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_b05a9c48_3($__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ nullableField
+ }
+ }
+ variables: |
+ {
+ "__fusion_3_id": "1"
+ }
response:
- contentType: application/jsonl; charset=utf-8
results:
- |
{
@@ -66,14 +53,6 @@ sourceSchemas:
}
}
}
- - |
- {
- "data": {
- "productById": {
- "id": "1"
- }
- }
- }
- name: B
schema: |
schema {
@@ -174,7 +153,8 @@ operationPlan:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: A
@@ -186,28 +166,9 @@ operationPlan:
}
source: $.productById
target: $.productById
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: A
- operation: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.productById
- target: $.productById
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object.yaml
index e244031f5c0..66ddb752bd7 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object.yaml
@@ -36,33 +36,20 @@ sourceSchemas:
}
interactions:
- request:
- accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8
- kind: OperationBatch
- items:
- - document: |
- query Op_b05a9c48_3($__fusion_3_id: ID!) {
- productById(id: $__fusion_3_id) {
- nullableObject {
- field
- }
- }
- }
- variables: |
- {
- "__fusion_3_id": "1"
- }
- - document: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- variables: |
- {
- "__fusion_4_id": "1"
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_b05a9c48_3($__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ nullableObject {
+ field
}
+ }
+ }
+ variables: |
+ {
+ "__fusion_3_id": "1"
+ }
response:
- contentType: application/jsonl; charset=utf-8
results:
- |
{
@@ -74,14 +61,6 @@ sourceSchemas:
}
}
}
- - |
- {
- "data": {
- "productById": {
- "id": "1"
- }
- }
- }
- name: B
schema: |
schema {
@@ -184,7 +163,8 @@ operationPlan:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: A
@@ -198,28 +178,9 @@ operationPlan:
}
source: $.productById
target: $.productById
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: A
- operation: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.productById
- target: $.productById
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object_Returning_Null.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object_Returning_Null.yaml
index ff9d27467ff..44ef2f9a37f 100644
--- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object_Returning_Null.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Requirement_On_Property_Within_Nullable_Object_Returning_Null.yaml
@@ -36,33 +36,20 @@ sourceSchemas:
}
interactions:
- request:
- accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8
- kind: OperationBatch
- items:
- - document: |
- query Op_b05a9c48_3($__fusion_3_id: ID!) {
- productById(id: $__fusion_3_id) {
- nullableObject {
- field
- }
- }
- }
- variables: |
- {
- "__fusion_3_id": "1"
- }
- - document: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- variables: |
- {
- "__fusion_4_id": "1"
+ accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8
+ document: |
+ query Op_b05a9c48_3($__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ nullableObject {
+ field
}
+ }
+ }
+ variables: |
+ {
+ "__fusion_3_id": "1"
+ }
response:
- contentType: application/jsonl; charset=utf-8
results:
- |
{
@@ -72,14 +59,6 @@ sourceSchemas:
}
}
}
- - |
- {
- "data": {
- "productById": {
- "id": "1"
- }
- }
- }
- name: B
schema: |
schema {
@@ -182,7 +161,8 @@ operationPlan:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: A
@@ -196,28 +176,9 @@ operationPlan:
}
source: $.productById
target: $.productById
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: A
- operation: |
- query Op_b05a9c48_4($__fusion_4_id: ID!) {
- productById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.productById
- target: $.productById
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs
index b3fda06eb57..2ea12b1a856 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs
@@ -1023,6 +1023,58 @@ ... @defer(label: "location") {
g => Assert.Same(parsedPlan.DeliveryGroups.Single(d => d.Id == g.Parent!.Id), g.Parent));
}
+ [Fact]
+ public void Parse_Plan_Preserves_NoParentNodeId_When_RootPlanHasNoExecutionNode()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ }
+ """);
+
+ var originalPlan = PlanOperation(
+ schema,
+ """
+ {
+ ... @defer {
+ users {
+ id
+ }
+ }
+ }
+ """);
+
+ using var buffer = new PooledArrayWriter();
+ var formatter = new JsonOperationPlanFormatter(
+ new JsonWriterOptions
+ {
+ Indented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ });
+ formatter.Format(buffer, originalPlan);
+
+ // act
+ var compiler = new OperationCompiler(
+ schema,
+ new DefaultObjectPool>>(
+ new DefaultPooledObjectPolicy>>()));
+ var parser = new JsonOperationPlanParser(compiler);
+ var parsedPlan = parser.Parse(buffer.WrittenMemory);
+
+ // assert
+ Encoding.UTF8.GetString(buffer.WrittenSpan).MatchSnapshot();
+ Assert.Empty(parsedPlan.AllNodes);
+ Assert.Equal(
+ IncrementalPlan.NoParentNodeId,
+ Assert.Single(parsedPlan.IncrementalPlans).ParentNodeId);
+ }
+
[Fact]
public void Parse_Plan_Preserves_ParentDependencies_On_Deferred_IncrementalPlan_Nodes()
{
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap
index d82db577fe0..36b6f69b92d 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap
@@ -1,5 +1,5 @@
{
- "version": "1.0.0",
+ "version": "1.1.0",
"id": "7ccc4b3cd883b1588a4115c02422819f7d1c38558717d2caf7ea8688f97a6de3",
"operation": {
"kind": "Query",
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_NoParentNodeId_When_RootPlanHasNoExecutionNode.snap b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_NoParentNodeId_When_RootPlanHasNoExecutionNode.snap
new file mode 100644
index 00000000000..afc58a4dc67
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_NoParentNodeId_When_RootPlanHasNoExecutionNode.snap
@@ -0,0 +1,51 @@
+{
+ "version": "1.1.0",
+ "id": "e3bfdf27a7447da7a44fc86a7c355ebfe8fb39a1032110dfefda270dc6b969ba",
+ "operation": {
+ "kind": "Query",
+ "document": "{\n __typename @fusion__empty\n}",
+ "id": "123456789101112",
+ "hash": "123456789101112",
+ "shortHash": "123456789101112"
+ },
+ "searchSpace": 1,
+ "expandedNodes": 1,
+ "nodes": [],
+ "deliveryGroups": [
+ {
+ "id": 0,
+ "path": "$"
+ }
+ ],
+ "incrementalPlans": [
+ {
+ "deliveryGroupIds": [
+ 0
+ ],
+ "parentNodeId": 0,
+ "operation": {
+ "kind": "Query",
+ "document": "{\n users {\n id\n }\n}",
+ "id": "123456789101112#defer_0",
+ "hash": "123456789101112#defer_0",
+ "shortHash": "123456789101112"
+ },
+ "nodes": [
+ {
+ "id": 1,
+ "type": "Operation",
+ "schema": "a",
+ "operation": {
+ "name": "Op_defer_1",
+ "kind": "Query",
+ "document": "query Op_defer_1 {\n users {\n id\n }\n}",
+ "hash": "2cc3b5a55fdfdf80b9984b175e1cae7bc21de0a605d4647d549c5ed435bea939",
+ "shortHash": "2cc3b5a5",
+ "xxHash": 2175018508951606729
+ },
+ "resultSelectionSet": "{ users }"
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs
index 16c0fdc1bdf..6e678c8be35 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs
@@ -1,4 +1,8 @@
using HotChocolate.Fusion.Execution.Nodes;
+using HotChocolate.Fusion.Logging;
+using HotChocolate.Fusion.Options;
+using HotChocolate.Fusion.Types;
+using HotChocolate.Language;
namespace HotChocolate.Fusion.Planning;
@@ -2155,6 +2159,311 @@ ... @defer {
MatchSnapshot(plan);
}
+ [Theory]
+ [InlineData("__typename")]
+ [InlineData("")]
+ public void Defer_RootFragment_Should_PreserveDeferredFetch_When_InitialSelectionHasNoSourceFetch(
+ string initialSelection)
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ name: String!
+ }
+ """);
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ $$"""
+ {
+ {{initialSelection}}
+ ... @defer {
+ users {
+ id
+ }
+ }
+ }
+ """);
+
+ // assert
+ var incrementalPlan = Assert.Single(plan.IncrementalPlans);
+ Assert.Equal("$", Assert.Single(plan.DeliveryGroups).Path!.ToString());
+ Assert.Empty(plan.AllNodes.OfType());
+ GetOperationSelectionSets(incrementalPlan.AllNodes).MatchInlineSnapshots(
+ [
+ """
+ {
+ users {
+ id
+ }
+ }
+ """
+ ]);
+ }
+
+ [Fact]
+ public void Defer_RootFragment_Should_AnchorDeferredPlanAtIntrospectionNode_When_RootSelectsOnlyTypename()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ }
+ """);
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ {
+ __typename
+ ... @defer {
+ users {
+ id
+ }
+ }
+ }
+ """);
+
+ // assert
+ var incrementalPlan = Assert.Single(plan.IncrementalPlans);
+ var parent = plan.GetNodeById(incrementalPlan.ParentNodeId);
+ Assert.IsType(parent);
+ Assert.Same(Assert.Single(plan.AllNodes), parent);
+ }
+
+ [Fact]
+ public void Defer_RootFragment_Should_HaveNoParentNode_When_RootPlanHasNoExecutionNode()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ type Query {
+ users: [User!]!
+ }
+
+ type User {
+ id: ID!
+ }
+ """);
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ {
+ ... @defer {
+ users {
+ id
+ }
+ }
+ }
+ """);
+
+ // assert
+ var incrementalPlan = Assert.Single(plan.IncrementalPlans);
+ Assert.Empty(plan.AllNodes);
+ Assert.Equal(IncrementalPlan.NoParentNodeId, incrementalPlan.ParentNodeId);
+ }
+
+ [Fact]
+ public void Defer_NodeField_Should_PlanDeferredTypeBranch_When_FieldIsNotAliased()
+ {
+ // arrange
+ var schema = CreateNodeDeferSchema();
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ {
+ node(id: "1") {
+ id
+ ... @defer {
+ ... on User {
+ name
+ }
+ }
+ }
+ }
+ """);
+
+ // assert
+ var incrementalPlan = Assert.Single(plan.IncrementalPlans);
+ Assert.Equal("$.node", Assert.Single(incrementalPlan.DeliveryGroups).Path!.ToString());
+ GetOperationSelectionSets(incrementalPlan.AllNodes).MatchInlineSnapshots(
+ [
+ """
+ {
+ node(id: "1") {
+ __typename
+ }
+ }
+ """,
+ """
+ {
+ node(id: "1") {
+ __typename
+ ... on User {
+ __typename
+ name
+ }
+ }
+ }
+ """
+ ]);
+ }
+
+ [Fact]
+ public void Defer_NodeField_Should_PlanDeferredTypeBranch_When_FieldIsAliased()
+ {
+ // arrange
+ var schema = CreateNodeDeferSchema();
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ {
+ n: node(id: "1") {
+ id
+ ... @defer {
+ ... on User {
+ name
+ }
+ }
+ }
+ }
+ """);
+
+ // assert
+ var incrementalPlan = Assert.Single(plan.IncrementalPlans);
+ Assert.Equal("$.n", Assert.Single(incrementalPlan.DeliveryGroups).Path!.ToString());
+ GetOperationSelectionSets(incrementalPlan.AllNodes).MatchInlineSnapshots(
+ [
+ """
+ {
+ n: node(id: "1") {
+ __typename
+ }
+ }
+ """,
+ """
+ {
+ n: node(id: "1") {
+ __typename
+ ... on User {
+ __typename
+ name
+ }
+ }
+ }
+ """
+ ]);
+ }
+
+ [Fact]
+ public void Defer_Should_ThrowPlannerError_When_DeferredFieldHasNoReachableSource()
+ {
+ // arrange
+ var options = new SchemaComposerOptions();
+ options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects = true;
+
+ var result = new SchemaComposer(
+ [
+ new SourceSchemaText(
+ "a",
+ """
+ extend schema
+ @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"])
+
+ type Query {
+ a: Node
+ }
+
+ interface Node @key(fields: "id") {
+ id: ID!
+ }
+ """),
+ new SourceSchemaText(
+ "b",
+ """
+ extend schema
+ @link(
+ url: "https://specs.apollo.dev/federation/v2.6"
+ import: ["@key", "@interfaceObject"])
+
+ type Query {
+ b: Node
+ }
+
+ type Node @key(fields: "id", resolvable: false) @interfaceObject {
+ id: ID!
+ field: String
+ }
+ """)
+ ],
+ options,
+ new CompositionLog()).Compose();
+ var schema = FusionSchemaDefinition.Create(result.Value.ToSyntaxNode());
+
+ // act
+ var error = Assert.Throws(() => PlanOperation(
+ schema,
+ """
+ {
+ __typename
+ ... @defer {
+ a {
+ field
+ }
+ }
+ }
+ """));
+
+ // assert
+ Assert.Equal("No plan was found for the @defer fragment at path '$'.", error.Message);
+ }
+
+ private static FusionSchemaDefinition CreateNodeDeferSchema()
+ => ComposeSchema(
+ """
+ type Query {
+ node(id: ID!): Node @lookup
+ userById(id: ID!): User @lookup
+ users: [User!]!
+ }
+
+ interface Node {
+ id: ID!
+ }
+
+ type User implements Node {
+ id: ID!
+ name: String!
+ }
+ """);
+
+ private static string[] GetOperationSelectionSets(IEnumerable nodes)
+ => nodes
+ .OfType()
+ .Select(node => Utf8GraphQLParser.Parse(node.Operation.Value.Span)
+ .Definitions.OfType().Single()
+ .SelectionSet.ToString())
+ .ToArray();
+
[Fact]
public void Defer_KeyOnlyField_Should_NotDefer_When_OnlyReachableLookupIsSelfCyclic()
{
@@ -2503,4 +2812,138 @@ ... @defer {
}
Assert.True(dependsOnProvider);
}
+
+ [Fact]
+ public void Defer_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ # name: a
+ type Query {
+ product: Product
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ raw: Int!
+ computed(value: Int! @require(field: "value")): Int!
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ value: Int!
+ }
+ """,
+ """
+ # name: c
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ total(input: TotalInput! @require(field: "{ raw computed }")): Int!
+ }
+
+ input TotalInput {
+ raw: Int!
+ computed: Int!
+ }
+ """);
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ query {
+ product {
+ id
+ ... @defer {
+ total
+ }
+ }
+ }
+ """);
+
+ // assert
+ MatchSnapshot(plan);
+ }
+
+ [Fact]
+ public void Defer_Should_FetchAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ # name: a
+ type Query {
+ product: Product
+ productById(id: ID!): Product @lookup @internal
+ userById(id: ID!): User @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ owner: User!
+ }
+
+ type User @key(fields: "id") {
+ id: ID!
+ raw: Int!
+ computed(value: Int! @require(field: "value")): Int!
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ userById(id: ID!): User @lookup @internal
+ }
+
+ type User @key(fields: "id") {
+ id: ID!
+ value: Int!
+ }
+ """,
+ """
+ # name: c
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ total(input: OwnerInput! @require(field: "owner.{ raw computed }")): Int!
+ }
+
+ input OwnerInput {
+ raw: Int!
+ computed: Int!
+ }
+ """);
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ query {
+ product {
+ id
+ ... @defer {
+ total
+ }
+ }
+ }
+ """);
+
+ // assert
+ MatchSnapshot(plan);
+ }
}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlanStepTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlanStepTests.cs
new file mode 100644
index 00000000000..39109224df2
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlanStepTests.cs
@@ -0,0 +1,133 @@
+using System.Collections.Immutable;
+using HotChocolate.Execution;
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion.Planning;
+
+public class OperationPlanStepTests : FusionTestBase
+{
+ [Fact]
+ public void DependsOn_Should_ReturnTrue_When_OtherStepListsStepAsDependent()
+ {
+ // arrange
+ var steps = CreateSteps(2, (1, [2]));
+
+ // act
+ var dependsOn = Step(steps, 2).DependsOn(Step(steps, 1), steps);
+ var reverse = Step(steps, 1).DependsOn(Step(steps, 2), steps);
+
+ // assert
+ Assert.True(dependsOn);
+ Assert.False(reverse);
+ }
+
+ [Fact]
+ public void DependsOn_Should_ReturnTrue_When_StepIsReachableThroughDependents()
+ {
+ // arrange
+ var steps = CreateSteps(4, (1, [2]), (2, [3]), (3, [4]));
+
+ // act
+ var dependsOn = Step(steps, 4).DependsOn(Step(steps, 1), steps);
+
+ // assert
+ Assert.True(dependsOn);
+ }
+
+ [Fact]
+ public void DependsOn_Should_ReturnFalse_When_StepIsNotReachableThroughDependents()
+ {
+ // arrange
+ var steps = CreateSteps(4, (1, [2]), (3, [4]));
+
+ // act
+ var dependsOn = Step(steps, 4).DependsOn(Step(steps, 1), steps);
+
+ // assert
+ Assert.False(dependsOn);
+ }
+
+ [Fact]
+ public void DependsOn_Should_ReturnFalse_When_DependentsFormCycleWithoutReachingStep()
+ {
+ // arrange
+ var steps = CreateSteps(3, (1, [2]), (2, [1]));
+
+ // act
+ var dependsOn = Step(steps, 3).DependsOn(Step(steps, 1), steps);
+
+ // assert
+ Assert.False(dependsOn);
+ }
+
+ [Theory]
+ [InlineData(70)]
+ [InlineData(2100)]
+ public void DependsOn_Should_ReturnTrue_When_DependencyChainIsLong(int length)
+ {
+ // arrange
+ var edges = Enumerable.Range(1, length - 1)
+ .Select(id => (id, new[] { id + 1 }))
+ .ToArray();
+ var steps = CreateSteps(length, edges);
+
+ // act
+ var dependsOn = Step(steps, length).DependsOn(Step(steps, 1), steps);
+ var reverse = Step(steps, 1).DependsOn(Step(steps, length), steps);
+
+ // assert
+ Assert.True(dependsOn);
+ Assert.False(reverse);
+ }
+
+ private static OperationPlanStep Step(ImmutableList steps, int id)
+ => (OperationPlanStep)steps[id - 1];
+
+ private static ImmutableList CreateSteps(
+ int count,
+ params (int Id, int[] Dependents)[] edges)
+ {
+ var schema = ComposeSchema(
+ """
+ # name: a
+ schema {
+ query: Query
+ }
+
+ type Query {
+ field: Int
+ }
+ """);
+
+ var definition = Utf8GraphQLParser
+ .Parse("{ field }")
+ .Definitions
+ .OfType()
+ .Single();
+ var type = schema.Types["Query"];
+ var dependents = edges.ToDictionary(e => e.Id, e => e.Dependents);
+
+ var steps = ImmutableList.CreateBuilder();
+
+ for (var id = 1; id <= count; id++)
+ {
+ steps.Add(
+ new OperationPlanStep
+ {
+ Id = id,
+ Definition = definition,
+ Type = type,
+ RootSelectionSetId = 1,
+ SelectionSets = [1],
+ SchemaName = "a",
+ Target = SelectionPath.Root,
+ Source = SelectionPath.Root,
+ Dependents = dependents.TryGetValue(id, out var ids)
+ ? [.. ids]
+ : []
+ });
+ }
+
+ return steps.ToImmutable();
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs
index 84ec2e8b0de..b3c44451e26 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs
@@ -1,6 +1,9 @@
using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
using HotChocolate.Execution;
using HotChocolate.Fusion.Execution.Nodes;
+using HotChocolate.Fusion.Types;
+using HotChocolate.Fusion.Types.Metadata;
using HotChocolate.Language;
using Microsoft.Extensions.ObjectPool;
@@ -131,6 +134,313 @@ public void RemainingCost_Projects_ExcessFanout_For_EqualOperationFloor()
Assert.True(excessiveFanoutCost > moderateFanoutCost);
}
+ [Theory]
+ [InlineData(
+ 1.5,
+ Skip = "Requires the admissible pruning bound, which lands together with the search-space reduction.")]
+ [InlineData(10.0)]
+ public void RemainingCost_Should_NotExceedCompletionCost_When_OneOperationRemains(
+ double operationWeight)
+ {
+ // arrange
+ var options = new OperationPlannerOptions { OperationWeight = operationWeight };
+ var backlogCost = CreateBacklogCost(1);
+ var completed = CreateNode(1, 1, 0, options);
+
+ // act
+ var remainingCost = PlannerCostEstimator.EstimateRemainingCost(
+ options,
+ currentMaxDepth: 0,
+#if NET10_0_OR_GREATER
+ [],
+#else
+ ImmutableDictionary.Empty,
+#endif
+ backlogCost);
+
+ // assert
+ Assert.True(
+ remainingCost <= completed.PathCost,
+ $"Remaining cost {remainingCost} exceeds completion cost {completed.PathCost}.");
+ }
+
+ [Theory]
+ [InlineData(
+ 1.5,
+ Skip = "Requires the admissible pruning bound, which lands together with the search-space reduction.")]
+ [InlineData(10.0)]
+ public void CreatePlan_Should_ChooseTwoRootFetches_When_GreedyCoverNeedsThree(
+ double operationWeight)
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ # name: a
+ type Query {
+ f1: Int @shareable
+ f2: Int @shareable
+ f3: Int @shareable
+ f4: Int @shareable
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ f1: Int @shareable
+ f2: Int @shareable
+ f5: Int
+ }
+ """,
+ """
+ # name: c
+ type Query {
+ f3: Int @shareable
+ f4: Int @shareable
+ f6: Int
+ }
+ """);
+ var options = new OperationPlannerOptions { OperationWeight = operationWeight };
+
+ // act
+ var plan = PlanOperation(schema, "{ f1 f2 f3 f4 f5 f6 }", options);
+
+ // assert
+ plan.AllNodes
+ .Cast()
+ .OrderBy(node => node.SchemaName, StringComparer.Ordinal)
+ .Select(node => new
+ {
+ node.SchemaName,
+ Fields = Utf8GraphQLParser.Parse(node.Operation.Value.Span)
+ .GetOperation(operationName: null)
+ .SelectionSet.Selections
+ .Cast()
+ .Select(field => field.Name.Value)
+ .ToArray(),
+ DependencyCount = node.Dependencies.Length
+ })
+ .ToArray()
+ .MatchInlineSnapshot(
+ """
+ [
+ {
+ "SchemaName": "b",
+ "Fields": [
+ "f1",
+ "f2",
+ "f5"
+ ],
+ "DependencyCount": 0
+ },
+ {
+ "SchemaName": "c",
+ "Fields": [
+ "f3",
+ "f4",
+ "f6"
+ ],
+ "DependencyCount": 0
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_ApplySchemaTieBreak_When_GreedyPlanHasEqualCost()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ # name: a
+ type Query {
+ a: Int
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ b: Int
+ c: Int
+ }
+ """);
+ var options = new OperationPlannerOptions { OperationWeight = 10.0 };
+
+ // act
+ var plan = PlanOperation(schema, "{ a b c }", options);
+
+ // assert
+ plan.AllNodes.Select(node => node.SchemaName).MatchInlineSnapshots(["a", "b"]);
+ }
+
+ [Fact]
+ public void ComparePlansForTieBreak_Should_PreferFewerSurvivingSteps_When_CostsTie()
+ {
+ // arrange
+ var schema = CreateCompositeSchema();
+ var plan1 = CreateTieNode(
+ CreateStep(1, "a", "query Test { a }", schema, lookup: null, SelectionPath.Root, SelectionPath.Root, []),
+ CreateStep(2, "z", "query Test { __typename @fusion__empty }", schema, lookup: null, SelectionPath.Root, SelectionPath.Root, []));
+ var plan2 = CreateTieNode(
+ CreateStep(1, "a", "query Test { a }", schema, lookup: null, SelectionPath.Root, SelectionPath.Root, []),
+ CreateStep(2, "a", "query Test { b }", schema, lookup: null, SelectionPath.Root, SelectionPath.Root, []));
+ var cached = -1;
+
+ // act
+ var comparison = OperationPlanner.ComparePlansForTieBreak(plan1, plan2, ref cached);
+ cached = -1;
+ var reverseComparison = OperationPlanner.ComparePlansForTieBreak(plan2, plan1, ref cached);
+
+ // assert
+ Assert.Equal(-1, Math.Sign(comparison));
+ Assert.Equal(1, Math.Sign(reverseComparison));
+ }
+
+ [Fact]
+ public void ComparePlansForTieBreak_Should_PreferFewerSurvivingSteps_When_KeyOnlyLookupIsRemoved()
+ {
+ // arrange
+ var schema = ComposeProductSchema();
+ var lookup = GetProductByIdLookup(schema);
+ var plan1 = CreateTieNode(
+ CreateStep(1, "a", "query Test { product { id } }", schema, null, SelectionPath.Root, SelectionPath.Root, [2]),
+ CreateStep(
+ 2,
+ "b",
+ "query Test($x: ID!) { productById(id: $x) { id } }",
+ schema,
+ lookup,
+ SelectionPath.Parse("$.product"),
+ SelectionPath.Parse("$.productById"),
+ []));
+ var plan2 = CreateTieNode(
+ CreateStep(1, "a", "query Test { product { id } }", schema, null, SelectionPath.Root, SelectionPath.Root, [2]),
+ CreateStep(
+ 2,
+ "b",
+ "query Test($x: ID!) { productById(id: $x) { name } }",
+ schema,
+ lookup,
+ SelectionPath.Parse("$.product"),
+ SelectionPath.Parse("$.productById"),
+ []));
+ var cached = -1;
+
+ // act
+ var comparison = OperationPlanner.ComparePlansForTieBreak(plan1, plan2, ref cached);
+ cached = -1;
+ var reverseComparison = OperationPlanner.ComparePlansForTieBreak(plan2, plan1, ref cached);
+
+ // assert
+ Assert.Equal(-1, Math.Sign(comparison));
+ Assert.Equal(1, Math.Sign(reverseComparison));
+ }
+
+ [Fact]
+ public void ComparePlansForTieBreak_Should_PreferFewerSurvivingSteps_When_SkipGatedKeyOnlyLookupIsRemoved()
+ {
+ // arrange
+ var schema = ComposeProductSchema();
+ var lookup = GetProductByIdLookup(schema);
+ var plan1 = CreateTieNode(
+ CreateStep(1, "a", "query Test { product { id } }", schema, null, SelectionPath.Root, SelectionPath.Root, [2]),
+ CreateStep(
+ 2,
+ "b",
+ "query Test($x: ID!, $s: Boolean!) { productById(id: $x) { id @skip(if: $s) } }",
+ schema,
+ lookup,
+ SelectionPath.Parse("$.product"),
+ SelectionPath.Parse("$.productById"),
+ []));
+ var plan2 = CreateTieNode(
+ CreateStep(1, "a", "query Test { product { id } }", schema, null, SelectionPath.Root, SelectionPath.Root, [2]),
+ CreateStep(
+ 2,
+ "b",
+ "query Test($x: ID!) { productById(id: $x) { name } }",
+ schema,
+ lookup,
+ SelectionPath.Parse("$.product"),
+ SelectionPath.Parse("$.productById"),
+ []));
+ var cached = -1;
+
+ // act
+ var comparison = OperationPlanner.ComparePlansForTieBreak(plan1, plan2, ref cached);
+ cached = -1;
+ var reverseComparison = OperationPlanner.ComparePlansForTieBreak(plan2, plan1, ref cached);
+
+ // assert
+ Assert.Equal(-1, Math.Sign(comparison));
+ Assert.Equal(1, Math.Sign(reverseComparison));
+ }
+
+ private static FusionSchemaDefinition ComposeProductSchema()
+ {
+ return ComposeSchema(
+ """
+ # name: a
+ type Query {
+ product: Product
+ }
+
+ type Product {
+ id: ID! @shareable
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ productById(id: ID!): Product @lookup
+ }
+
+ type Product @key(fields: "id") {
+ id: ID! @shareable
+ name: String
+ }
+ """);
+ }
+
+ private static Lookup GetProductByIdLookup(FusionSchemaDefinition schema)
+ {
+ return schema.Types.GetType("Product").Sources["b"].Lookups[0];
+ }
+
+ private static PlanNode CreateTieNode(params PlanStep[] steps)
+ {
+ var node = CreateNode(maxDepth: 1, operationStepCount: steps.Length, excessFanout: 0);
+ return node with { Steps = [.. steps] };
+ }
+
+ private static OperationPlanStep CreateStep(
+ int id,
+ string schemaName,
+ [StringSyntax("graphql")] string operation,
+ FusionSchemaDefinition schema,
+ Lookup? lookup,
+ SelectionPath target,
+ SelectionPath source,
+ ImmutableHashSet dependents)
+ {
+ return new OperationPlanStep
+ {
+ Id = id,
+ Definition = Utf8GraphQLParser
+ .Parse(operation)
+ .Definitions
+ .OfType()
+ .Single(),
+ Type = schema.QueryType,
+ RootSelectionSetId = 0,
+ SelectionSets = [],
+ SchemaName = schemaName,
+ Target = target,
+ Source = source,
+ Lookup = lookup,
+ Dependents = dependents
+ };
+ }
+
private static PlanNode CreateNode(
int maxDepth,
int operationStepCount,
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs
index fdc34c9d502..e3ae6c83c12 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs
@@ -122,6 +122,259 @@ public void CreatePlan_Throws_When_MaxGeneratedOptions_Guardrail_Is_Exceeded()
Assert.True(error.Observed > error.Limit);
}
+ [Fact]
+ public void CreatePlan_Should_Throw_When_GreedyExpansionExceedsMaxExpandedNodes()
+ {
+ // arrange
+ var planner = CreatePlanner(
+ CreateSerialMutationSchema(),
+ new OperationPlannerOptions { MaxExpandedNodes = 1 });
+ var operation = ParseOperation(SerialMutationText);
+
+ // act
+ var error = Assert.Throws(
+ () => planner.CreatePlan(
+ "guardrail-greedy-expanded",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken));
+
+ // assert
+ Assert.Equal(OperationPlannerGuardrailReason.MaxExpandedNodesExceeded, error.Reason);
+ Assert.Equal(1, error.Limit);
+ Assert.Equal(2, error.Observed);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_PlanSerialMutations_When_ExpansionBudgetIsSufficient()
+ {
+ // arrange
+ var planner = CreatePlanner(
+ CreateSerialMutationSchema(),
+ new OperationPlannerOptions { MaxExpandedNodes = 32 });
+ var operation = ParseOperation(SerialMutationText);
+
+ // act
+ var plan = planner.CreatePlan(
+ "guardrail-greedy-expanded-control",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken);
+
+ // assert
+ Assert.Equal(3, plan.AllNodes.OfType().Count());
+ }
+
+ [Fact]
+ public void CreatePlan_Should_Throw_When_GreedyAndSearchExpansionsTogetherExceedMaxExpandedNodes()
+ {
+ // arrange
+ // the limit equals the nodes the main search expands on its own, so only the greedy expansions can exceed it
+ var schema = CreateSerialMutationSchema();
+ var operation = ParseOperation(SerialMutationText);
+ var searchExpandedNodes = CreatePlanner(schema, new OperationPlannerOptions())
+ .CreatePlan(
+ "guardrail-greedy-shared-baseline",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken)
+ .ExpandedNodes;
+ var planner = CreatePlanner(
+ schema,
+ new OperationPlannerOptions { MaxExpandedNodes = searchExpandedNodes });
+
+ // act
+ var error = Assert.Throws(
+ () => planner.CreatePlan(
+ "guardrail-greedy-shared",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken));
+
+ // assert
+ Assert.Equal(OperationPlannerGuardrailReason.MaxExpandedNodesExceeded, error.Reason);
+ Assert.Equal(searchExpandedNodes, error.Limit);
+ Assert.Equal(searchExpandedNodes + 1, error.Observed);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_Throw_When_InitialQueueExceedsMaxQueueSizeByOne()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ # name: a
+ type Query { value: Int @shareable }
+ """,
+ """
+ # name: b
+ type Query { value: Int @shareable }
+ """);
+ var planner = CreatePlanner(schema, new OperationPlannerOptions { MaxQueueSize = 1 });
+ var operation = ParseOperation("{ value }");
+
+ // act
+ var error = Assert.Throws(
+ () => planner.CreatePlan(
+ "guardrail-initial-queue",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken));
+
+ // assert
+ Assert.Equal(OperationPlannerGuardrailReason.MaxQueueSizeExceeded, error.Reason);
+ Assert.Equal(1, error.Limit);
+ Assert.Equal(2, error.Observed);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_Throw_When_GreedyExpansionExceedsMaxGeneratedOptions()
+ {
+ // arrange
+ var schema = ComposeSchema(
+ """
+ # name: a
+ type Query {
+ f1: Int @shareable
+ f2: Int @shareable
+ f3: Int @shareable
+ f4: Int @shareable
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ f1: Int @shareable
+ f2: Int @shareable
+ f5: Int
+ }
+ """,
+ """
+ # name: c
+ type Query {
+ f3: Int @shareable
+ f4: Int @shareable
+ f6: Int
+ }
+ """);
+ var planner = CreatePlanner(
+ schema,
+ new OperationPlannerOptions { MaxGeneratedOptionsPerWorkItem = 1 });
+ var operation = ParseOperation("{ f1 f2 f3 f4 f5 f6 }");
+
+ // act
+ var error = Assert.Throws(
+ () => planner.CreatePlan(
+ "guardrail-greedy-generated",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken));
+
+ // assert
+ Assert.Equal(
+ OperationPlannerGuardrailReason.MaxGeneratedOptionsPerWorkItemExceeded,
+ error.Reason);
+ Assert.Equal(1, error.Limit);
+ Assert.Equal(2, error.Observed);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_Throw_When_DeferredPlansTogetherExceedMaxExpandedNodes()
+ {
+ // arrange
+ var planner = CreatePlanner(
+ CreateDeferredRootSchema(),
+ new OperationPlannerOptions { EnableDefer = true, MaxExpandedNodes = 4 });
+ var operation = ParseOperation(DeferredRootOperationText);
+
+ // act
+ var error = Assert.Throws(
+ () => planner.CreatePlan(
+ "guardrail-deferred-expanded",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken));
+
+ // assert
+ Assert.Equal(OperationPlannerGuardrailReason.MaxExpandedNodesExceeded, error.Reason);
+ Assert.Equal(4, error.Limit);
+ Assert.Equal(5, error.Observed);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_PlanDeferredGroups_When_ExpansionBudgetIsSufficient()
+ {
+ // arrange
+ var planner = CreatePlanner(
+ CreateDeferredRootSchema(),
+ new OperationPlannerOptions { EnableDefer = true, MaxExpandedNodes = 32 });
+ var operation = ParseOperation(DeferredRootOperationText);
+
+ // act
+ var plan = planner.CreatePlan(
+ "guardrail-deferred-expanded-control",
+ "hash",
+ "12345678",
+ operation,
+ TestContext.Current.CancellationToken);
+
+ // assert
+ Assert.Single(plan.AllNodes.OfType());
+ Assert.Equal(5, plan.IncrementalPlans.Length);
+ }
+
+ private static FusionSchemaDefinition CreateSerialMutationSchema()
+ => ComposeSchema(
+ """
+ # name: a
+ type Query { a: Int }
+ type Mutation { first: Int }
+ """,
+ """
+ # name: b
+ type Query { b: Int }
+ type Mutation { second: Int }
+ """,
+ """
+ # name: c
+ type Query { c: Int }
+ type Mutation { third: Int }
+ """);
+
+ private const string SerialMutationText = "mutation { first second third }";
+
+ private static FusionSchemaDefinition CreateDeferredRootSchema()
+ => ComposeSchema(
+ """
+ type Query {
+ immediate: Int
+ first: Int
+ second: Int
+ third: Int
+ fourth: Int
+ fifth: Int
+ }
+ """);
+
+ private const string DeferredRootOperationText =
+ """
+ {
+ immediate
+ ... @defer(label: "first") { first }
+ ... @defer(label: "second") { second }
+ ... @defer(label: "third") { third }
+ ... @defer(label: "fourth") { fourth }
+ ... @defer(label: "fifth") { fifth }
+ }
+ """;
+
private static OperationPlanner CreatePlanner(
FusionSchemaDefinition schema,
OperationPlannerOptions options)
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementChainTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementChainTests.cs
index 0cef4f4705d..b6818ef6510 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementChainTests.cs
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementChainTests.cs
@@ -54,6 +54,37 @@ public void Requires_Requires_Many()
MatchSnapshot(plan);
}
+ [Fact]
+ public void CreatePlan_Should_AliasMergedLookupFields_When_RequirementVariablesDiffer()
+ {
+ // arrange
+ var schema = CreateRequiresRequiresSchema();
+ var options = new OperationPlannerOptions { OperationWeight = 100.0 };
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ query {
+ product {
+ id
+ price
+ hasDiscount
+ isExpensive
+ isExpensiveWithDiscount
+ canAfford
+ canAfford2
+ canAffordWithDiscount
+ canAffordWithDiscount2
+ }
+ }
+ """,
+ options);
+
+ // assert
+ MatchSnapshot(plan);
+ }
+
[Fact]
public void Requires_Requires_Two_Fields_Same_Requirement_Different_Order()
{
@@ -118,6 +149,146 @@ public void Requires_Circular_2()
MatchSnapshot(plan);
}
+ [Fact]
+ public void CreatePlan_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields()
+ {
+ // arrange
+ var schema = CreateNativeAndComputedRequirementSchema(includeRaw: true);
+
+ // act
+ var plan = PlanOperation(schema, "{ product { total } }");
+
+ // assert
+ MatchSnapshot(plan);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_FetchAllPrerequisites_When_RequirementContainsOnlyComputedField()
+ {
+ // arrange
+ var schema = CreateNativeAndComputedRequirementSchema(includeRaw: false);
+
+ // act
+ var plan = PlanOperation(schema, "{ product { total } }");
+
+ // assert
+ MatchSnapshot(plan);
+ }
+
+ [Fact]
+ public void CreatePlan_Should_NotFetchEntryKeyAgain_When_LookupRequirementIsOnlyComputed()
+ {
+ // arrange
+ var schema = CreateComputedOnlyRequirementSchema();
+
+ // act
+ var plan = PlanOperation(schema, "{ product { onlyComputed } }");
+
+ // assert
+ MatchSnapshot(plan);
+ }
+
+ private static FusionSchemaDefinition CreateNativeAndComputedRequirementSchema(bool includeRaw)
+ {
+ return ComposeSchema(
+ """
+ # name: a
+ type Query {
+ product: Product
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ raw: Int!
+ computed(value: Int! @require(field: "value")): Int!
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ value: Int!
+ }
+ """,
+ includeRaw
+ ? """
+ # name: c
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ total(input: TotalInput! @require(field: "{ raw computed }")): Int!
+ }
+
+ input TotalInput {
+ raw: Int!
+ computed: Int!
+ }
+ """
+ : """
+ # name: c
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ total(input: TotalInput! @require(field: "{ computed }")): Int!
+ }
+
+ input TotalInput {
+ computed: Int!
+ }
+ """);
+ }
+
+ private static FusionSchemaDefinition CreateComputedOnlyRequirementSchema()
+ {
+ return ComposeSchema(
+ """
+ # name: a
+ type Query {
+ product: Product
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ raw: Int!
+ computed(value: Int! @require(field: "value")): Int!
+ }
+ """,
+ """
+ # name: b
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ value: Int!
+ }
+ """,
+ """
+ # name: c
+ type Query {
+ productById(id: ID!): Product @lookup @internal
+ }
+
+ type Product @key(fields: "id") {
+ id: ID!
+ onlyComputed(computed: Int! @require(field: "computed")): Int!
+ }
+ """);
+ }
+
private static FusionSchemaDefinition CreateRequiresRequiresSchema()
{
return ComposeSchema(
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml
new file mode 100644
index 00000000000..26e1b353692
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml
@@ -0,0 +1,117 @@
+operation:
+ - document: |
+ {
+ product {
+ id
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 1
+ expandedNodes: 1
+nodes:
+ - id: 1
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_1 {
+ product {
+ id
+ }
+ }
+deliveryGroups:
+ - id: 0
+ path: $.product
+incrementalPlans:
+ - deliveryGroupIds:
+ - 0
+ parentNodeId: 1
+ nodes:
+ - id: 2
+ type: Operation
+ schema: c
+ operation: |
+ query Op_defer_2($__fusion_2_input: TotalInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_2_input
+ selectionMap: >-
+ {
+ raw
+ computed
+ }
+ - name: __fusion_3_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 4
+ - id: 7
+ - id: 3
+ type: Operation
+ schema: a
+ operation: |
+ query Op_defer_3($__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ raw
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_4_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 7
+ - id: 4
+ type: Operation
+ schema: a
+ operation: |
+ query Op_defer_4($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ productById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_5_value
+ selectionMap: >-
+ value
+ - name: __fusion_6_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 5
+ - id: 7
+ - id: 5
+ type: Operation
+ schema: b
+ operation: |
+ query Op_defer_5($__fusion_7_id: ID!) {
+ productById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_7_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 7
+ - id: 7
+ type: Operation
+ schema: a
+ operation: |
+ query Op_defer_7 {
+ product {
+ id
+ }
+ }
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_Should_FetchAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_Should_FetchAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement.yaml
new file mode 100644
index 00000000000..40b2c923231
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_Should_FetchAllPrerequisites_When_RequirementSelectsNestedFieldWithOwnRequirement.yaml
@@ -0,0 +1,120 @@
+operation:
+ - document: |
+ {
+ product {
+ id
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 1
+ expandedNodes: 1
+nodes:
+ - id: 1
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_1 {
+ product {
+ id
+ }
+ }
+deliveryGroups:
+ - id: 0
+ path: $.product
+incrementalPlans:
+ - deliveryGroupIds:
+ - 0
+ parentNodeId: 1
+ nodes:
+ - id: 2
+ type: Operation
+ schema: c
+ operation: |
+ query Op_defer_2($__fusion_2_input: OwnerInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_2_input
+ selectionMap: >-
+ owner.{
+ raw
+ computed
+ }
+ - name: __fusion_3_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 4
+ - id: 8
+ - id: 3
+ type: Operation
+ schema: a
+ operation: |
+ query Op_defer_3($__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ owner {
+ raw
+ id
+ }
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_4_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 8
+ - id: 4
+ type: Operation
+ schema: a
+ operation: |
+ query Op_defer_4($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ userById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ source: $.userById
+ target: $.product.owner
+ requirements:
+ - name: __fusion_5_value
+ selectionMap: >-
+ value
+ - name: __fusion_6_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 5
+ - id: 5
+ type: Operation
+ schema: b
+ operation: |
+ query Op_defer_5($__fusion_7_id: ID!) {
+ userById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ source: $.userById
+ target: $.product.owner
+ requirements:
+ - name: __fusion_7_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 3
+ - id: 8
+ type: Operation
+ schema: a
+ operation: |
+ query Op_defer_8 {
+ product {
+ id
+ }
+ }
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml
index e4d0532f83a..8d922f306b5 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml
@@ -52,6 +52,7 @@ nodes:
selectionMap: >-
otherField
dependencies:
+ - id: 1
- id: 6
- id: 3
type: Operation
@@ -71,24 +72,6 @@ nodes:
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_4($__fusion_4_id: ID!) {
- testById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.testById
- target: $.test
- batchingGroupId: 6
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
- id: 5
type: Operation
schema: b
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Does_Not_Conflicts_Should_Merge.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Does_Not_Conflicts_Should_Merge.yaml
index 8d1149107bb..a4cb0403509 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Does_Not_Conflicts_Should_Merge.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Does_Not_Conflicts_Should_Merge.yaml
@@ -41,6 +41,7 @@ nodes:
selectionMap: >-
id
dependencies:
+ - id: 1
- id: 6
- id: 3
type: OperationBatch
@@ -62,21 +63,3 @@ nodes:
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_4($__fusion_4_id: ID!) {
- testById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.testById
- target: $.test
- batchingGroupId: 6
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml
index 1d73393be00..6a5f3afaf57 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml
@@ -49,7 +49,8 @@ nodes:
selectionMap: >-
otherField
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: b
@@ -61,28 +62,9 @@ nodes:
}
source: $.testById
target: $.test
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_4($__fusion_4_id: ID!) {
- testById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.testById
- target: $.test
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Simple_Requires_Arguments.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Simple_Requires_Arguments.yaml
index 88d783b73ab..14de4251278 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Simple_Requires_Arguments.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Simple_Requires_Arguments.yaml
@@ -40,7 +40,8 @@ nodes:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: b
@@ -52,28 +53,9 @@ nodes:
}
source: $.testById
target: $.test
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_4($__fusion_4_id: ID!) {
- testById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.testById
- target: $.test
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_AliasMergedLookupFields_When_RequirementVariablesDiffer.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_AliasMergedLookupFields_When_RequirementVariablesDiffer.yaml
new file mode 100644
index 00000000000..0ee6cfc5ea7
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_AliasMergedLookupFields_When_RequirementVariablesDiffer.yaml
@@ -0,0 +1,166 @@
+operation:
+ - document: |
+ {
+ product {
+ id
+ id @fusion__requirement
+ price
+ price @fusion__requirement
+ hasDiscount
+ hasDiscount @fusion__requirement
+ isExpensive
+ isExpensive @fusion__requirement
+ isExpensiveWithDiscount
+ isExpensiveWithDiscount @fusion__requirement
+ canAfford
+ canAfford2
+ canAffordWithDiscount
+ canAffordWithDiscount2
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 105
+ expandedNodes: 419
+nodes:
+ - id: 1
+ type: Operation
+ schema: b
+ operation: |
+ query Op_123456789101112_1 {
+ product {
+ id
+ hasDiscount
+ }
+ }
+ - id: 2
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_2($__fusion_1_id: ID!) {
+ productById(id: $__fusion_1_id) {
+ price
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_1_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 4
+ type: Operation
+ schema: d
+ operation: |
+ query Op_123456789101112_4(
+ $__fusion_10_isExpensive: Boolean!
+ $__fusion_13_isExpensive: Boolean!
+ $__fusion_3_isExpensiveWithDiscount: Boolean!
+ $__fusion_4_id: ID!
+ $__fusion_7_isExpensiveWithDiscount: Boolean!
+ ) {
+ productById(id: $__fusion_4_id) {
+ canAffordWithDiscount2(
+ isExpensiveWithDiscount: $__fusion_3_isExpensiveWithDiscount
+ )
+ canAffordWithDiscount(
+ isExpensiveWithDiscount: $__fusion_7_isExpensiveWithDiscount
+ )
+ canAfford2(isExpensive: $__fusion_10_isExpensive)
+ canAfford(isExpensive: $__fusion_13_isExpensive)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_10_isExpensive
+ selectionMap: >-
+ isExpensive
+ - name: __fusion_13_isExpensive
+ selectionMap: >-
+ isExpensive
+ - name: __fusion_3_isExpensiveWithDiscount
+ selectionMap: >-
+ isExpensiveWithDiscount
+ - name: __fusion_4_id
+ selectionMap: >-
+ id
+ - name: __fusion_7_isExpensiveWithDiscount
+ selectionMap: >-
+ isExpensiveWithDiscount
+ dependencies:
+ - id: 1
+ - id: 10
+ - id: 5
+ type: Operation
+ schema: c
+ operation: |
+ query Op_123456789101112_5(
+ $__fusion_12_price: Float!
+ $__fusion_15_price: Float!
+ $__fusion_5_id: ID!
+ $__fusion_6_hasDiscount: Boolean!
+ $__fusion_9_hasDiscount: Boolean!
+ ) {
+ productById(id: $__fusion_5_id) {
+ isExpensiveWithDiscount(hasDiscount: $__fusion_6_hasDiscount)
+ fusion__requirement_isExpensiveWithDiscount_0: isExpensiveWithDiscount(
+ hasDiscount: $__fusion_9_hasDiscount
+ )
+ isExpensive(price: $__fusion_12_price)
+ fusion__requirement_isExpensive_1: isExpensive(price: $__fusion_15_price)
+ }
+ }
+ source: $.productById
+ target: $.product
+ batchingGroupId: 10
+ requirements:
+ - name: __fusion_12_price
+ selectionMap: >-
+ price
+ - name: __fusion_15_price
+ selectionMap: >-
+ price
+ - name: __fusion_5_id
+ selectionMap: >-
+ id
+ - name: __fusion_6_hasDiscount
+ selectionMap: >-
+ hasDiscount
+ - name: __fusion_9_hasDiscount
+ selectionMap: >-
+ hasDiscount
+ dependencies:
+ - id: 1
+ - id: 2
+ - id: 9
+ type: Operation
+ schema: c
+ operation: |
+ query Op_123456789101112_9(
+ $__fusion_16_id: ID!
+ $__fusion_17_hasDiscount: Boolean!
+ $__fusion_18_price: Float!
+ ) {
+ productById(id: $__fusion_16_id) {
+ isExpensiveWithDiscount(hasDiscount: $__fusion_17_hasDiscount)
+ isExpensive(price: $__fusion_18_price)
+ }
+ }
+ source: $.productById
+ target: $.product
+ batchingGroupId: 10
+ requirements:
+ - name: __fusion_16_id
+ selectionMap: >-
+ id
+ - name: __fusion_17_hasDiscount
+ selectionMap: >-
+ hasDiscount
+ - name: __fusion_18_price
+ selectionMap: >-
+ price
+ dependencies:
+ - id: 1
+ - id: 2
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_FetchAllPrerequisites_When_RequirementContainsOnlyComputedField.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_FetchAllPrerequisites_When_RequirementContainsOnlyComputedField.yaml
new file mode 100644
index 00000000000..3d3d01a0048
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_FetchAllPrerequisites_When_RequirementContainsOnlyComputedField.yaml
@@ -0,0 +1,84 @@
+operation:
+ - document: |
+ {
+ product {
+ total
+ id @fusion__requirement
+ computed @fusion__requirement
+ value @fusion__requirement
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 2
+ expandedNodes: 9
+nodes:
+ - id: 1
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_1 {
+ product {
+ id
+ }
+ }
+ - id: 3
+ type: Operation
+ schema: c
+ operation: |
+ query Op_123456789101112_3($__fusion_2_input: TotalInput!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_2_input
+ selectionMap: >-
+ {
+ computed
+ }
+ - name: __fusion_3_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 5
+ - id: 5
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_5($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ productById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_5_value
+ selectionMap: >-
+ value
+ - name: __fusion_6_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 6
+ - id: 6
+ type: Operation
+ schema: b
+ operation: |
+ query Op_123456789101112_6($__fusion_7_id: ID!) {
+ productById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_7_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml
new file mode 100644
index 00000000000..f0188e6c1f3
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_FetchAllPrerequisites_When_RequirementMixesNativeAndComputedFields.yaml
@@ -0,0 +1,87 @@
+operation:
+ - document: |
+ {
+ product {
+ total
+ id @fusion__requirement
+ raw @fusion__requirement
+ computed @fusion__requirement
+ value @fusion__requirement
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 2
+ expandedNodes: 6
+nodes:
+ - id: 1
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_1 {
+ product {
+ id
+ raw
+ }
+ }
+ - id: 2
+ type: Operation
+ schema: c
+ operation: |
+ query Op_123456789101112_2($__fusion_1_id: ID!, $__fusion_2_input: TotalInput!) {
+ productById(id: $__fusion_1_id) {
+ total(input: $__fusion_2_input)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_1_id
+ selectionMap: >-
+ id
+ - name: __fusion_2_input
+ selectionMap: >-
+ {
+ raw
+ computed
+ }
+ dependencies:
+ - id: 1
+ - id: 3
+ - id: 3
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_3($__fusion_3_value: Int!, $__fusion_4_id: ID!) {
+ productById(id: $__fusion_4_id) {
+ computed(value: $__fusion_3_value)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_3_value
+ selectionMap: >-
+ value
+ - name: __fusion_4_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 4
+ - id: 4
+ type: Operation
+ schema: b
+ operation: |
+ query Op_123456789101112_4($__fusion_5_id: ID!) {
+ productById(id: $__fusion_5_id) {
+ value
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_5_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_NotFetchEntryKeyAgain_When_LookupRequirementIsOnlyComputed.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_NotFetchEntryKeyAgain_When_LookupRequirementIsOnlyComputed.yaml
new file mode 100644
index 00000000000..13cb2905ccb
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.CreatePlan_Should_NotFetchEntryKeyAgain_When_LookupRequirementIsOnlyComputed.yaml
@@ -0,0 +1,82 @@
+operation:
+ - document: |
+ {
+ product {
+ onlyComputed
+ id @fusion__requirement
+ computed @fusion__requirement
+ value @fusion__requirement
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 2
+ expandedNodes: 9
+nodes:
+ - id: 1
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_1 {
+ product {
+ id
+ }
+ }
+ - id: 3
+ type: Operation
+ schema: c
+ operation: |
+ query Op_123456789101112_3($__fusion_2_computed: Int!, $__fusion_3_id: ID!) {
+ productById(id: $__fusion_3_id) {
+ onlyComputed(computed: $__fusion_2_computed)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_2_computed
+ selectionMap: >-
+ computed
+ - name: __fusion_3_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 5
+ - id: 5
+ type: Operation
+ schema: a
+ operation: |
+ query Op_123456789101112_5($__fusion_5_value: Int!, $__fusion_6_id: ID!) {
+ productById(id: $__fusion_6_id) {
+ computed(value: $__fusion_5_value)
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_5_value
+ selectionMap: >-
+ value
+ - name: __fusion_6_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 6
+ - id: 6
+ type: Operation
+ schema: b
+ operation: |
+ query Op_123456789101112_6($__fusion_7_id: ID!) {
+ productById(id: $__fusion_7_id) {
+ value
+ }
+ }
+ source: $.productById
+ target: $.product
+ requirements:
+ - name: __fusion_7_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.Requires_Circular_2.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.Requires_Circular_2.yaml
index 98a1372938d..001ed3bcf82 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.Requires_Circular_2.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementChainTests.Requires_Circular_2.yaml
@@ -45,7 +45,8 @@ nodes:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: b
@@ -60,7 +61,6 @@ nodes:
}
source: $.postById
target: $.feed
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
@@ -70,21 +70,3 @@ nodes:
author.yearsOfExperience
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_4($__fusion_5_id: ID!) {
- postById(id: $__fusion_5_id) {
- id
- }
- }
- source: $.postById
- target: $.feed
- batchingGroupId: 5
- requirements:
- - name: __fusion_5_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_ByExpert_When_Circular_Require_Chains_Through_ByNovice.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_ByExpert_When_Circular_Require_Chains_Through_ByNovice.yaml
index c58bee04305..38c17c56b9a 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_ByExpert_When_Circular_Require_Chains_Through_ByNovice.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_ByExpert_When_Circular_Require_Chains_Through_ByNovice.yaml
@@ -43,8 +43,8 @@ nodes:
selectionMap: >-
id
dependencies:
+ - id: 1
- id: 4
- - id: 8
- id: 4
type: Operation
schema: b
@@ -85,7 +85,7 @@ nodes:
selectionMap: >-
id
dependencies:
- - id: 8
+ - id: 6
- id: 6
type: Operation
schema: b
@@ -99,28 +99,9 @@ nodes:
}
source: $.postById
target: $.feed
- batchingGroupId: 8
requirements:
- name: __fusion_7_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 7
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_7($__fusion_8_id: ID!) {
- postById(id: $__fusion_8_id) {
- id
- }
- }
- source: $.postById
- target: $.feed
- batchingGroupId: 8
- requirements:
- - name: __fusion_8_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_NeedsFlag_When_Require_Is_Same_Entity_Direct_Field.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_NeedsFlag_When_Require_Is_Same_Entity_Direct_Field.yaml
index f9bfa91e85f..e30c5c3aaba 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_NeedsFlag_When_Require_Is_Same_Entity_Direct_Field.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_NeedsFlag_When_Require_Is_Same_Entity_Direct_Field.yaml
@@ -39,7 +39,8 @@ nodes:
selectionMap: >-
id
dependencies:
- - id: 5
+ - id: 1
+ - id: 3
- id: 3
type: Operation
schema: b
@@ -51,28 +52,9 @@ nodes:
}
source: $.postById
target: $.feed
- batchingGroupId: 5
requirements:
- name: __fusion_3_id
selectionMap: >-
id
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: b
- operation: |
- query Op_123456789101112_4($__fusion_4_id: ID!) {
- postById(id: $__fusion_4_id) {
- id
- }
- }
- source: $.postById
- target: $.feed
- batchingGroupId: 5
- requirements:
- - name: __fusion_4_id
- selectionMap: >-
- id
- dependencies:
- - id: 1
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml
index 95310cb00d3..2cad57be0d9 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml
@@ -79,8 +79,8 @@ nodes:
selectionMap: >-
id
dependencies:
+ - id: 3
- id: 5
- - id: 6
- id: 5
type: Operation
schema: products
@@ -98,23 +98,6 @@ nodes:
id
dependencies:
- id: 3
- - id: 6
- type: Operation
- schema: cart
- operation: |
- query Op_123456789101112_6($__fusion_7_id: ID!) {
- productById(id: $__fusion_7_id) {
- id
- }
- }
- source: $.productById
- target: $.viewer.cart.items.nodes.product
- requirements:
- - name: __fusion_7_id
- selectionMap: >-
- id
- dependencies:
- - id: 3
- id: 1
type: Operation
schema: cart
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml
index c2acf982950..49f2e0e2823 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml
@@ -51,6 +51,7 @@ nodes:
selectionMap: >-
price
dependencies:
+ - id: 1
- id: 6
- id: 3
type: Operation
@@ -70,24 +71,6 @@ nodes:
upc
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: products
- operation: |
- query Op_123456789101112_4($__fusion_4_upc: String!) {
- productByUpc(upc: $__fusion_4_upc) {
- upc
- }
- }
- source: $.productByUpc
- target: $.products
- batchingGroupId: 6
- requirements:
- - name: __fusion_4_upc
- selectionMap: >-
- upc
- dependencies:
- - id: 1
- id: 5
type: Operation
schema: products
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml
index 9840a2502dd..c8e5617430d 100644
--- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml
@@ -52,6 +52,7 @@ nodes:
selectionMap: >-
price(withDiscount: true)
dependencies:
+ - id: 1
- id: 6
- id: 3
type: Operation
@@ -72,24 +73,6 @@ nodes:
upc
dependencies:
- id: 1
- - id: 4
- type: Operation
- schema: products
- operation: |
- query Op_123456789101112_4($__fusion_4_upc: String!) {
- productByUpc(upc: $__fusion_4_upc) {
- upc
- }
- }
- source: $.productByUpc
- target: $.products
- batchingGroupId: 6
- requirements:
- - name: __fusion_4_upc
- selectionMap: >-
- upc
- dependencies:
- - id: 1
- id: 5
type: Operation
schema: products
diff --git a/website/public/schemas/fusion/operation-plan.json b/website/public/schemas/fusion/operation-plan.json
index 27653be8ff5..70d6c34d029 100644
--- a/website/public/schemas/fusion/operation-plan.json
+++ b/website/public/schemas/fusion/operation-plan.json
@@ -6,7 +6,7 @@
"required": ["version"],
"properties": {
"version": {
- "enum": ["1.0.0"]
+ "enum": ["1.0.0", "1.1.0"]
}
},
"allOf": [
@@ -22,6 +22,19 @@
"then": {
"$ref": "#/$defs/v1_0_0_plan"
}
+ },
+ {
+ "if": {
+ "properties": {
+ "version": {
+ "const": "1.1.0"
+ }
+ },
+ "required": ["version"]
+ },
+ "then": {
+ "$ref": "#/$defs/v1_1_0_plan"
+ }
}
],
"$defs": {
@@ -90,6 +103,71 @@
],
"additionalProperties": false
},
+ "v1_1_0_plan": {
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+ "version": {
+ "const": "1.1.0"
+ },
+ "id": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "operation": {
+ "$ref": "#/$defs/requestOperation"
+ },
+ "searchSpace": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "expandedNodes": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "appId": {
+ "type": "string"
+ },
+ "environment": {
+ "type": "string"
+ },
+ "traceId": {
+ "type": "string"
+ },
+ "duration": {
+ "type": "number",
+ "minimum": 0
+ },
+ "nodes": {
+ "$ref": "#/$defs/nodes"
+ },
+ "deliveryGroups": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/deliveryGroup"
+ },
+ "minItems": 1
+ },
+ "incrementalPlans": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/v1_1_0_incrementalPlan"
+ },
+ "minItems": 1
+ }
+ },
+ "required": [
+ "version",
+ "id",
+ "operation",
+ "searchSpace",
+ "expandedNodes",
+ "nodes"
+ ],
+ "additionalProperties": false
+ },
"requestOperation": {
"type": "object",
"properties": {
@@ -279,6 +357,34 @@
"required": ["deliveryGroupIds", "parentNodeId", "operation"],
"additionalProperties": false
},
+ "v1_1_0_incrementalPlan": {
+ "type": "object",
+ "properties": {
+ "deliveryGroupIds": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "parentNodeId": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The id of the execution node of the enclosing plan that this plan is anchored at. 0 means the plan is anchored at the operation root of an enclosing plan that has no execution node."
+ },
+ "requirements": {
+ "$ref": "#/$defs/requirements"
+ },
+ "operation": {
+ "$ref": "#/$defs/requestOperation"
+ },
+ "nodes": {
+ "$ref": "#/$defs/nodes"
+ }
+ },
+ "required": ["deliveryGroupIds", "parentNodeId", "operation"],
+ "additionalProperties": false
+ },
"operationNode": {
"type": "object",
"properties": {