From d9ecbcb0ce99492c2af53febcea397473ab427e7 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Sun, 1 Dec 2024 16:03:56 +0100 Subject: [PATCH 1/3] Add initial support for conditional nodes --- .../Planning/Nodes/ConditionPlanNode.cs | 52 +++ .../Planning/Nodes/FieldPlanNode.cs | 15 +- .../Planning/Nodes/InlineFragmentPlanNode.cs | 2 +- .../Planning/Nodes/OperationPlanNode.cs | 4 +- .../Planning/Nodes/PlanNodeKind.cs | 3 +- .../Planning/Nodes/SelectionPlanNode.cs | 43 +- .../Planning/OperationPlanner.cs | 208 +++++++++- .../Planning/OperationVariableBinder.cs | 12 +- .../Fusion.Execution.Tests/ConditionTests.cs | 391 ++++++++++++++++++ .../Fusion.Execution.Tests/FusionTestBase.cs | 27 ++ .../OperationPlannerTests.cs | 66 +-- 11 files changed, 746 insertions(+), 77 deletions(-) create mode 100644 src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/ConditionPlanNode.cs create mode 100644 src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs create mode 100644 src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/FusionTestBase.cs diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/ConditionPlanNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/ConditionPlanNode.cs new file mode 100644 index 00000000000..305bc91a6c9 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/ConditionPlanNode.cs @@ -0,0 +1,52 @@ +using System.Text.Json; + +namespace HotChocolate.Fusion.Planning.Nodes; + +public sealed class ConditionPlanNode : PlanNode, ISerializablePlanNode, IPlanNodeProvider +{ + private readonly List _nodes = []; + + public ConditionPlanNode( + string variableName, + bool passingValue, + PlanNode? parent = null) + { + VariableName = variableName; + PassingValue = passingValue; + Parent = parent; + } + + /// + /// The name of the variable that controls if this node is executed. + /// + public string VariableName { get; } + + /// + /// The value the has to be, in order + /// for this node to be executed. + /// + public bool PassingValue { get; } + + public IReadOnlyList Nodes => _nodes; + + public void AddChildNode(PlanNode node) + { + ArgumentNullException.ThrowIfNull(node); + _nodes.Add(node); + node.Parent = this; + } + + public PlanNodeKind Kind => PlanNodeKind.Condition; + + public void Serialize(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + SerializationHelper.WriteKind(writer, this); + writer.WriteString("variableName", VariableName); + writer.WriteBoolean("passingValue", PassingValue); + SerializationHelper.WriteChildNodes(writer, this); + writer.WriteEndObject(); + } +} + +public record Condition(string VariableName, bool PassingValue); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/FieldPlanNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/FieldPlanNode.cs index 4db8fe3ba21..63e02dff79b 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/FieldPlanNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/FieldPlanNode.cs @@ -12,7 +12,7 @@ public sealed class FieldPlanNode : SelectionPlanNode public FieldPlanNode( FieldNode fieldNode, OutputFieldInfo field) - : base(field.Type.NamedType(), fieldNode.SelectionSet?.Selections) + : base(field.Type.NamedType(), fieldNode.SelectionSet?.Selections, fieldNode.Directives) { FieldNode = fieldNode; Field = field; @@ -38,7 +38,7 @@ public FieldPlanNode( public OutputFieldInfo Field { get; } public IReadOnlyList Arguments - => _arguments ?? (IReadOnlyList)Array.Empty(); + => _arguments ?? []; public void AddArgument(ArgumentAssignment argument) { @@ -49,10 +49,19 @@ public void AddArgument(ArgumentAssignment argument) public FieldNode ToSyntaxNode() { + var directives = new List(Directives.ToSyntaxNode()); + + foreach (var condition in Conditions) + { + var directiveName = condition.PassingValue ? "include" : "skip"; + directives.Add(new DirectiveNode(directiveName, + new ArgumentNode("if", new VariableNode(condition.VariableName)))); + } + return new FieldNode( new NameNode(Field.Name), Field.Name.Equals(ResponseName) ? null : new NameNode(ResponseName), - Directives.ToSyntaxNode(), + directives, Arguments.ToSyntaxNode(), Selections.Count == 0 ? null : Selections.ToSyntaxNode()); } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/InlineFragmentPlanNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/InlineFragmentPlanNode.cs index 723f6aeb4fa..919e9b00f59 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/InlineFragmentPlanNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/InlineFragmentPlanNode.cs @@ -8,7 +8,7 @@ public sealed class InlineFragmentPlanNode : SelectionPlanNode public InlineFragmentPlanNode( ICompositeNamedType declaringType, IReadOnlyList selectionNodes) - : base(declaringType, selectionNodes) + : base(declaringType, selectionNodes, []) { } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/OperationPlanNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/OperationPlanNode.cs index 51ca8375c99..aa7bbad8b6a 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/OperationPlanNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/OperationPlanNode.cs @@ -19,7 +19,7 @@ public OperationPlanNode( ICompositeNamedType declaringType, SelectionSetNode selectionSet, PlanNode? parent = null) - : base(declaringType, selectionSet.Selections) + : base(declaringType, selectionSet.Selections, []) { SchemaName = schemaName; Parent = parent; @@ -30,7 +30,7 @@ public OperationPlanNode( ICompositeNamedType declaringType, IReadOnlyList selections, PlanNode? parent = null) - : base(declaringType, selections) + : base(declaringType, selections, []) { SchemaName = schemaName; Parent = parent; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/PlanNodeKind.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/PlanNodeKind.cs index 0f0fd7db7ee..8fccdfe3539 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/PlanNodeKind.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/PlanNodeKind.cs @@ -3,5 +3,6 @@ namespace HotChocolate.Fusion.Planning; public enum PlanNodeKind { Root, - Operation + Operation, + Condition } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/SelectionPlanNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/SelectionPlanNode.cs index f283a984977..90ca75e7c71 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/SelectionPlanNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/Nodes/SelectionPlanNode.cs @@ -10,6 +10,7 @@ public abstract class SelectionPlanNode : PlanNode { private List? _directives; private List? _selections; + private List? _conditions; /// /// Initializes a new instance of . @@ -20,13 +21,36 @@ public abstract class SelectionPlanNode : PlanNode /// /// The child selection syntax nodes of this selection. /// + /// + /// The directives applied to this selection. + /// protected SelectionPlanNode( ICompositeNamedType declaringType, - IReadOnlyList? selectionNodes) + IReadOnlyList? selectionNodes, + IReadOnlyList directiveNodes) { DeclaringType = declaringType; IsEntity = declaringType.IsEntity(); SelectionNodes = selectionNodes; + + foreach (var directive in directiveNodes) + { + var isSkipDirective = directive.Name.Value.Equals("skip"); + var isIncludeDirective = directive.Name.Value.Equals("include"); + + // TODO: Ideally this would be just a lookup to the directive + if (isSkipDirective || isIncludeDirective) + { + var ifArgument = directive.Arguments.FirstOrDefault( + t => t.Name.Value.Equals("if")); + + if (ifArgument?.Value is VariableNode variableNode) + { + var condition = new Condition(variableNode.Name.Value, isIncludeDirective); + (_conditions ??= []).Add(condition); + } + } + } } /// @@ -56,6 +80,8 @@ public IReadOnlyList Directives public IReadOnlyList Selections => _selections ?? (IReadOnlyList)Array.Empty(); + public IReadOnlyList Conditions => _conditions ?? []; + /// /// Adds a child selection to this selection. /// @@ -66,7 +92,7 @@ public void AddSelection(SelectionPlanNode selection) { ArgumentNullException.ThrowIfNull(selection); - if(selection is OperationPlanNode) + if (selection is OperationPlanNode) { throw new NotSupportedException( "An operation cannot be a child of a selection."); @@ -87,4 +113,17 @@ public void AddDirective(CompositeDirective directive) ArgumentNullException.ThrowIfNull(directive); (_directives ??= []).Add(directive); } + + // TODO: Maybe remove + public void RemoveCondition(Condition condition) + { + ArgumentNullException.ThrowIfNull(condition); + + if (_conditions is null) + { + return; + } + + _conditions.Remove(condition); + } } 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 61516e24461..1a804e57d6d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using HotChocolate.Fusion.Planning.Nodes; using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Collections; using HotChocolate.Language; using HotChocolate.Types; @@ -27,7 +28,15 @@ public RootPlanNode CreatePlan(DocumentNode document, string? operationName) if (TryPlanSelectionSet(operation, operation, new Stack())) { - operationPlan.AddChildNode(operation); + if (TryPlanConditionNode(operation.Selections, out var conditionNode)) + { + conditionNode.AddChildNode(operation); + operationPlan.AddChildNode(conditionNode); + } + else + { + operationPlan.AddChildNode(operation); + } } } @@ -50,9 +59,16 @@ private bool TryPlanSelectionSet( List? unresolved = null; var type = (CompositeComplexType)parent.DeclaringType; + var haveConditionalSelectionsBeenRemoved = false; foreach (var selection in parent.SelectionNodes) { + if (IsSelectionAlwaysSkipped(selection)) + { + haveConditionalSelectionsBeenRemoved = true; + continue; + } + if (selection is FieldNode fieldNode) { if (!type.Fields.TryGetField(fieldNode.Name.Value, out var field)) @@ -64,8 +80,7 @@ private bool TryPlanSelectionSet( // if we have an operation plan node we have a pre-validated set of // root fields, so we now the field will be resolvable on the // source schema. - if (parent is OperationPlanNode - || IsResolvable(fieldNode, field, operation.SchemaName)) + if (parent is OperationPlanNode || IsResolvable(fieldNode, field, operation.SchemaName)) { var fieldNamedType = field.Type.NamedType(); @@ -87,9 +102,9 @@ private bool TryPlanSelectionSet( // if this field as a selection set it must be a object, interface or union type, // otherwise the validation should have caught this. So, we just throw here if this // is not the case. - if (fieldNamedType.Kind != TypeKind.Object - && fieldNamedType.Kind != TypeKind.Interface - && fieldNamedType.Kind != TypeKind.Union) + if (fieldNamedType.Kind != TypeKind.Object && + fieldNamedType.Kind != TypeKind.Interface && + fieldNamedType.Kind != TypeKind.Union) { throw new InvalidOperationException( "Only object, interface, or union types can have a selection set."); @@ -121,10 +136,30 @@ private bool TryPlanSelectionSet( } } - return skipUnresolved - || unresolved is null - || unresolved.Count == 0 - || TryHandleUnresolvedSelections(operation, parent, type, unresolved, path); + if (haveConditionalSelectionsBeenRemoved) + { + // If we have removed conditional selections from a composite field, we need to add a __typename field + // to have a valid selection set. + if (parent is FieldPlanNode fieldPlanNode && fieldPlanNode.Selections.Count == 0) + { + // TODO: How to properly create a __typename field? + var dummyType = new CompositeObjectType("Dummy", description: null, + fields: new CompositeOutputFieldCollection([])); + var outputFieldInfo = new OutputFieldInfo("__typename", dummyType, []); + fieldPlanNode.AddSelection(new FieldPlanNode(new FieldNode("__typename"), outputFieldInfo)); + } + // If we have removed conditional selections from an operation, we need to fail the creation + // of the operation as it would be invalid without any selections. + else if (parent is OperationPlanNode operationPlanNode && operationPlanNode.Selections.Count == 0) + { + return false; + } + } + + return skipUnresolved || + unresolved is null || + unresolved.Count == 0 || + TryHandleUnresolvedSelections(operation, parent, type, unresolved, path); } private bool TryHandleUnresolvedSelections( @@ -175,8 +210,8 @@ private bool TryHandleUnresolvedSelections( foreach (var unresolvedField in unresolved) { - if (unresolvedField.Field.Sources.ContainsSchema(schemaName) - && !processedFields.Contains(unresolvedField.Field.Name)) + if (unresolvedField.Field.Sources.ContainsSchema(schemaName) && + !processedFields.Contains(unresolvedField.Field.Name)) { fields.Add(unresolvedField.FieldNode); } @@ -191,7 +226,15 @@ private bool TryHandleUnresolvedSelections( continue; } - operation.AddChildNode(lookupOperation); + if (TryPlanConditionNode(lookupField.Selections, out var conditionNode)) + { + conditionNode.AddChildNode(lookupOperation); + operation.AddChildNode(conditionNode); + } + else + { + operation.AddChildNode(lookupOperation); + } foreach (var selection in lookupField.Selections) { @@ -267,8 +310,8 @@ private bool TryGetLookup(SelectionPlanNode selection, HashSet schemas, // is available for free. foreach (var schemaName in schemas) { - if (((CompositeComplexType)selection.DeclaringType).Sources.TryGetType(schemaName, out var source) - && source.Lookups.Length > 0) + if (((CompositeComplexType)selection.DeclaringType).Sources.TryGetType(schemaName, out var source) && + source.Lookups.Length > 0) { lookup = source.Lookups[0]; return true; @@ -288,8 +331,8 @@ private OperationPlanNode CreateLookupOperation( var lookupFieldNode = new FieldNode( new NameNode(lookup.Name), null, - Array.Empty(), - Array.Empty(), + [], + [], new SelectionSetNode(selections)); var selectionNodes = new ISelectionNode[] { lookupFieldNode }; @@ -370,6 +413,137 @@ private static Dictionary GetSchemasWeighted( return counts; } + private bool TryPlanConditionNode(IReadOnlyList selectionPlanNodes, + [NotNullWhen(true)] out ConditionPlanNode? conditionNode) + { + conditionNode = null; + // TODO: This is not correct + Condition? sharedCondition = null; + + foreach (var selection in selectionPlanNodes) + { + if (selection.Conditions.Count < 1) + { + return false; + } + + var condition = selection.Conditions[0]; + + if (sharedCondition is null) + { + sharedCondition = condition; + continue; + } + + // One of the selection doesn't have same condition as the others. + if (sharedCondition != condition) + { + return false; + } + } + + if (sharedCondition is not null) + { + RemoveConditionFromSelections(selectionPlanNodes, sharedCondition); + conditionNode = new ConditionPlanNode(sharedCondition.VariableName, sharedCondition.PassingValue); + + return true; + } + + return false; + } + + private bool IsSelectionAlwaysSkipped(ISelectionNode selectionNode) + { + var selectionIsSkipped = false; + foreach (var directive in selectionNode.Directives) + { + var isSkipDirective = directive.Name.Value == "skip"; + var isIncludedDirective = directive.Name.Value == "include"; + + if (isSkipDirective || isIncludedDirective) + { + var ifArgument = directive.Arguments.FirstOrDefault(a => a.Name.Value == "if"); + + if (ifArgument is not null) + { + if (ifArgument.Value is BooleanValueNode booleanValueNode) + { + if (booleanValueNode.Value && isSkipDirective) + { + selectionIsSkipped = true; + } + else if (!booleanValueNode.Value && isIncludedDirective) + { + selectionIsSkipped = true; + } + else + { + selectionIsSkipped = false; + } + } + else + { + selectionIsSkipped = false; + } + } + } + } + + return selectionIsSkipped; + } + + private (bool IsSelectionNodeObsolete, List? conditions) CreateConditions(ISelectionNode selectionNode) + { + List? conditions = null; + var isSelectionNodeObsolete = false; + + foreach (var directive in selectionNode.Directives) + { + var isSkipDirective = directive.Name.Value == "skip"; + var isIncludedDirective = directive.Name.Value == "include"; + + if (isSkipDirective || isIncludedDirective) + { + var ifArgument = directive.Arguments.FirstOrDefault(a => a.Name.Value == "if"); + + if (ifArgument is not null) + { + if (ifArgument.Value is VariableNode variableNode) + { + conditions ??= new List(); + conditions.Add(new Condition(variableNode.Name.Value, isIncludedDirective)); + } + else if (ifArgument.Value is BooleanValueNode booleanValueNode) + { + if (booleanValueNode.Value && isSkipDirective) + { + isSelectionNodeObsolete = true; + } + else if (!booleanValueNode.Value && isIncludedDirective) + { + isSelectionNodeObsolete = true; + } + else + { + isSelectionNodeObsolete = false; + } + } + } + } + } + + return (isSelectionNodeObsolete, conditions); + } + + private void RemoveConditionFromSelections(IReadOnlyList selectionPlanNodes, Condition condition) + { + foreach (var selection in selectionPlanNodes) + { + selection.RemoveCondition(condition); + } + } + public record SelectionPathSegment( SelectionPlanNode PlanNode); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationVariableBinder.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationVariableBinder.cs index c701ae22d23..26fdbd0588a 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationVariableBinder.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationVariableBinder.cs @@ -9,16 +9,11 @@ public static void BindOperationVariables( OperationDefinitionNode operationDefinition, RootPlanNode operationPlan) { - var operationBacklog = new Stack(); + var operationBacklog = new Stack(operationPlan.Nodes.OfType()); var selectionBacklog = new Stack(); var variableDefinitions = operationDefinition.VariableDefinitions.ToDictionary(t => t.Variable.Name.Value); var usedVariables = new HashSet(); - foreach (var operation in operationPlan.Nodes.OfType()) - { - operationBacklog.Push(operation); - } - while (operationBacklog.TryPop(out var operation)) { CollectAndBindUsedVariables(operation, variableDefinitions, usedVariables, selectionBacklog); @@ -62,6 +57,11 @@ private static void CollectAndBindUsedVariables( } } } + + foreach (var condition in field.Conditions) + { + usedVariables.Add(condition.VariableName); + } } foreach (var selection in node.Selections) diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs new file mode 100644 index 00000000000..969bbb57861 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs @@ -0,0 +1,391 @@ +namespace HotChocolate.Fusion; + +public class ConditionTests : FusionTestBase +{ + [Test] + public async Task Skip_On_SubField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!) { + productById(id: $id) { + name @skip(if: $skip) + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!, $skip: Boolean!) { productById(id: $id) { name @skip(if: $skip) } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_If_False_On_SubField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name @skip(if: false) + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_If_True_On_SubField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name @skip(if: true) + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { __typename } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_If_True_On_SubField_With_Other_SubFields() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + id + name @skip(if: true) + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { id } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_Of_Another_Subgraph() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!) { + productById(id: $id) { + name + reviews(first: 10) @skip(if: $skip) { + nodes { + body + } + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }", + "nodes": [ + { + "kind": "Condition", + "variableName": "skip", + "passingValue": false, + "nodes": [ + { + "kind": "Operation", + "schema": "REVIEWS", + "document": "{ productById { reviews(first: 10) { nodes { body } } } }" + } + ] + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_If_False_On_SubField_Of_Another_Subgraph() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name + reviews(first: 10) @skip(if: false) { + nodes { + body + } + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }", + "nodes": [ + { + "kind": "Operation", + "schema": "REVIEWS", + "document": "{ productById { reviews(first: 10) { nodes { body } } } }" + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_If_True_On_SubField_Of_Another_Subgraph() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name + reviews(first: 10) @skip(if: true) { + nodes { + body + } + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_RootField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!) { + productById(id: $id) @skip(if: $skip) { + name + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Condition", + "variableName": "skip", + "passingValue": false, + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "{ productById(id: $id) { name } }" + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_If_False_On_RootField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) @skip(if: false) { + name + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_If_True_On_RootField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) @skip(if: true) { + name + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root" + } + """); + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/FusionTestBase.cs new file mode 100644 index 00000000000..028b82a5936 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/FusionTestBase.cs @@ -0,0 +1,27 @@ +using HotChocolate.Fusion.Planning; +using HotChocolate.Fusion.Planning.Nodes; +using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Completion; +using HotChocolate.Language; + +namespace HotChocolate.Fusion; + +public abstract class FusionTestBase +{ + protected static CompositeSchema CreateCompositeSchema() + { + var compositeSchemaDoc = Utf8GraphQLParser.Parse(FileResource.Open("fusion1.graphql")); + return CompositeSchemaBuilder.Create(compositeSchemaDoc); + } + + protected static RootPlanNode PlanOperationAsync(CompositeSchema compositeSchema, string operation) + { + var doc = Utf8GraphQLParser.Parse(operation); + + var rewriter = new InlineFragmentOperationRewriter(compositeSchema); + var rewritten = rewriter.RewriteDocument(doc, null); + + var planner = new OperationPlanner(compositeSchema); + return planner.CreatePlan(rewritten, null); + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/OperationPlannerTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/OperationPlannerTests.cs index cf0108a5f5a..1f114b8f420 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/OperationPlannerTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/OperationPlannerTests.cs @@ -1,18 +1,16 @@ -using HotChocolate.Fusion.Planning; -using HotChocolate.Fusion.Types.Completion; -using HotChocolate.Language; - namespace HotChocolate.Fusion; -public class OperationPlannerTests +public class OperationPlannerTests : FusionTestBase { [Test] public async Task Plan_Simple_Operation_1_Source_Schema() { - var compositeSchemaDoc = Utf8GraphQLParser.Parse(FileResource.Open("fusion1.graphql")); - var compositeSchema = CompositeSchemaBuilder.Create(compositeSchemaDoc); + // arrange + var compositeSchema = CreateCompositeSchema(); - var doc = Utf8GraphQLParser.Parse( + // act + var plan = PlanOperationAsync( + compositeSchema, """ { productById(id: 1) { @@ -26,13 +24,6 @@ fragment Product on Product { } """); - var rewriter = new InlineFragmentOperationRewriter(compositeSchema); - var rewritten = rewriter.RewriteDocument(doc, null); - - // act - var planner = new OperationPlanner(compositeSchema); - var plan = planner.CreatePlan(rewritten, null); - // assert await Assert .That(plan.Serialize()) @@ -54,10 +45,12 @@ await Assert [Test] public async Task Plan_Simple_Operation_2_Source_Schema() { - var compositeSchemaDoc = Utf8GraphQLParser.Parse(FileResource.Open("fusion1.graphql")); - var compositeSchema = CompositeSchemaBuilder.Create(compositeSchemaDoc); + // arrange + var compositeSchema = CreateCompositeSchema(); - var doc = Utf8GraphQLParser.Parse( + // act + var plan = PlanOperationAsync( + compositeSchema, """ { productById(id: 1) { @@ -72,13 +65,6 @@ fragment Product on Product { } """); - var rewriter = new InlineFragmentOperationRewriter(compositeSchema); - var rewritten = rewriter.RewriteDocument(doc, null); - - // act - var planner = new OperationPlanner(compositeSchema); - var plan = planner.CreatePlan(rewritten, null); - // assert await Assert .That(plan.Serialize()) @@ -107,10 +93,12 @@ await Assert [Test] public async Task Plan_Simple_Operation_3_Source_Schema() { - var compositeSchemaDoc = Utf8GraphQLParser.Parse(FileResource.Open("fusion1.graphql")); - var compositeSchema = CompositeSchemaBuilder.Create(compositeSchemaDoc); + // arrange + var compositeSchema = CreateCompositeSchema(); - var doc = Utf8GraphQLParser.Parse( + // act + var plan = PlanOperationAsync( + compositeSchema, """ { productById(id: 1) { @@ -140,13 +128,6 @@ fragment AuthorCard on UserProfile { } """); - var rewriter = new InlineFragmentOperationRewriter(compositeSchema); - var rewritten = rewriter.RewriteDocument(doc, null); - - // act - var planner = new OperationPlanner(compositeSchema); - var plan = planner.CreatePlan(rewritten, null); - // assert await Assert .That(plan.Serialize()) @@ -182,10 +163,12 @@ await Assert [Test] public async Task Plan_Simple_Operation_3_Source_Schema_And_Single_Variable() { - var compositeSchemaDoc = Utf8GraphQLParser.Parse(FileResource.Open("fusion1.graphql")); - var compositeSchema = CompositeSchemaBuilder.Create(compositeSchemaDoc); + // arrange + var compositeSchema = CreateCompositeSchema(); - var doc = Utf8GraphQLParser.Parse( + // act + var plan = PlanOperationAsync( + compositeSchema, """ query GetProduct($id: ID!, $first: Int! = 10) { productById(id: $id) { @@ -215,13 +198,6 @@ fragment AuthorCard on UserProfile { } """); - var rewriter = new InlineFragmentOperationRewriter(compositeSchema); - var rewritten = rewriter.RewriteDocument(doc, null); - - // act - var planner = new OperationPlanner(compositeSchema); - var plan = planner.CreatePlan(rewritten, null); - // assert await Assert .That(plan.Serialize()) From 5d1f32d18f1d5e0a10f321283a4901e79274b316 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:17:33 +0100 Subject: [PATCH 2/3] Add more tests --- .../Fusion.Execution.Tests/ConditionTests.cs | 358 +++++++++++++++++- .../__resources__/fusion1.graphql | 1 + 2 files changed, 348 insertions(+), 11 deletions(-) diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs index 969bbb57861..164bd1223d2 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs @@ -8,6 +8,114 @@ public async Task Skip_On_SubField() // arrange var compositeSchema = CreateCompositeSchema(); + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!) { + productById(id: $id) { + name @skip(if: $skip) + description + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!, $skip: Boolean!) { productById(id: $id) { name @skip(if: $skip) description } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_If_False() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name @skip(if: false) + description + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name description } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_If_True() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name @skip(if: true) + description + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { description } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_Only_Skipped_Field_Selected() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + // act var plan = PlanOperationAsync( compositeSchema, @@ -38,7 +146,7 @@ await Assert } [Test] - public async Task Skip_If_False_On_SubField() + public async Task Skip_On_SubField_Only_Skipped_Field_Selected_If_False() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -73,7 +181,7 @@ await Assert } [Test] - public async Task Skip_If_True_On_SubField() + public async Task Skip_On_SubField_Only_Skipped_Field_Selected_If_True() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -108,7 +216,55 @@ await Assert } [Test] - public async Task Skip_If_True_On_SubField_With_Other_SubFields() + public async Task Skip_On_SubField_Resolved_From_Other_Source() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!) { + productById(id: $id) { + name + averageRating + reviews(first: 10) @skip(if: $skip) { + nodes { + body + } + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }", + "nodes": [ + { + "kind": "Operation", + "schema": "REVIEWS", + "document": "query($skip: Boolean!) { productById { averageRating reviews(first: 10) @skip(if: $skip) { nodes { body } } } }" + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_Resolved_From_Other_Source_If_False() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -119,8 +275,61 @@ public async Task Skip_If_True_On_SubField_With_Other_SubFields() """ query GetProduct($id: ID!) { productById(id: $id) { - id - name @skip(if: true) + name + averageRating + reviews(first: 10) @skip(if: false) { + nodes { + body + } + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } }", + "nodes": [ + { + "kind": "Operation", + "schema": "REVIEWS", + "document": "{ productById { averageRating reviews(first: 10) { nodes { body } } } }" + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_Resolved_From_Other_Source_If_True() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + name + averageRating + reviews(first: 10) @skip(if: true) { + nodes { + body + } + } } } """); @@ -136,7 +345,14 @@ await Assert { "kind": "Operation", "schema": "PRODUCTS", - "document": "query($id: ID!) { productById(id: $id) { id } }" + "document": "query($id: ID!) { productById(id: $id) { name } }", + "nodes": [ + { + "kind": "Operation", + "schema": "REVIEWS", + "document": "{ productById { averageRating } }" + } + ] } ] } @@ -144,7 +360,7 @@ await Assert } [Test] - public async Task Skip_On_SubField_Of_Another_Subgraph() + public async Task Skip_On_SubField_Resolved_From_Other_Source_Only_Skipped_Field_Selected() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -198,7 +414,7 @@ await Assert } [Test] - public async Task Skip_If_False_On_SubField_Of_Another_Subgraph() + public async Task Skip_On_SubField_Resolved_From_Other_Source_Only_Skipped_Field_Selected_If_False() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -245,7 +461,7 @@ await Assert } [Test] - public async Task Skip_If_True_On_SubField_Of_Another_Subgraph() + public async Task Skip_On_SubField_Resolved_From_Other_Source_Only_Skipped_Field_Selected_If_True() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -290,6 +506,126 @@ public async Task Skip_On_RootField() // arrange var compositeSchema = CreateCompositeSchema(); + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!) { + productById(id: $id) @skip(if: $skip) { + name + } + products { + nodes { + name + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!, $skip: Boolean!) { productById(id: $id) @skip(if: $skip) { name } products { nodes { name } } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_RootField_If_False() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) @skip(if: false) { + name + } + products { + nodes { + name + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!) { productById(id: $id) { name } products { nodes { name } } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_RootField_If_True() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) @skip(if: true) { + name + } + products { + nodes { + name + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "{ products { nodes { name } } }" + } + ] + } + """); + } + + [Test] + public async Task Skip_On_RootField_Only_Skipped_Field_Selected() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + // act var plan = PlanOperationAsync( compositeSchema, @@ -327,7 +663,7 @@ await Assert } [Test] - public async Task Skip_If_False_On_RootField() + public async Task Skip_On_RootField_Only_Skipped_Field_Selected_If_False() { // arrange var compositeSchema = CreateCompositeSchema(); @@ -362,7 +698,7 @@ await Assert } [Test] - public async Task Skip_If_True_On_RootField() + public async Task Skip_On_RootField_Only_Skipped_Field_Selected_If_True() { // arrange var compositeSchema = CreateCompositeSchema(); diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/__resources__/fusion1.graphql b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/__resources__/fusion1.graphql index 46b412e9e08..81047170eb2 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/__resources__/fusion1.graphql +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/__resources__/fusion1.graphql @@ -42,6 +42,7 @@ type Product @fusion__field(schema: PRODUCTS) dimension: ProductDimension! @fusion__field(schema: PRODUCTS) + averageRating: Int! @fusion__field(schema: REVIEWS) reviews(first: Int, after: String, last: Int, before: String): ProductReviewConnection @fusion__field(schema: REVIEWS) estimatedDelivery(postCode: String): Int! From 783f3050b5c62793db6468801d743969a6098ee1 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Sun, 1 Dec 2024 21:14:14 +0100 Subject: [PATCH 3/3] @skip and @include on same field --- .../Planning/OperationPlanner.cs | 92 +++++++++---------- .../Fusion.Execution.Tests/ConditionTests.cs | 89 ++++++++++++++++++ 2 files changed, 132 insertions(+), 49 deletions(-) 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 1a804e57d6d..593226206b3 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -28,15 +28,8 @@ public RootPlanNode CreatePlan(DocumentNode document, string? operationName) if (TryPlanSelectionSet(operation, operation, new Stack())) { - if (TryPlanConditionNode(operation.Selections, out var conditionNode)) - { - conditionNode.AddChildNode(operation); - operationPlan.AddChildNode(conditionNode); - } - else - { - operationPlan.AddChildNode(operation); - } + var planNodeToAdd = PlanConditionNode(operation.Selections, operation); + operationPlan.AddChildNode(planNodeToAdd); } } @@ -226,15 +219,8 @@ private bool TryHandleUnresolvedSelections( continue; } - if (TryPlanConditionNode(lookupField.Selections, out var conditionNode)) - { - conditionNode.AddChildNode(lookupOperation); - operation.AddChildNode(conditionNode); - } - else - { - operation.AddChildNode(lookupOperation); - } + var planNodeToAdd = PlanConditionNode(lookupField.Selections, lookupOperation); + operation.AddChildNode(planNodeToAdd); foreach (var selection in lookupField.Selections) { @@ -413,44 +399,60 @@ private static Dictionary GetSchemasWeighted( return counts; } - private bool TryPlanConditionNode(IReadOnlyList selectionPlanNodes, - [NotNullWhen(true)] out ConditionPlanNode? conditionNode) + private PlanNode PlanConditionNode( + IReadOnlyList selectionPlanNodes, + OperationPlanNode operation) { - conditionNode = null; - // TODO: This is not correct - Condition? sharedCondition = null; - - foreach (var selection in selectionPlanNodes) + var firstSelection = selectionPlanNodes.FirstOrDefault(); + if (firstSelection is null || firstSelection.Conditions.Count == 0) { - if (selection.Conditions.Count < 1) - { - return false; - } + return operation; + } - var condition = selection.Conditions[0]; + var conditionsOnFirstSelectionNode = new HashSet(firstSelection.Conditions); - if (sharedCondition is null) + foreach (var selection in selectionPlanNodes.Skip(1)) + { + if (selection.Conditions.Count == 0) { - sharedCondition = condition; - continue; + return operation; } - // One of the selection doesn't have same condition as the others. - if (sharedCondition != condition) + foreach (var condition in selection.Conditions) { - return false; + if (!conditionsOnFirstSelectionNode.Contains(condition)) + { + return operation; + } } } - if (sharedCondition is not null) + ConditionPlanNode? startConditionNode = null; + ConditionPlanNode? lastConditionNode = null; + + foreach (var sharedCondition in conditionsOnFirstSelectionNode) { - RemoveConditionFromSelections(selectionPlanNodes, sharedCondition); - conditionNode = new ConditionPlanNode(sharedCondition.VariableName, sharedCondition.PassingValue); + foreach (var selection in selectionPlanNodes) + { + selection.RemoveCondition(sharedCondition); + } - return true; + if (startConditionNode is null) + { + startConditionNode = lastConditionNode = + new ConditionPlanNode(sharedCondition.VariableName, sharedCondition.PassingValue); + } + else if (lastConditionNode is not null) + { + var childCondition = new ConditionPlanNode(sharedCondition.VariableName, sharedCondition.PassingValue); + lastConditionNode.AddChildNode(childCondition); + lastConditionNode = childCondition; + } } - return false; + lastConditionNode?.AddChildNode(operation); + + return startConditionNode!; } private bool IsSelectionAlwaysSkipped(ISelectionNode selectionNode) @@ -536,14 +538,6 @@ private bool IsSelectionAlwaysSkipped(ISelectionNode selectionNode) return (isSelectionNodeObsolete, conditions); } - private void RemoveConditionFromSelections(IReadOnlyList selectionPlanNodes, Condition condition) - { - foreach (var selection in selectionPlanNodes) - { - selection.RemoveCondition(condition); - } - } - public record SelectionPathSegment( SelectionPlanNode PlanNode); diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs index 164bd1223d2..73af309604c 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs @@ -724,4 +724,93 @@ await Assert } """); } + + [Test] + public async Task Skip_And_Include_On_RootField_Only_Skipped_Field_Selected() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!, $include: Boolean!) { + productById(id: $id) @skip(if: $skip) @include(if: $include) { + name + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Condition", + "variableName": "skip", + "passingValue": false, + "nodes": [ + { + "kind": "Condition", + "variableName": "include", + "passingValue": true, + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "{ productById(id: $id) { name } }" + } + ] + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_And_Include_On_RootField() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!, $skip: Boolean!, $include: Boolean!) { + productById(id: $id) @skip(if: $skip) @include(if: $include) { + name + } + products { + nodes { + name + } + } + } + """); + + // assert + await Assert + .That(plan.Serialize()) + .IsEqualTo( + """ + { + "kind": "Root", + "nodes": [ + { + "kind": "Operation", + "schema": "PRODUCTS", + "document": "query($id: ID!, $include: Boolean!, $skip: Boolean!) { productById(id: $id) @skip(if: $skip) @include(if: $include) { name } products { nodes { name } } }" + } + ] + } + """); + } }