Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,12 @@ internal ImmutableArray<VariableValues> CreateVariableValueSets(
{
if (forwardedVariables.Length == 0)
{
return [];
if (selectionSet.IsRoot)
{
return [];
}

return [new VariableValues(ToResultPath(selectionSet), new ObjectValueNode([]))];
}

var variableValues = GetPathThroughVariables(forwardedVariables);
Expand Down Expand Up @@ -257,6 +262,21 @@ internal ImmutableArray<VariableValues> 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<SourceSchemaResult> results,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2327,6 +2340,7 @@ public static int NextId(this ImmutableList<PlanStep> steps)
var segments = selectionSet.Path.Segments;
var finalSelectionSet = selectionSet.Node;
var fieldsMovedUp = 0;
var viewerFallbackToQueryRoot = false;

while (pathItems.TryPop(out var pathItem))
{
Expand All @@ -2338,6 +2352,20 @@ public static int NextId(this ImmutableList<PlanStep> 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;
}

Expand Down Expand Up @@ -2407,9 +2435,11 @@ public static int NextId(this ImmutableList<PlanStep> 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;
}
Expand All @@ -2418,19 +2448,40 @@ public static int NextId(this ImmutableList<PlanStep> 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<IPathItem>? 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<IPathItem>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SourceSchemaA.Query>()
.AddMutationType<SourceSchemaA.Mutation>());

using var serverB = CreateSourceSchema(
"B",
b => b.AddQueryType<SourceSchemaB.Query>());

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);
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading