diff --git a/src/HotChocolate/Fusion/src/Core/Planning/Pipeline/ExecutionStepDiscoveryMiddleware.cs b/src/HotChocolate/Fusion/src/Core/Planning/Pipeline/ExecutionStepDiscoveryMiddleware.cs index fc1cfca7460..8533d79714e 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/Pipeline/ExecutionStepDiscoveryMiddleware.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/Pipeline/ExecutionStepDiscoveryMiddleware.cs @@ -123,8 +123,15 @@ parentSelectionPath is null var current = leftovers ?? selections; var subgraph = _config.GetBestMatchingSubgraph( operation, + parentSelectionPath, current, selectionSetTypeMetadata); + + if (subgraph is null) + { + throw ThrowHelper.NoResolverInContext(); + } + var executionStep = new SelectionExecutionStep( context.NextStepId(), subgraph, @@ -232,14 +239,14 @@ parentSelectionPath is null path.RemoveAt(pathIndex); } - // if the current execution step has now way to resolve the data + // if the current execution step has no way to resolve the data // we will try to resolve it from the root. if (executionStep.ParentSelection is not null && executionStep.ParentSelectionPath is not null && executionStep.Resolver is null && executionStep.SelectionResolvers.Count == 0) { - if (!EnsureStepCanBeResolvedFromRoot( + if (!_config.EnsurePathCanBeResolvedFromRoot( executionStep.SubgraphName, executionStep.ParentSelectionPath)) { @@ -628,10 +635,16 @@ private SelectionExecutionStep CreateNodeNestedExecutionSteps( ? availableSubgraphs[0] : _config.GetBestMatchingSubgraph( operation, + null, entityTypeSelectionSet.Selections, entityTypeMetadata, availableSubgraphs); + if (subgraph is null) + { + throw ThrowHelper.NoResolverInContext(); + } + var field = nodeSelection.Field; var fieldInfo = queryTypeMetadata.Fields[field.Name]; var executionStep = new SelectionExecutionStep( @@ -914,27 +927,6 @@ private static bool IsNodeField(IObjectField field, IOperation operation) && field.DeclaringType.Equals(operation.RootType) && (field.Name.EqualsOrdinal("node") || field.Name.EqualsOrdinal("nodes")); - private bool EnsureStepCanBeResolvedFromRoot( - string subgraphName, - SelectionPath path) - { - var current = path; - - while (current is not null) - { - var typeMetadata = _config.GetType(current.Selection.DeclaringType.Name); - - if (!typeMetadata.Fields[current.Selection.Field.Name].Bindings.ContainsSubgraph(subgraphName)) - { - return false; - } - - current = current.Parent; - } - - return true; - } - private readonly struct BacklogItem( ISelection parentSelection, SelectionPath? selectionPath, diff --git a/src/HotChocolate/Fusion/src/Core/Planning/QueryPlannerHelpers.cs b/src/HotChocolate/Fusion/src/Core/Planning/QueryPlannerHelpers.cs index 26f6fc9d6d1..3381579a964 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/QueryPlannerHelpers.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/QueryPlannerHelpers.cs @@ -5,15 +5,16 @@ namespace HotChocolate.Fusion.Planning; internal static class QueryPlannerHelpers { - public static string GetBestMatchingSubgraph( + public static string? GetBestMatchingSubgraph( this FusionGraphConfiguration configuration, IOperation operation, + SelectionPath? parentSelectionPath, IReadOnlyList selections, ObjectTypeMetadata typeMetadataContext, IReadOnlyList? availableSubgraphs = null) { var bestScore = 0; - var bestSubgraph = configuration.SubgraphNames[0]; + var bestSubgraph = default(string?); foreach (var subgraphName in availableSubgraphs ?? configuration.SubgraphNames) { @@ -21,6 +22,7 @@ public static string GetBestMatchingSubgraph( EvaluateSubgraphCompatibilityScore( configuration, operation, + parentSelectionPath, selections, typeMetadataContext, subgraphName); @@ -38,11 +40,15 @@ public static string GetBestMatchingSubgraph( private static int EvaluateSubgraphCompatibilityScore( FusionGraphConfiguration configuration, IOperation operation, + SelectionPath? parentSelectionPath, IReadOnlyList selections, ObjectTypeMetadata typeMetadataContext, string schemaName) { var score = 0; + + var pathOrTypeCanBeResolvedFromRoot = EnsurePathOrTypeCanBeResolvedFromRoot(configuration, parentSelectionPath, typeMetadataContext, schemaName); + var stack = new Stack<(IReadOnlyList selections, ObjectTypeMetadata typeContext)>(); stack.Push((selections, typeMetadataContext)); @@ -50,11 +56,21 @@ private static int EvaluateSubgraphCompatibilityScore( { var (currentSelections, currentTypeContext) = stack.Pop(); + // If there are no selections at the current node, it means the subgraph + // can resolve the path up to this point without requiring any further fields, so we increase the score. + if (currentSelections.Count == 0) + { + score++; + } + foreach (var selection in currentSelections) { if (!selection.Field.IsIntrospectionField && currentTypeContext.Fields[selection.Field.Name].Bindings - .ContainsSubgraph(schemaName)) + .ContainsSubgraph(schemaName) && + (pathOrTypeCanBeResolvedFromRoot || + currentTypeContext.Fields[selection.Field.Name].Resolvers + .ContainsResolvers(schemaName))) { score++; @@ -73,4 +89,36 @@ private static int EvaluateSubgraphCompatibilityScore( return score; } + + private static bool EnsurePathOrTypeCanBeResolvedFromRoot( + FusionGraphConfiguration configuration, + SelectionPath? parentSelectionPath, + ObjectTypeMetadata typeMetadataContext, + string schemaName) + { + return typeMetadataContext.Resolvers.ContainsResolvers(schemaName) || + configuration.EnsurePathCanBeResolvedFromRoot(schemaName, parentSelectionPath); + } + + public static bool EnsurePathCanBeResolvedFromRoot( + this FusionGraphConfiguration configuration, + string subgraphName, + SelectionPath? path) + { + var current = path; + + while (current is not null) + { + var typeMetadata = configuration.GetType(current.Selection.DeclaringType.Name); + + if (!typeMetadata.Fields[current.Selection.Field.Name].Bindings.ContainsSubgraph(subgraphName)) + { + return false; + } + + current = current.Parent; + } + + return true; + } } diff --git a/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs b/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs index 7343a707525..0773161b997 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs @@ -2895,6 +2895,150 @@ query Test($number: Int!) { Assert.Null(result.ExpectOperationResult().Errors); } + [Fact] + public async Task Unresolvable_Subgraph_Is_Not_Chosen_If_Data_Is_Available_In_Resolvable_Subgraph() + { + // arrange + var subgraphA = await TestSubgraph.CreateAsync( + """ + type Query { + viewer: Viewer + } + + type Viewer { + product: Product! + } + + type Product implements Node { + id: ID! + } + + interface Node { + id: ID! + } + """); + + var subgraphB = await TestSubgraph.CreateAsync( + """ + type Query { + test: Test! + } + + type Test { + id: ID! + } + + type Product { + id: ID! + name: String! + } + """); + + var subgraphC = await TestSubgraph.CreateAsync( + """ + type Query { + node(id: ID!): Node + } + + type Product implements Node { + id: ID! + name: String! + } + + interface Node { + id: ID! + } + """); + + using var subgraphs = new TestSubgraphCollection(output, [subgraphA, subgraphB, subgraphC]); + var executor = await subgraphs.GetExecutorAsync(); + var request = """ + query { + viewer { + product { + id + name + } + } + } + """; + + // act + var result = await executor.ExecuteAsync(request); + + // assert + MatchMarkdownSnapshot(request, result); + } + + [Fact] + public async Task Subgraph_Containing_More_Selections_Is_Chosen() + { + // arrange + var subgraphA = await TestSubgraph.CreateAsync( + """ + type Query { + productBySlug: Product + } + + type Product { + id: ID! + } + """); + + var subgraphB = await TestSubgraph.CreateAsync( + """ + type Query { + productById(id: ID!): Product + } + + type Product { + id: ID! + author: Author + } + + type Author { + name: String! + } + """); + + var subgraphC = await TestSubgraph.CreateAsync( + """ + type Query { + productById(id: ID!): Product + } + + type Product { + id: ID! + author: Author + } + + type Author { + name: String! + rating: Int! + } + """); + + using var subgraphs = new TestSubgraphCollection(output, [subgraphA, subgraphB, subgraphC]); + var executor = await subgraphs.GetExecutorAsync(); + var request = """ + query { + productBySlug { + author { + name + rating + } + } + } + """; + + // act + var result = await executor.ExecuteAsync(request); + + // assert + MatchMarkdownSnapshot(request, result); + } + + public sealed class HotReloadConfiguration : IObservable { private GatewayConfiguration _configuration; diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Forward_Nested_Node_Variables.md b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Forward_Nested_Node_Variables.md index 7b24d4ab105..575e984dc8d 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Forward_Nested_Node_Variables.md +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Forward_Nested_Node_Variables.md @@ -29,7 +29,7 @@ query ProductReviews($id: ID!, $first: Int!) { ## QueryPlan Hash ```text -3EB74A019DB95A4FA68CF9569951EA91659E7186 +90288E30B42792662254D8D4CF683A745331D191 ``` ## QueryPlan @@ -50,7 +50,7 @@ query ProductReviews($id: ID!, $first: Int!) { "type": "User", "node": { "type": "Resolve", - "subgraph": "Accounts", + "subgraph": "Reviews2", "document": "query ProductReviews_1($id: ID!) { node(id: $id) { ... on User { __typename } } }", "selectionSetId": 0, "forwardedVariables": [ diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Subgraph_Containing_More_Selections_Is_Chosen.md b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Subgraph_Containing_More_Selections_Is_Chosen.md new file mode 100644 index 00000000000..de78f1291da --- /dev/null +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Subgraph_Containing_More_Selections_Is_Chosen.md @@ -0,0 +1,89 @@ +# Subgraph_Containing_More_Selections_Is_Chosen + +## Result + +```json +{ + "data": { + "productBySlug": { + "author": { + "name": "string", + "rating": 123 + } + } + } +} +``` + +## Request + +```graphql +{ + productBySlug { + author { + name + rating + } + } +} +``` + +## QueryPlan Hash + +```text +97A88121800B8E021163EA531B156FA2A6CDF11F +``` + +## QueryPlan + +```json +{ + "document": "{ productBySlug { author { name rating } } }", + "rootNode": { + "type": "Sequence", + "nodes": [ + { + "type": "Resolve", + "subgraph": "Subgraph_1", + "document": "query fetch_productBySlug_1 { productBySlug { __fusion_exports__1: id } }", + "selectionSetId": 0, + "provides": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Compose", + "selectionSetIds": [ + 0 + ] + }, + { + "type": "Resolve", + "subgraph": "Subgraph_3", + "document": "query fetch_productBySlug_2($__fusion_exports__1: ID!) { productById(id: $__fusion_exports__1) { author { name rating } } }", + "selectionSetId": 1, + "path": [ + "productById" + ], + "requires": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Compose", + "selectionSetIds": [ + 1 + ] + } + ] + }, + "state": { + "__fusion_exports__1": "Product_id" + } +} +``` + diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Unresolvable_Subgraph_Is_Not_Chosen_If_Data_Is_Available_In_Resolvable_Subgraph.md b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Unresolvable_Subgraph_Is_Not_Chosen_If_Data_Is_Available_In_Resolvable_Subgraph.md new file mode 100644 index 00000000000..57c5244397f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Unresolvable_Subgraph_Is_Not_Chosen_If_Data_Is_Available_In_Resolvable_Subgraph.md @@ -0,0 +1,89 @@ +# Unresolvable_Subgraph_Is_Not_Chosen_If_Data_Is_Available_In_Resolvable_Subgraph + +## Result + +```json +{ + "data": { + "viewer": { + "product": { + "id": "1", + "name": "string" + } + } + } +} +``` + +## Request + +```graphql +{ + viewer { + product { + id + name + } + } +} +``` + +## QueryPlan Hash + +```text +68F7ACC73F0431E50C4145FBB512E15D04D79198 +``` + +## QueryPlan + +```json +{ + "document": "{ viewer { product { id name } } }", + "rootNode": { + "type": "Sequence", + "nodes": [ + { + "type": "Resolve", + "subgraph": "Subgraph_1", + "document": "query fetch_viewer_1 { viewer { product { id __fusion_exports__1: id } } }", + "selectionSetId": 0, + "provides": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Compose", + "selectionSetIds": [ + 0 + ] + }, + { + "type": "Resolve", + "subgraph": "Subgraph_3", + "document": "query fetch_viewer_2($__fusion_exports__1: ID!) { node(id: $__fusion_exports__1) { ... on Product { name } } }", + "selectionSetId": 2, + "path": [ + "node" + ], + "requires": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Compose", + "selectionSetIds": [ + 2 + ] + } + ] + }, + "state": { + "__fusion_exports__1": "Product_id" + } +} +``` +