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..593226206b3 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,8 @@ public RootPlanNode CreatePlan(DocumentNode document, string? operationName) if (TryPlanSelectionSet(operation, operation, new Stack())) { - operationPlan.AddChildNode(operation); + var planNodeToAdd = PlanConditionNode(operation.Selections, operation); + operationPlan.AddChildNode(planNodeToAdd); } } @@ -50,9 +52,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 +73,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 +95,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 +129,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 +203,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 +219,8 @@ private bool TryHandleUnresolvedSelections( continue; } - operation.AddChildNode(lookupOperation); + var planNodeToAdd = PlanConditionNode(lookupField.Selections, lookupOperation); + operation.AddChildNode(planNodeToAdd); foreach (var selection in lookupField.Selections) { @@ -267,8 +296,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 +317,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 +399,145 @@ private static Dictionary GetSchemasWeighted( return counts; } + private PlanNode PlanConditionNode( + IReadOnlyList selectionPlanNodes, + OperationPlanNode operation) + { + var firstSelection = selectionPlanNodes.FirstOrDefault(); + if (firstSelection is null || firstSelection.Conditions.Count == 0) + { + return operation; + } + + var conditionsOnFirstSelectionNode = new HashSet(firstSelection.Conditions); + + foreach (var selection in selectionPlanNodes.Skip(1)) + { + if (selection.Conditions.Count == 0) + { + return operation; + } + + foreach (var condition in selection.Conditions) + { + if (!conditionsOnFirstSelectionNode.Contains(condition)) + { + return operation; + } + } + } + + ConditionPlanNode? startConditionNode = null; + ConditionPlanNode? lastConditionNode = null; + + foreach (var sharedCondition in conditionsOnFirstSelectionNode) + { + foreach (var selection in selectionPlanNodes) + { + selection.RemoveCondition(sharedCondition); + } + + 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; + } + } + + lastConditionNode?.AddChildNode(operation); + + return startConditionNode!; + } + + 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); + } + 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..73af309604c --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Execution.Tests/ConditionTests.cs @@ -0,0 +1,816 @@ +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) + 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, + """ + 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_On_SubField_Only_Skipped_Field_Selected_If_False() + { + // 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_On_SubField_Only_Skipped_Field_Selected_If_True() + { + // 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_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(); + + // act + var plan = PlanOperationAsync( + compositeSchema, + """ + query GetProduct($id: ID!) { + productById(id: $id) { + 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 + } + } + } + } + """); + + // 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 } }" + } + ] + } + ] + } + """); + } + + [Test] + public async Task Skip_On_SubField_Resolved_From_Other_Source_Only_Skipped_Field_Selected() + { + // 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_On_SubField_Resolved_From_Other_Source_Only_Skipped_Field_Selected_If_False() + { + // 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_On_SubField_Resolved_From_Other_Source_Only_Skipped_Field_Selected_If_True() + { + // 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 + } + 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, + """ + 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_On_RootField_Only_Skipped_Field_Selected_If_False() + { + // 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_On_RootField_Only_Skipped_Field_Selected_If_True() + { + // 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" + } + """); + } + + [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 } } }" + } + ] + } + """); + } +} 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 31f38aa4ffe..aa0cd4e05ba 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 void 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 plan.Serialize().MatchSnapshot(); } @@ -40,10 +31,12 @@ fragment Product on Product { [Test] public void 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) { @@ -58,13 +51,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 plan.Serialize().MatchSnapshot(); } @@ -72,10 +58,12 @@ fragment Product on Product { [Test] public void 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) { @@ -105,13 +93,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 plan.Serialize().MatchSnapshot(); } @@ -119,10 +100,12 @@ fragment AuthorCard on UserProfile { [Test] public void 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) { @@ -152,13 +135,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 plan.Serialize().MatchSnapshot(); } 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!