From b006cea4924cbf58246353aa5ac35c3125e37dd0 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Fri, 5 Jun 2026 17:58:28 +0000 Subject: [PATCH] [Fusion] Partition child selections when inlining fields with requirements --- .../Planning/OperationPlanner.cs | 26 ++- .../Planning/RequirementReentrancyTests.cs | 220 ++++++++++++++++++ ...EnteringFromRecommendation_Standalone.yaml | 43 ++++ ...tion_When_InnerProduct_Selects_OnlyId.yaml | 56 +++++ 4 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementReentrancyTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_Reenter_Catalog_When_EnteringFromRecommendation_Standalone.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_StayInRecommendation_When_InnerProduct_Selects_OnlyId.yaml diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 23a696f80c7..c9c0549dead 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -1090,13 +1090,28 @@ private void PlanInlineFieldWithRequirements( new VariableNode(new NameNode($"{requirementKey}_{argument.Name}")))); } + // we partition the requiring field's children against the consumer schema and + // only inline the resolvable subset. Unresolvable descendants and nested + // fields-with-requirements are pushed onto the backlog so that they are planned + // as re-entrant lookups instead of being inlined into a step that cannot resolve + // them. + var childSelections = + ExtractResolvableChildSelections( + stepConsumer.StepId, + workItem.EstimatedDepth, + workItem.Selection, + current, + index, + ref backlog); + var operation = InlineSelections( currentStep.Definition, index, currentStep.Type, workItem.Selection.SelectionSetId, - new SelectionSetNode([workItem.Selection.Node.WithArguments(arguments)])); + new SelectionSetNode( + [workItem.Selection.Node.WithArguments(arguments).WithSelectionSet(childSelections)])); var requirements = currentStep.Requirements; @@ -1699,11 +1714,18 @@ private static List GetLookupArguments(Lookup lookup, string requi var selectionSetId = index.GetId(selection.Node.SelectionSet); var selectionSetType = selection.Field.Type.AsTypeDefinition(); + + // the field's path points at the selection set the field lives in. Its child + // selections live one level deeper, so we append the field's response name to + // root the partitioned children (and any re-entrant lookups derived from them) + // at the correct path. + var childPath = selection.Path.AppendField( + selection.Node.Alias?.Value ?? selection.Node.Name.Value); var selectionSet = new SelectionSet( selectionSetId, selection.Node.SelectionSet, selectionSetType, - selection.Path); + childPath); var input = new SelectionSetPartitionerInput { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementReentrancyTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementReentrancyTests.cs new file mode 100644 index 00000000000..5cba0a58ee6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementReentrancyTests.cs @@ -0,0 +1,220 @@ +using HotChocolate.Fusion.Types; + +namespace HotChocolate.Fusion.Planning; + +public class RequirementReentrancyTests : FusionTestBase +{ + [Fact] + public void Plan_Should_Reenter_Catalog_When_InnerProductCategory_Crosses_RequireBoundary() + { + // arrange + var schema = CreateRecommendationSchema(); + + // act + // products (catalog) -> recommendations (recommendation service, @require "category") + // -> product.category (must re-enter the catalog service) + var plan = PlanOperation( + schema, + """ + query getProducts { + products { + category + recommendations { + product { + category + } + } + } + } + """); + + // assert + // the recommendation service does not own Product.category, so its operation must + // select only the inner product key (id) and a separate re-entrant catalog lookup + // resolves product.category (target $.products.recommendations.product). + MatchInline( + plan, + """ + operation: + - document: | + query getProducts { + products { + category + category @fusion__requirement + recommendations { + product { + category + id @fusion__requirement + } + } + id @fusion__requirement + } + } + name: getProducts + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 8 + nodes: + - id: 1 + type: Operation + schema: CATALOG + operation: | + query getProducts_123456789101112_1 { + products { + category + id + } + } + - id: 3 + type: Operation + schema: RECOMMENDATION + operation: | + query getProducts_123456789101112_3( + $__fusion_2_category: ProductCategory! + $__fusion_3_id: ID! + ) { + productById(id: $__fusion_3_id) { + recommendations(category: $__fusion_2_category) { + product { + id + } + } + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_2_category + selectionMap: >- + category + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 4 + type: Operation + schema: CATALOG + operation: | + query getProducts_123456789101112_4($__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + category + } + } + source: $.productById + target: $.products.recommendations.product + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 3 + """); + } + + [Fact] + public void Plan_Should_StayInRecommendation_When_InnerProduct_Selects_OnlyId() + { + // arrange + var schema = CreateRecommendationSchema(); + + // act + // control: inner selection is `id`, already owned by the recommendation service, + // so no re-entrant lookup back into the catalog is required. + var plan = PlanOperation( + schema, + """ + query getProducts { + products { + category + recommendations { + product { + id + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Reenter_Catalog_When_EnteringFromRecommendation_Standalone() + { + // arrange + var schema = CreateRecommendationSchema(); + + // act + // control: enter from the recommendation service directly (no outer @require + // boundary), product.category still needs a re-entrant lookup into the catalog. + var plan = PlanOperation( + schema, + """ + query getProducts { + recommendations { + product { + category + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + private static FusionSchemaDefinition CreateRecommendationSchema() + { + return ComposeSchema( + """ + # name: CATALOG + schema { + query: Query + } + + type Query { + products: [Product!] + productById(id: ID! @is(field: "id")): Product @lookup + } + + type Product @key(fields: "id") { + id: ID! + category: ProductCategory! + } + + enum ProductCategory { + ELECTRONICS + BOOKS + } + """, + """ + # name: RECOMMENDATION + schema { + query: Query + } + + type Query { + recommendations: [Recommendation!] + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + recommendations( + category: ProductCategory! @require(field: "category")): [Recommendation!] + } + + type Recommendation @key(fields: "id") { + id: ID! + product: Product + } + + enum ProductCategory { + ELECTRONICS + BOOKS + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_Reenter_Catalog_When_EnteringFromRecommendation_Standalone.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_Reenter_Catalog_When_EnteringFromRecommendation_Standalone.yaml new file mode 100644 index 00000000000..ed857f4f13f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_Reenter_Catalog_When_EnteringFromRecommendation_Standalone.yaml @@ -0,0 +1,43 @@ +operation: + - document: | + query getProducts { + recommendations { + product { + category + id @fusion__requirement + } + } + } + name: getProducts + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: RECOMMENDATION + operation: | + query getProducts_123456789101112_1 { + recommendations { + product { + id + } + } + } + - id: 2 + type: Operation + schema: CATALOG + operation: | + query getProducts_123456789101112_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + category + } + } + source: $.productById + target: $.recommendations.product + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_StayInRecommendation_When_InnerProduct_Selects_OnlyId.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_StayInRecommendation_When_InnerProduct_Selects_OnlyId.yaml new file mode 100644 index 00000000000..b204442e8c2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementReentrancyTests.Plan_Should_StayInRecommendation_When_InnerProduct_Selects_OnlyId.yaml @@ -0,0 +1,56 @@ +operation: + - document: | + query getProducts { + products { + category + category @fusion__requirement + recommendations { + product { + id + } + } + id @fusion__requirement + } + } + name: getProducts + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: CATALOG + operation: | + query getProducts_123456789101112_1 { + products { + category + id + } + } + - id: 2 + type: Operation + schema: RECOMMENDATION + operation: | + query getProducts_123456789101112_2( + $__fusion_1_id: ID! + $__fusion_2_category: ProductCategory! + ) { + productById(id: $__fusion_1_id) { + recommendations(category: $__fusion_2_category) { + product { + id + } + } + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_category + selectionMap: >- + category + dependencies: + - id: 1