diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs index e7b6234b9fd..2b0fea9f836 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -222,7 +222,12 @@ internal ImmutableArray CreateVariableValueSets( { if (forwardedVariables.Length == 0) { - return []; + if (selectionSet.IsRoot) + { + return []; + } + + return [new VariableValues(ToResultPath(selectionSet), new ObjectValueNode([]))]; } var variableValues = GetPathThroughVariables(forwardedVariables); @@ -257,6 +262,21 @@ internal ImmutableArray CreateVariableValueSets( } } + private static Path ToResultPath(SelectionPath selectionSet) + { + var resultPath = Path.Root; + + foreach (var segment in selectionSet.Segments) + { + if (segment.Kind is SelectionPathSegmentKind.Root or SelectionPathSegmentKind.Field) + { + resultPath = resultPath.Append(segment.Name); + } + } + + return resultPath; + } + internal void AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs index 848b50ba037..e3d0663ad9d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -670,11 +670,12 @@ private void PlanSelections( backlog = backlog.PushUnresolvable(unresolvable, current.SchemaName, stepDepth); backlog = backlog.PushRequirements(fieldsWithRequirements, stepId, stepDepth); - // lookups are always queries. + // Lookups are always queries. Root work items can also be rewritten to the query root + // when walking shared paths (for example the viewer convention in mutations). var operationType = - lookup is null - ? current.OperationDefinition.Operation - : OperationType.Query; + lookup is not null || IsQueryRootSelection(workItem.SelectionSet) + ? OperationType.Query + : current.OperationDefinition.Operation; var operationBuilder = OperationDefinitionBuilder @@ -711,6 +712,15 @@ lookup is null (var definition, index, var source) = operationBuilder.Build(index); + if (lookup is null + && operationType == OperationType.Query + && !workItem.SelectionSet.Path.IsRoot + && resolvable.Selections is [FieldNode field] + && PlannerExtensions.IsViewerFieldSelection(field)) + { + source = SelectionPath.Root.AppendField(field.Name.Value); + } + var step = new OperationPlanStep { Id = stepId, @@ -756,6 +766,9 @@ lookup is null possiblePlans.EnqueueBranches(next); } + private bool IsQueryRootSelection(SelectionSet selectionSet) + => selectionSet.Type.Name.Equals(_schema.QueryType.Name, StringComparison.Ordinal); + private PlanNode InlineLookupRequirements( SelectionSet workItemSelectionSet, PlanNode current, @@ -2327,6 +2340,7 @@ public static int NextId(this ImmutableList steps) var segments = selectionSet.Path.Segments; var finalSelectionSet = selectionSet.Node; var fieldsMovedUp = 0; + var viewerFallbackToQueryRoot = false; while (pathItems.TryPop(out var pathItem)) { @@ -2338,6 +2352,20 @@ public static int NextId(this ImmutableList steps) out var fieldResolution) || !fieldResolution.ContainsSchema(schemaName)) { + if (planNodeTemplate.OperationDefinition.Operation != OperationType.Query + && IsViewerFieldSelection(fieldPathItem.Node) + && HasViewerQueryRoot(schemaName, compositeSchema)) + { + finalSelectionSet = new SelectionSetNode( + [fieldPathItem.Node.WithSelectionSet(finalSelectionSet)]); + selectionSetIndexBuilder.Register( + planNodeTemplate.InternalOperationDefinition.SelectionSet, + finalSelectionSet); + fieldsMovedUp++; + viewerFallbackToQueryRoot = true; + break; + } + yield break; } @@ -2407,9 +2435,11 @@ public static int NextId(this ImmutableList steps) } } - // Even if we can walk up to the root of a non-Query operation, - // we want to bail here as we do not want two nodes with the same root fields. - if (planNodeTemplate.OperationDefinition.Operation != OperationType.Query) + // For mutations/subscriptions we generally avoid query-root fallback to prevent + // duplicate root operations. The viewer convention is the one supported exception, + // because cross-subgraph viewer fields are resolved via Query.viewer. + if (planNodeTemplate.OperationDefinition.Operation != OperationType.Query + && !IsViewerRootSelection(finalSelectionSet)) { yield break; } @@ -2418,19 +2448,40 @@ public static int NextId(this ImmutableList steps) selectionSetIndexBuilder.GetId(finalSelectionSet), finalSelectionSet, compositeSchema.QueryType, - SelectionPath.Root); + viewerFallbackToQueryRoot ? selectionSet.Path : SelectionPath.Root); var newRootWorkItem = workItem with { Kind = OperationWorkItemKind.Root, SelectionSet = newRootSelectionSet }; yield return (newRootWorkItem, fieldsMovedUp, selectionSetIndexBuilder); } + private static bool IsViewerRootSelection(SelectionSetNode selectionSet) + => selectionSet.Selections is [FieldNode field] && IsViewerFieldSelection(field); + + internal static bool IsViewerFieldSelection(FieldNode field) + => field is + { + Name.Value: "viewer", + Alias: null, + Arguments.Count: 0, + Directives.Count: 0 + }; + + private static bool HasViewerQueryRoot( + string schemaName, + FusionSchemaDefinition compositeSchema) + => compositeSchema.TryGetFieldResolution( + compositeSchema.QueryType, + "viewer", + out var viewerResolution) + && viewerResolution.ContainsSchema(schemaName); + private static Stack? ReverseSelectionPath( OperationDefinitionNode operationDefinitionNode, SelectionPath path, FusionSchemaDefinition compositeSchema) { - IOutputTypeDefinition currentType = compositeSchema.QueryType; + IOutputTypeDefinition currentType = compositeSchema.GetOperationType(operationDefinitionNode.Operation); var currentSelectionSetNode = operationDefinitionNode.SelectionSet; var items = new Stack(); diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Issue7996Tests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Issue7996Tests.cs new file mode 100644 index 00000000000..abd2238ad80 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Issue7996Tests.cs @@ -0,0 +1,81 @@ +using HotChocolate.Transport.Http; +using HotChocolate.Types.Composite; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion; + +public class Issue7996Tests : FusionTestBase +{ + [Fact] + public async Task Mutation_Returned_Viewer_Can_Resolve_Field_From_Another_Subgraph() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + b => b + .AddQueryType() + .AddMutationType()); + + using var serverB = CreateSourceSchema( + "B", + b => b.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new HotChocolate.Transport.OperationRequest( + """ + mutation { + doSomething { + something + viewer { + subgraphA + subgraphB + } + } + } + """); + + // act + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + private static class SourceSchemaA + { + public class Query + { + [Shareable] + public Viewer Viewer => new("subgraphA"); + } + + public class Mutation + { + public DoSomethingPayload DoSomething() => new(123, new Viewer("subgraphA")); + } + + public sealed record DoSomethingPayload(int Something, Viewer Viewer); + + public sealed record Viewer(string SubgraphA); + } + + private static class SourceSchemaB + { + public class Query + { + [Shareable] + public Viewer Viewer => new("subgraphB"); + } + + public sealed record Viewer(string SubgraphB); + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/Issue7996Tests.Mutation_Returned_Viewer_Can_Resolve_Field_From_Another_Subgraph.yaml b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/Issue7996Tests.Mutation_Returned_Viewer_Can_Resolve_Field_From_Another_Subgraph.yaml new file mode 100644 index 00000000000..dbfe6ceace4 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/Issue7996Tests.Mutation_Returned_Viewer_Can_Resolve_Field_From_Another_Subgraph.yaml @@ -0,0 +1,145 @@ +title: Mutation_Returned_Viewer_Can_Resolve_Field_From_Another_Subgraph +request: + document: | + mutation { + doSomething { + something + viewer { + subgraphA + subgraphB + } + } + } +response: + body: | + { + "data": { + "doSomething": { + "something": 123, + "viewer": { + "subgraphA": "subgraphA", + "subgraphB": "subgraphB" + } + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + mutation: Mutation + } + + type DoSomethingPayload { + something: Int! + viewer: Viewer! + } + + type Mutation { + doSomething: DoSomethingPayload! + } + + type Query { + viewer: Viewer! @shareable + } + + type Viewer { + subgraphA: String! + } + interactions: + - request: + document: | + mutation Op_f2f24951_1 { + doSomething { + something + viewer { + subgraphA + } + } + } + response: + results: + - | + { + "data": { + "doSomething": { + "something": 123, + "viewer": { + "subgraphA": "subgraphA" + } + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer! @shareable + } + + type Viewer { + subgraphB: String! + } + interactions: + - request: + document: | + query Op_f2f24951_2 { + viewer { + subgraphB + } + } + variables: | + {} + response: + results: + - | + { + "data": { + "viewer": { + "subgraphB": "subgraphB" + } + } + } +operationPlan: + operation: + - document: | + mutation { + doSomething { + something + viewer { + subgraphA + subgraphB + } + } + } + hash: f2f2495103ab7ab2216433f4deb197bf + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + mutation Op_f2f24951_1 { + doSomething { + something + viewer { + subgraphA + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_f2f24951_2 { + viewer { + subgraphB + } + } + source: $.viewer + target: $.doSomething.viewer