diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs index 78d404f2360..f79170a71ee 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs @@ -1,7 +1,7 @@ using System; using System.IO; -namespace HotChocolate.Fusion.Benchmarks; +namespace Fusion.Execution.Benchmarks; /// /// Resolves the paths of the large federated graph benchmark corpus (the big-federated-graphs checkout) without diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 7cf693b100a..109c93ac477 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -3436,7 +3436,7 @@ private static void RegisterRequirementSelectionSets( } } - private OperationDefinitionNode InlineSelections( + internal OperationDefinitionNode InlineSelections( OperationDefinitionNode operation, SelectionSetIndexBuilder index, ITypeDefinition selectionSetType, @@ -3447,88 +3447,127 @@ private OperationDefinitionNode InlineSelections( List? backlog = null; var didInline = false; - var rewriter = SyntaxRewriter.Create>( - rewrite: (node, path) => - { - if (node is not SelectionSetNode selectionSet) - { - return node; - } + // Requirement inlining runs for many search branches. Only field and inline-fragment + // selection sets can change here, so walk that tree directly instead of visiting every + // syntax node in arguments, directives, variable definitions, names and types. + var rewrittenSelectionSet = RewriteSelectionSet(operation.SelectionSet); + var rewrittenOperation = ReferenceEquals(rewrittenSelectionSet, operation.SelectionSet) + ? operation + : operation.WithSelectionSet(rewrittenSelectionSet); - // if the node was rewritten we keep track that the rewritten node and - // the original node are semantically equivalent. - var originalSelectionSet = (SelectionSetNode)path.Peek(); - var id = index.GetId(originalSelectionSet); + if (!didInline) + { + throw new InvalidOperationException( + $"Selections `{selectionsToInline}` could not be inlined into selection set of type " + + $"'{selectionSetType.Name}', as no selection set with the id {targetSelectionSetId} was found."); + } + + return rewrittenOperation; - if (!ReferenceEquals(originalSelectionSet, selectionSet)) + SelectionSetNode RewriteSelectionSet(SelectionSetNode originalSelectionSet) + { + List? rewrittenSelections = null; + + for (var i = 0; i < originalSelectionSet.Selections.Count; i++) + { + var originalSelection = originalSelectionSet.Selections[i]; + var rewrittenSelection = originalSelection switch { - index.Register(originalSelectionSet, selectionSet); - } + FieldNode { SelectionSet: { } childSelectionSet } field => + RewriteField(field, childSelectionSet), + FieldNode => originalSelection, + InlineFragmentNode inlineFragment => RewriteInlineFragment(inlineFragment), + FragmentSpreadNode => originalSelection, + _ => throw new ArgumentOutOfRangeException(nameof(originalSelection)) + }; - if (targetSelectionSetId != id) + if (rewrittenSelections is null + && !ReferenceEquals(originalSelection, rewrittenSelection)) { - return node; + rewrittenSelections = new List(originalSelectionSet.Selections.Count); + + for (var j = 0; j < i; j++) + { + rewrittenSelections.Add(originalSelectionSet.Selections[j]); + } } - SelectionSetNode newSelectionSet; + rewrittenSelections?.Add(rewrittenSelection); + } - if (inlineInternal) - { - var size = selectionSet.Selections.Count + selectionsToInline.Selections.Count; - var selections = new List(size); - selections.AddRange(originalSelectionSet.Selections); + var selectionSet = rewrittenSelections is null + ? originalSelectionSet + : originalSelectionSet.WithSelections(rewrittenSelections); + var id = index.GetId(originalSelectionSet); - foreach (var selection in selectionsToInline.Selections) - { - var markedSelection = MarkInternalSubtree(selection, index); + if (!ReferenceEquals(originalSelectionSet, selectionSet)) + { + index.Register(originalSelectionSet, selectionSet); + } - selections.Add(markedSelection); + if (targetSelectionSetId != id) + { + return selectionSet; + } - switch (markedSelection) - { - case FieldNode field: - IndexInternalSelections(field.SelectionSet, index, ref backlog); - break; + SelectionSetNode newSelectionSet; - case InlineFragmentNode inlineFragment: - IndexInternalSelections(inlineFragment.SelectionSet, index, ref backlog); - break; - } - } + if (inlineInternal) + { + var size = selectionSet.Selections.Count + selectionsToInline.Selections.Count; + var selections = new List(size); + selections.AddRange(originalSelectionSet.Selections); - newSelectionSet = new SelectionSetNode(selections); - } - else + foreach (var selection in selectionsToInline.Selections) { - newSelectionSet = _mergeRewriter.Merge( - selectionSet, - selectionsToInline, - selectionSetType, - index); - } + var markedSelection = MarkInternalSubtree(selection, index); - didInline = true; + selections.Add(markedSelection); - index.Register(originalSelectionSet, newSelectionSet); - return newSelectionSet; - }, - enter: (node, path) => + switch (markedSelection) + { + case FieldNode field: + IndexInternalSelections(field.SelectionSet, index, ref backlog); + break; + + case InlineFragmentNode inlineFragment: + IndexInternalSelections(inlineFragment.SelectionSet, index, ref backlog); + break; + } + } + + newSelectionSet = new SelectionSetNode(selections); + } + else { - path.Push(node); - return path; - }, - leave: (_, path) => path.Pop()); + newSelectionSet = _mergeRewriter.Merge( + selectionSet, + selectionsToInline, + selectionSetType, + index); + } - var rewrittenOperation = (OperationDefinitionNode)rewriter.Rewrite(operation, [])!; + didInline = true; - if (!didInline) + index.Register(originalSelectionSet, newSelectionSet); + return newSelectionSet; + } + + FieldNode RewriteField(FieldNode field, SelectionSetNode childSelectionSet) { - throw new InvalidOperationException( - $"Selections `{selectionsToInline}` could not be inlined into selection set of type " - + $"'{selectionSetType.Name}', as no selection set with the id {targetSelectionSetId} was found."); + var rewrittenChildSelectionSet = RewriteSelectionSet(childSelectionSet); + return ReferenceEquals(childSelectionSet, rewrittenChildSelectionSet) + ? field + : field.WithSelectionSet(rewrittenChildSelectionSet); } - return rewrittenOperation; + InlineFragmentNode RewriteInlineFragment(InlineFragmentNode inlineFragment) + { + var rewrittenChildSelectionSet = RewriteSelectionSet(inlineFragment.SelectionSet); + return ReferenceEquals(inlineFragment.SelectionSet, rewrittenChildSelectionSet) + ? inlineFragment + : inlineFragment.WithSelectionSet(rewrittenChildSelectionSet); + } static ISelectionNode MarkInternalSubtree( ISelectionNode selection, diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerSelectionInliningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerSelectionInliningTests.cs new file mode 100644 index 00000000000..e9503495c28 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerSelectionInliningTests.cs @@ -0,0 +1,196 @@ +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; +using Microsoft.Extensions.ObjectPool; + +namespace HotChocolate.Fusion.Planning; + +public sealed class OperationPlannerSelectionInliningTests : FusionTestBase +{ + [Fact] + public void InlineSelections_Should_RewriteAllMatchingSelectionSets_When_SelectionSetsShareTargetId() + { + // arrange + var (planner, schema) = CreatePlanner(); + var operation = ParseOperation(); + var (index, root, left, nested, right) = CreateMergedIndex(operation); + var targetId = index.GetId(left); + var selectionsToInline = ParseSelections("{ extra }"); + + // act + var rewritten = planner.InlineSelections( + operation, + index, + schema.Types.GetType("Item"), + targetId, + selectionsToInline); + + // assert + FormatResult(rewritten, index, root, targetId).MatchInlineSnapshot( + """ + query Test { + left { + child { + original + extra + } + extra + } + right { + original + extra + } + untouched { + ...Shared @include(if: true) + } + } + rootId: 1 + rewrittenRootId: 1 + targetId: 2 + leftId: 2 + nestedId: 2 + rightId: 2 + allRegistered: true + """); + } + + [Fact] + public void InlineSelections_Should_PreserveInternalPostOrderSemantics_When_NestedSelectionSetsShareTargetId() + { + // arrange + var (planner, schema) = CreatePlanner(); + var operation = ParseOperation(); + var (index, root, left, _, _) = CreateMergedIndex(operation); + var targetId = index.GetId(left); + var selectionsToInline = ParseSelections("{ extra }"); + + // act + var rewritten = planner.InlineSelections( + operation, + index, + schema.Types.GetType("Item"), + targetId, + selectionsToInline, + inlineInternal: true); + + // assert + FormatResult(rewritten, index, root, targetId).MatchInlineSnapshot( + """ + query Test { + left { + child { + original + } + extra @fusion__requirement + } + right { + original + extra @fusion__requirement + } + untouched { + ...Shared @include(if: true) + } + } + rootId: 1 + rewrittenRootId: 1 + targetId: 2 + leftId: 2 + nestedId: 2 + rightId: 2 + allRegistered: true + """); + } + + private static (OperationPlanner Planner, FusionSchemaDefinition Schema) CreatePlanner() + { + var schema = ComposeSchema( + """ + schema { + query: Query + } + + type Query { + left: Item + right: Item + untouched: Item + } + + type Item { + original: String + extra: String + child: Item + } + """); + var pool = new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>()); + var compiler = new OperationCompiler(schema, pool); + + return (new OperationPlanner(schema, compiler), schema); + } + + private static OperationDefinitionNode ParseOperation() + => Utf8GraphQLParser + .Parse( + """ + query Test { + left { + child { + original + } + } + right { + original + } + untouched { + ...Shared @include(if: true) + } + } + """) + .GetOperation("Test"); + + private static SelectionSetNode ParseSelections(string source) + => Utf8GraphQLParser.Parse(source).GetOperation(null).SelectionSet; + + private static ( + SelectionSetIndexBuilder Index, + SelectionSetNode Root, + SelectionSetNode Left, + SelectionSetNode Nested, + SelectionSetNode Right) CreateMergedIndex(OperationDefinitionNode operation) + { + var root = operation.SelectionSet; + var left = ((FieldNode)root.Selections[0]).SelectionSet!; + var nested = ((FieldNode)left.Selections[0]).SelectionSet!; + var right = ((FieldNode)root.Selections[1]).SelectionSet!; + var index = SelectionSetIndexer.Create(operation).ToBuilder(); + + index.OnMerge(left, nested); + index.OnMerge(left, right); + + return (index, root, left, nested, right); + } + + private static string FormatResult( + OperationDefinitionNode operation, + SelectionSetIndexBuilder index, + SelectionSetNode originalRoot, + uint targetId) + { + var root = operation.SelectionSet; + var left = ((FieldNode)root.Selections[0]).SelectionSet!; + var nested = ((FieldNode)left.Selections[0]).SelectionSet!; + var right = ((FieldNode)root.Selections[1]).SelectionSet!; + var allRegistered = SelectionSetIndexer + .CreateIdSet(root, index) + .All(id => index.TryGetSelectionSet(id, out _)); + + return operation.ToString(indented: true) + + $"\nrootId: {index.GetId(originalRoot)}" + + $"\nrewrittenRootId: {index.GetId(root)}" + + $"\ntargetId: {targetId}" + + $"\nleftId: {index.GetId(left)}" + + $"\nnestedId: {index.GetId(nested)}" + + $"\nrightId: {index.GetId(right)}" + + $"\nallRegistered: {allRegistered.ToString().ToLowerInvariant()}"; + } +}