diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs index bda3fd10888..c80a4e75930 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationCompiler.cs @@ -291,13 +291,12 @@ private SelectionSet BuildSelectionSet( var includeFlags = new List(); var deferUsages = new List(); var selectionSetId = ++lastId; - var alwaysIncluded = false; - foreach (var (responseName, nodes) in fieldMap) { includeFlags.Clear(); deferUsages.Clear(); + var alwaysIncluded = false; var first = nodes[0]; var isInternal = IsInternal(first.Node); var hasNonDeferredNode = first.DeferUsage is null; diff --git a/src/HotChocolate/Core/test/Execution.Tests/Processing/SelectionIncludeConditionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Processing/SelectionIncludeConditionTests.cs index 0153c571adf..433a6444219 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Processing/SelectionIncludeConditionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Processing/SelectionIncludeConditionTests.cs @@ -758,12 +758,134 @@ fragment B on Person { """); } + [Fact] + public async Task Complementary_Fragment_Spreads_Should_Use_Full_Fragment_When_Minimal_Is_False() + { + // arrange + var result = await ExecuteComplementaryFragmentQueryAsync(minimal: false); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "series": [ + { + "streams": [ + { + "__typename": "Stream", + "id": "stream-1", + "hasDrm": true, + "title": "Stream One", + "publishedAt": "2026-01-01" + } + ] + } + ] + } + } + """); + } + + [Fact] + public async Task Complementary_Fragment_Spreads_Should_Use_Minimal_Fragment_When_Minimal_Is_True() + { + // arrange + var result = await ExecuteComplementaryFragmentQueryAsync(minimal: true); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "series": [ + { + "streams": [ + { + "__typename": "Stream", + "id": "stream-1", + "hasDrm": true + } + ] + } + ] + } + } + """); + } + + private static async Task ExecuteComplementaryFragmentQueryAsync(bool minimal) + { + var schema = SchemaBuilder.New() + .AddDocumentFromString( + """ + type Query { + series: [Series!]! + } + + type Series { + id: ID! + streams: [Stream!]! + } + + type Stream { + id: ID! + title: String + hasDrm: Boolean + publishedAt: String + } + """) + .AddResolver() + .Create(); + + return await schema.MakeExecutable().ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + query TestQuery($minimal: Boolean = false) { + series { + streams { + __typename + ...streamFields + } + } + } + + fragment streamFields on Stream { + __typename + ...MinimalStream @include(if: $minimal) + ...FullStream @skip(if: $minimal) + } + + fragment MinimalStream on Stream { + id + hasDrm + } + + fragment FullStream on Stream { + id + title + hasDrm + publishedAt + } + """) + .SetVariableValues( + $$""" + { + "minimal": {{minimal.ToString().ToLowerInvariant()}} + } + """) + .Build()); + } + public sealed class Query { public Person Person() => new Person(); [UsePaging] public Person[] Persons() => [new Person()]; + + public Series[] Series() => [new Series()]; } public sealed class Person @@ -772,4 +894,22 @@ public sealed class Person public string Address { get; } = "world"; } + + public sealed class Series + { + public string Id { get; } = "series-1"; + + public Stream[] Streams() => [new Stream()]; + } + + public sealed class Stream + { + public string Id { get; } = "stream-1"; + + public string Title { get; } = "Stream One"; + + public bool HasDrm { get; } = true; + + public string PublishedAt { get; } = "2026-01-01"; + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs index ba8445ac0f0..1cc342edd7b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs @@ -424,8 +424,10 @@ private ImmutableArray GetPossibleLookupsInternal(ITypeDefinition type, } /// - /// Tries to get the best direct lookup to transition from one schema to another without intermediary transitions. - /// The best lookup algorithm will try to find the smallest possible key that does not require any intermediary transitions. + /// Tries to get the best direct lookup to transition from one schema + /// to another without intermediary transitions. + /// The best lookup algorithm will try to find the smallest possible key that does not + /// require any intermediary transitions. /// /// The type to get the best direct lookup for. /// The schemas to get the best direct lookup from. @@ -442,7 +444,7 @@ public bool TryGetBestDirectLookup( ArgumentNullException.ThrowIfNull(fromSchemas); ArgumentException.ThrowIfNullOrEmpty(toSchema); - foreach (var fromSchema in fromSchemas) + foreach (var fromSchema in fromSchemas.OrderBy(static t => t, StringComparer.Ordinal)) { if (TryGetBestDirectLookup(type, fromSchema, toSchema, out lookup)) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs index e6a837457aa..7c4e0ff9bd9 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs @@ -242,8 +242,10 @@ public ulong CreateIncludeFlags(IVariableValueCollection variables) { if (includeCondition.IsIncluded(variables)) { - includeFlags |= 1ul << index++; + includeFlags |= 1ul << index; } + + index++; } return includeFlags; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index 12742573734..990a4cf5269 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -245,14 +245,12 @@ private SelectionSet BuildSelectionSet( includeFlags.Clear(); deliveryGroups.Clear(); + var alwaysIncluded = false; var first = nodes[0]; - var isInternal = IsInternal(first.Node); + var isInternal = IsInternal(nodes); var hasImmediateNode = first.DeliveryGroup is null; - if (first.PathIncludeFlags > 0) - { - includeFlags.Add(first.PathIncludeFlags); - } + AddIncludeFlags(first, isInternal, includeFlags, ref alwaysIncluded); if (first.DeliveryGroup is not null) { @@ -271,10 +269,7 @@ private SelectionSet BuildSelectionSet( $"The syntax nodes for the response name {responseName} are not all the same."); } - if (next.PathIncludeFlags > 0) - { - includeFlags.Add(next.PathIncludeFlags); - } + AddIncludeFlags(next, isInternal, includeFlags, ref alwaysIncluded); if (next.DeliveryGroup is null) { @@ -284,11 +279,6 @@ private SelectionSet BuildSelectionSet( { deliveryGroups.Add(next.DeliveryGroup); } - - if (isInternal) - { - isInternal = IsInternal(next.Node); - } } } @@ -413,6 +403,44 @@ private static void CollapseIncludeFlags(List includeFlags) } } + private static void AddIncludeFlags( + FieldSelectionNode node, + bool isInternalSelection, + List includeFlags, + ref bool alwaysIncluded) + { + if (!isInternalSelection && IsInternal(node.Node)) + { + return; + } + + if (node.PathIncludeFlags == 0) + { + alwaysIncluded = true; + if (includeFlags.Count > 0) + { + includeFlags.Clear(); + } + } + else if (!alwaysIncluded) + { + includeFlags.Add(node.PathIncludeFlags); + } + } + + private static bool IsInternal(List nodes) + { + for (var i = 0; i < nodes.Count; i++) + { + if (!IsInternal(nodes[i].Node)) + { + return false; + } + } + + return true; + } + private bool DoesTypeApply(NamedTypeNode? typeCondition, IObjectTypeDefinition typeContext) { if (typeCondition is null) diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ConditionalTests.NodeField_Skip_On_Interface_Selection_Type_Refinement_With_Same_Unskipped_Selection.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ConditionalTests.NodeField_Skip_On_Interface_Selection_Type_Refinement_With_Same_Unskipped_Selection.yaml index 8646d84474a..0eb9b0c7e87 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ConditionalTests.NodeField_Skip_On_Interface_Selection_Type_Refinement_With_Same_Unskipped_Selection.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ConditionalTests.NodeField_Skip_On_Interface_Selection_Type_Refinement_With_Same_Unskipped_Selection.yaml @@ -23,7 +23,11 @@ response: body: | { "data": { - "node": {} + "node": { + "author": { + "rating": 123 + } + } } } sourceSchemas: diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs index fa9f04abe45..d8d4acb932e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs @@ -1,5 +1,7 @@ +using System.Text; using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Rewriters; using HotChocolate.Fusion.Types; using HotChocolate.Language; using HotChocolate.Types; @@ -160,6 +162,119 @@ id @fusion__requirement Assert.True(id.IsInternal); } + [Fact] + public void Complementary_Fragment_Spreads_Should_Use_Full_Fragment_When_Minimal_Is_False() + { + // arrange + var includedResponseNames = GetComplementaryFragmentIncludedResponseNames(minimal: false); + + // assert + includedResponseNames.MatchInlineSnapshot( + """ + __typename + id + hasDrm + title + publishedAt + """); + } + + [Fact] + public void Complementary_Fragment_Spreads_Should_Use_Minimal_Fragment_When_Minimal_Is_True() + { + // arrange + var includedResponseNames = GetComplementaryFragmentIncludedResponseNames(minimal: true); + + // assert + includedResponseNames.MatchInlineSnapshot( + """ + __typename + id + hasDrm + """); + } + + [Fact] + public void Unconditional_Field_Should_Remain_Included_When_Conditional_Duplicate_Is_Excluded() + { + // arrange + const string sourceText = + """ + query ($if1: Boolean!) { + product { + name + name @include(if: $if1) + } + } + """; + + var document = Utf8GraphQLParser.Parse(sourceText); + var operationDefinition = document.Definitions.OfType().First(); + var schema = CreateSchema(); + + var booleanType = schema.Types.GetType("Boolean"); + var nonNullBooleanType = new NonNullType(booleanType); + + var variableValues = new VariableValueCollection( + new Dictionary + { + { "if1", new VariableValue("if1", nonNullBooleanType, BooleanValueNode.False) } + }); + + // act + var compiler = new OperationCompiler(schema, _fieldMapPool); + var operation = compiler.Compile("1", "1", operationDefinition); + var flags = operation.CreateIncludeFlags(variableValues); + + // assert + var product = GetSelection(operation.RootSelectionSet, "product"); + var productSelectionSet = operation.GetSelectionSet(product); + var name = GetSelection(productSelectionSet, "name"); + + Assert.True(name.IsIncluded(flags)); + } + + [Fact] + public void Internal_Field_Should_Not_Make_Conditional_User_Field_Always_Included() + { + // arrange + const string sourceText = + """ + query ($skip: Boolean!) { + product { + __typename @fusion__requirement + __typename @skip(if: $skip) + } + } + """; + + var document = Utf8GraphQLParser.Parse(sourceText); + var operationDefinition = document.Definitions.OfType().First(); + var schema = CreateSchema(); + + var booleanType = schema.Types.GetType("Boolean"); + var nonNullBooleanType = new NonNullType(booleanType); + + var variableValues = new VariableValueCollection( + new Dictionary + { + { "skip", new VariableValue("skip", nonNullBooleanType, BooleanValueNode.True) } + }); + + // act + var compiler = new OperationCompiler(schema, _fieldMapPool); + var operation = compiler.Compile("1", "1", operationDefinition); + var flags = operation.CreateIncludeFlags(variableValues); + + // assert + var product = GetSelection(operation.RootSelectionSet, "product"); + var productSelectionSet = operation.GetSelectionSet(product); + var typeName = GetSelection(productSelectionSet, "__typename"); + + Assert.False(typeName.IsInternal); + Assert.False(typeName.IsIncluded(flags)); + } + public static FusionSchemaDefinition CreateSchema() { const string sourceText = @@ -178,4 +293,122 @@ scalar Boolean return ComposeSchema(sourceText); } + + private static FusionSchemaDefinition CreateStreamSchema() + { + const string sourceText = + """ + type Query { + series: [Series!]! + } + + type Series { + id: ID! + streams: [Stream!]! + } + + type Stream { + id: ID! + title: String + hasDrm: Boolean + publishedAt: String + } + + scalar Boolean + """; + + return ComposeSchema(sourceText); + } + + private string GetComplementaryFragmentIncludedResponseNames(bool minimal) + { + const string sourceText = + """ + query TestQuery($minimal: Boolean = false) { + series { + streams { + __typename + ...streamFields + } + } + } + + fragment streamFields on Stream { + __typename + ...MinimalStream @include(if: $minimal) + ...FullStream @skip(if: $minimal) + } + + fragment MinimalStream on Stream { + id + hasDrm + } + + fragment FullStream on Stream { + id + title + hasDrm + publishedAt + } + """; + + var document = Utf8GraphQLParser.Parse(sourceText); + var schema = CreateStreamSchema(); + var rewritten = new DocumentRewriter(schema).RewriteDocument(document); + var operationDefinition = rewritten.Definitions.OfType().First(); + var booleanType = schema.Types.GetType("Boolean"); + var variableValues = new VariableValueCollection( + new Dictionary + { + { + "minimal", + new VariableValue( + "minimal", + booleanType, + minimal ? BooleanValueNode.True : BooleanValueNode.False) + } + }); + + var compiler = new OperationCompiler(schema, _fieldMapPool); + var operation = compiler.Compile("1", "1", operationDefinition); + var flags = operation.CreateIncludeFlags(variableValues); + + var series = GetSelection(operation.RootSelectionSet, "series"); + var seriesSelectionSet = operation.GetSelectionSet(series); + var streams = GetSelection(seriesSelectionSet, "streams"); + var streamsSelectionSet = operation.GetSelectionSet(streams); + + return GetIncludedResponseNames(streamsSelectionSet, flags); + } + + private static Selection GetSelection(SelectionSet selectionSet, string responseName) + { + if (selectionSet.TryGetSelection(responseName, out var selection)) + { + return selection; + } + + throw new InvalidOperationException( + $"The selection set does not contain a `{responseName}` selection."); + } + + private static string GetIncludedResponseNames(SelectionSet selectionSet, ulong flags) + { + var result = new StringBuilder(); + + foreach (var selection in selectionSet.Selections) + { + if (selection.IsIncluded(flags)) + { + if (result.Length > 0) + { + result.AppendLine(); + } + + result.Append(selection.ResponseName); + } + } + + return result.ToString(); + } }