From 50733bdc47962d7e694f661ddc888b5173209871 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Wed, 13 May 2026 15:01:06 +0200 Subject: [PATCH 1/2] Add DocumentRewriter benchmarks --- src/All.slnx | 2 +- src/Directory.Packages.props | 3 +- .../Fusion/HotChocolate.Fusion.slnx | 2 +- .../DocumentRewriterBenchmark.cs | 49 + .../FusionBenchmarkBase.cs | 849 ++++++++++++++ ...colate.Fusion.Execution.Benchmarks.csproj} | 2 + ...nlineFragmentOperationRewriterBenchmark.cs | 48 + .../OperationCompilerBenchmark.cs | 71 ++ .../OperationPlannerBenchmark.cs | 60 + .../Planning/FusionBenchmarkTests.cs | 898 +++++++++++++++ .../FusionBenchmarkTests.Complex_Query.yaml | 1023 +++++++++++++++++ ...arkTests.Conditional_Redundancy_Query.yaml | 463 ++++++++ ...kTests.Simple_Query_With_Requirements.yaml | 74 ++ 13 files changed, 3541 insertions(+), 3 deletions(-) create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/DocumentRewriterBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/FusionBenchmarkBase.cs rename src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/{Fusion.Execution.Benchmarks.csproj => HotChocolate.Fusion.Execution.Benchmarks.csproj} (86%) create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/InlineFragmentOperationRewriterBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationCompilerBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/FusionBenchmarkTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Conditional_Redundancy_Query.yaml create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Simple_Query_With_Requirements.yaml diff --git a/src/All.slnx b/src/All.slnx index f90a2fe29d1..d1ed667624e 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -199,7 +199,7 @@ - + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0c979487c80..10e0126fd42 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -10,7 +10,8 @@ - + + diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index 89e79ece137..823c27423ae 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -1,6 +1,6 @@ - + diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/DocumentRewriterBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/DocumentRewriterBenchmark.cs new file mode 100644 index 00000000000..2eb7d3fda76 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/DocumentRewriterBenchmark.cs @@ -0,0 +1,49 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using HotChocolate.Fusion.Planning; +using HotChocolate.Fusion.Rewriters; +using HotChocolate.Language; + +namespace Fusion.Execution.Benchmarks; + +[MemoryDiagnoser] +[ShortRunJob(RuntimeMoniker.Net10_0)] +[MarkdownExporter] +public class DocumentRewriterBenchmark : FusionBenchmarkBase +{ + private DocumentRewriter _documentRewriter = null!; + + private DocumentNode _simpleQueryWithRequirements = null!; + private DocumentNode _complexQuery = null!; + private DocumentNode _conditionalRedundancyQuery = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _simpleQueryWithRequirements = CreateSimpleQueryWithRequirementsDocument(); + _complexQuery = CreateComplexDocument(); + _conditionalRedundancyQuery = CreateConditionalRedundancyDocument(); + + var schema = CreateFusionSchema(); + + _documentRewriter = new DocumentRewriter(schema); + } + + [Benchmark] + public OperationDefinitionNode Rewrite_Simple_Query_With_Requirements() + { + return _documentRewriter.RewriteDocument(_simpleQueryWithRequirements).GetOperation(operationName: null); + } + + [Benchmark] + public OperationDefinitionNode Rewrite_Complex_Query() + { + return _documentRewriter.RewriteDocument(_complexQuery).GetOperation(operationName: null); + } + + [Benchmark] + public OperationDefinitionNode Rewrite_ConditionalRedundancy_Query() + { + return _documentRewriter.RewriteDocument(_conditionalRedundancyQuery).GetOperation(operationName: null); + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/FusionBenchmarkBase.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/FusionBenchmarkBase.cs new file mode 100644 index 00000000000..e27f08a52ab --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/FusionBenchmarkBase.cs @@ -0,0 +1,849 @@ +using System; +using System.Collections.Generic; +using HotChocolate.Fusion; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; + +namespace Fusion.Execution.Benchmarks; + +public abstract class FusionBenchmarkBase +{ + protected static FusionSchemaDefinition CreateFusionSchema() + { + List sourceSchemas = [ + new SourceSchemaText( + "products", + """ + type Query { + productById(id: ID!): Product @lookup + products(first: Int, after: String, last: Int, before: String): ProductConnection + } + + type Product { + id: ID! + name: String! + description: String @shareable + price: Float! + dimension: ProductDimension! + estimatedDelivery(postCode: String): Int! + } + + type ProductDimension { + height: Int! + width: Int! + } + + type ProductConnection { + pageInfo: PageInfo! + edges: [ProductEdge!] + nodes: [Product!] + } + + type ProductEdge { + cursor: String! + node: Product! + } + + type PageInfo @shareable { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + """), + new SourceSchemaText( + "reviews", + """ + type Query { + reviewById(id: ID!): Review @lookup + productById(id: ID!): Product @lookup @internal + viewer: Viewer @shareable + } + + type Viewer { + reviews(first: Int, after: String, last: Int, before: String): ProductReviewConnection + } + + type Product { + id: ID! + averageRating: Int! + reviews(first: Int, after: String, last: Int, before: String): ProductReviewConnection + } + + type Review { + id: ID! + body: String! + stars: Int! + author: User + product: Product + } + + type User { + id: ID! @shareable + } + + type ProductReviewConnection { + pageInfo: PageInfo! + edges: [ProductReviewEdge!] + nodes: [Review!] + } + + type ProductReviewEdge { + cursor: String! + node: Review! + } + + type PageInfo @shareable { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + """), + new SourceSchemaText( + "users", + """ + type Query { + userById(id: ID!): User @lookup + viewer: Viewer @shareable + } + + type Viewer { + displayName: String! + } + + type User { + id: ID! + displayName: String! + reviews(first: Int, after: String, last: Int, before: String): UserReviewConnection + } + + type UserReviewConnection { + pageInfo: PageInfo! + edges: [UserReviewEdge!] + nodes: [Review!] + } + + type UserReviewEdge { + cursor: String! + node: Review! + } + + type Review { + id: ID! @shareable + } + + type PageInfo @shareable { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + """), + new SourceSchemaText( + "search", + """ + type Query { + searchContent(query: String!): [SearchResult!]! + productById(id: ID!): Product @lookup @internal + } + + interface SearchResult { + id: ID! + title: String! + description: String + } + + type Product implements SearchResult { + id: ID! + title: String! + description: String @shareable + } + + type Article implements SearchResult { + id: ID! + title: String! + description: String + content: String! + author: User! + publishedAt: String! + tags: [String!]! + } + + type User { + id: ID! @shareable + } + """) + ]; + + var compositionLog = new CompositionLog(); + var composerOptions = new SchemaComposerOptions + { + Merger = new SourceSchemaMergerOptions { EnableGlobalObjectIdentification = true } + }; + var composer = new SchemaComposer(sourceSchemas, composerOptions, compositionLog); + var result = composer.Compose(); + + if (!result.IsSuccess) + { + throw new InvalidOperationException(result.Errors[0].Message); + } + + var compositeSchemaDoc = result.Value.ToSyntaxNode(); + return FusionSchemaDefinition.Create(compositeSchemaDoc); + } + + protected static DocumentNode CreateSimpleQueryWithRequirementsDocument() + { + return Utf8GraphQLParser.Parse( + """ + query($productId: ID!) { + productById(id: $id) { + name + reviews { + nodes { + id + body + author { + displayName + } + } + } + } + } + """); + } + + protected static DocumentNode CreateComplexDocument() + { + return Utf8GraphQLParser.Parse( + """ + query($level1: Boolean!, $level2: Boolean!, $level3: Boolean!, $level4: Boolean!, $level5: Boolean!, $level6: Boolean!, $includeExpensive: Boolean!, $includeReviews: Boolean!, $includeMetadata: Boolean!) { + # Deep conditional nesting (6+ levels) + productById(id: "1") { + id + name + ... @include(if: $level1) { + description + ... @skip(if: $level2) { + price + ... @include(if: $level3) { + dimension { + height + width + } + ... @skip(if: $level4) { + # Level 4 nesting + ... @include(if: $level5) { + # Level 5 nesting + ... @skip(if: $level6) { + # Level 6 nesting - extreme depth + reviews(first: 10) { + pageInfo { + hasNextPage + ... @include(if: $includeMetadata) { + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + ... @include(if: $includeReviews) { + author { + id + displayName + ... @skip(if: $level1) { + reviews(first: 5) { + nodes { + id + body + ... @include(if: $level2) { + stars + product { + id + name + ... @skip(if: $level3) { + price + ... @include(if: $level4) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + # Many fields with same response name but different conditionals + products(first: 3) { + edges { + node { + # Multiple 'name' fields with different conditionals + name + name @include(if: $level1) + name @skip(if: $level2) + name @include(if: $level3) @skip(if: $level4) + name @skip(if: $level5) @include(if: $level6) + # Multiple 'description' fields with complex conditionals + description + description @include(if: $includeExpensive) + description @skip(if: $level1) @include(if: $level2) + description @include(if: $level3) @skip(if: $level4) @include(if: $level5) + # Multiple 'price' fields with nested conditionals + price + price @include(if: $includeExpensive) + price @skip(if: $level1) + price @include(if: $level2) @skip(if: $level3) + price @skip(if: $level4) @include(if: $level5) @skip(if: $level6) + # Complex field merging with fragments + ... ProductBasicFields + ... @include(if: $level1) { + ... ProductBasicFields + } + ... @skip(if: $level2) { + ... ProductBasicFields + } + ... @include(if: $level3) @skip(if: $level4) { + ... ProductBasicFields + } + # Interface type refinements with deep conditionals + ... on Product { + ... @include(if: $includeReviews) { + reviews(first: 5) { + nodes { + id + body + stars + ... @skip(if: $level1) { + author { + id + displayName + ... @include(if: $level2) { + reviews(first: 3) { + nodes { + id + body + ... @skip(if: $level3) { + stars + product { + id + name + ... @include(if: $level4) { + price + ... @skip(if: $level5) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + # Complex conditional merging scenarios + searchContent(query: "extreme") { + # Interface field with multiple conditionals + id + id @include(if: $level1) + id @skip(if: $level2) + title + title @include(if: $includeExpensive) + title @skip(if: $level3) + description + description @include(if: $level4) + description @skip(if: $level5) @include(if: $level6) + # Type refinements with extreme conditional complexity + ... on Product { + name + name @include(if: $level1) + name @skip(if: $level2) @include(if: $level3) + price + price @include(if: $includeExpensive) + price @skip(if: $level4) @include(if: $level5) @skip(if: $level6) + ... @include(if: $includeReviews) { + reviews(first: 3) { + nodes { + id + body + stars + ... @skip(if: $level1) { + author { + id + displayName + ... @include(if: $level2) { + reviews(first: 2) { + nodes { + id + body + ... @skip(if: $level3) { + stars + product { + id + name + ... @include(if: $level4) { + price + ... @skip(if: $level5) { + dimension { + height + width + } + ... @include(if: $level6) { + # Extreme nesting level - move reviews to Product level + reviews(first: 1) { + nodes { + id + body + stars + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ... on Article { + content + content @include(if: $includeExpensive) + content @skip(if: $level1) @include(if: $level2) + author { + id + displayName + ... @include(if: $level3) { + reviews(first: 2) { + nodes { + id + body + ... @skip(if: $level4) { + stars + product { + id + name + ... @include(if: $level5) { + price + ... @skip(if: $level6) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + publishedAt + publishedAt @include(if: $includeMetadata) + publishedAt @skip(if: $level1) @include(if: $level2) @skip(if: $level3) + tags + tags @include(if: $level4) + tags @skip(if: $level5) @include(if: $level6) + } + } + # Viewer with extreme conditional complexity + viewer { + displayName + displayName @include(if: $level1) + displayName @skip(if: $level2) @include(if: $level3) + ... @include(if: $includeReviews) { + reviews(first: 5) { + pageInfo { + hasNextPage + hasNextPage @include(if: $level4) + hasNextPage @skip(if: $level5) @include(if: $level6) + hasPreviousPage + hasPreviousPage @include(if: $includeMetadata) + hasPreviousPage @skip(if: $level1) @include(if: $level2) + startCursor + startCursor @include(if: $level3) + startCursor @skip(if: $level4) @include(if: $level5) + endCursor + endCursor @include(if: $level6) + endCursor @skip(if: $level1) @include(if: $level2) @skip(if: $level3) + } + edges { + cursor + cursor @include(if: $level4) + cursor @skip(if: $level5) @include(if: $level6) + node { + id + id @include(if: $level1) + id @skip(if: $level2) @include(if: $level3) + body + body @include(if: $includeExpensive) + body @skip(if: $level4) @include(if: $level5) @skip(if: $level6) + stars + stars @include(if: $level1) + stars @skip(if: $level2) + ... @include(if: $includeReviews) { + author { + id + displayName + ... @skip(if: $level3) { + reviews(first: 3) { + nodes { + id + body + stars + ... @include(if: $level4) { + product { + id + name + ... @skip(if: $level5) { + price + ... @include(if: $level6) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + fragment ProductBasicFields on Product { + id + name + description + price + averageRating + } + """); + } + + protected static DocumentNode CreateConditionalRedundancyDocument() + { + return Utf8GraphQLParser.Parse( + """ + query($includeExpensive: Boolean!, $includeReviews: Boolean!, $includeMetadata: Boolean!, $includeDetails: Boolean!) { + # Unconditional selections that are also inside conditionals + productById(id: "1") { + # These fields exist unconditionally + id + name + description + price + averageRating + # Same fields inside conditionals - should be deduplicated + ... @include(if: $includeExpensive) { + id + name + description + price + averageRating + } + # More redundancy with different conditionals + ... @include(if: $includeReviews) { + id + name + description + price + averageRating + } + # Nested redundancy + dimension { + height + width + ... @include(if: $includeDetails) { + height + width + } + } + # Reviews with redundant selections + reviews(first: 5) { + pageInfo { + hasNextPage + hasPreviousPage + ... @include(if: $includeMetadata) { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + # Same fields in conditional + ... @include(if: $includeReviews) { + id + body + stars + author { + id + displayName + # Nested redundancy + ... @include(if: $includeDetails) { + id + displayName + } + } + } + } + } + } + } + # Products with extensive redundancy + products(first: 3) { + edges { + node { + # Unconditional fields + id + name + description + price + averageRating + # Redundant conditional selections + ... @include(if: $includeExpensive) { + id + name + description + price + averageRating + dimension { + height + width + } + } + ... @include(if: $includeReviews) { + id + name + description + price + averageRating + reviews(first: 3) { + nodes { + id + body + stars + # More redundancy + ... @include(if: $includeDetails) { + id + body + stars + } + } + } + } + # Fragment redundancy + ... ProductBasicInfo + ... @include(if: $includeExpensive) { + ... ProductBasicInfo + } + ... @include(if: $includeReviews) { + ... ProductBasicInfo + } + } + } + } + # Search content with interface redundancy + searchContent(query: "redundant") { + # Interface fields unconditionally + id + title + description + # Same interface fields in conditionals + ... @include(if: $includeExpensive) { + id + title + description + } + # Type-specific redundancy + ... on Product { + # Unconditional product fields + id + name + price + averageRating + # Redundant conditional selections + ... @include(if: $includeExpensive) { + id + name + price + averageRating + dimension { + height + width + } + } + ... @include(if: $includeReviews) { + id + name + price + averageRating + reviews(first: 2) { + nodes { + id + body + stars + # Nested redundancy + ... @include(if: $includeDetails) { + id + body + stars + } + } + } + } + } + ... on Article { + # Unconditional article fields + id + title + description + content + publishedAt + # Redundant conditional selections + ... @include(if: $includeExpensive) { + id + title + description + content + publishedAt + tags + } + ... @include(if: $includeDetails) { + id + title + description + content + publishedAt + author { + id + displayName + # More redundancy + ... @include(if: $includeReviews) { + id + displayName + } + } + } + } + } + # Viewer with extensive redundancy + viewer { + # Unconditional fields + displayName + # Redundant conditional selections + ... @include(if: $includeReviews) { + displayName + reviews(first: 5) { + pageInfo { + hasNextPage + hasPreviousPage + # Redundant pageInfo fields + ... @include(if: $includeMetadata) { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + # Unconditional review fields + id + body + stars + # Redundant conditional selections + ... @include(if: $includeDetails) { + id + body + stars + author { + id + displayName + # Nested redundancy + ... @include(if: $includeReviews) { + id + displayName + reviews(first: 2) { + nodes { + id + body + stars + # Deep redundancy + ... @include(if: $includeExpensive) { + id + body + stars + } + } + } + } + } + } + } + } + } + } + } + } + + fragment ProductBasicInfo on Product { + id + name + description + price + averageRating + } + """); + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Fusion.Execution.Benchmarks.csproj b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/HotChocolate.Fusion.Execution.Benchmarks.csproj similarity index 86% rename from src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Fusion.Execution.Benchmarks.csproj rename to src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/HotChocolate.Fusion.Execution.Benchmarks.csproj index 293f667f6ee..28fd26ca919 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Fusion.Execution.Benchmarks.csproj +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/HotChocolate.Fusion.Execution.Benchmarks.csproj @@ -11,6 +11,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/InlineFragmentOperationRewriterBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/InlineFragmentOperationRewriterBenchmark.cs new file mode 100644 index 00000000000..0d05fc2bb49 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/InlineFragmentOperationRewriterBenchmark.cs @@ -0,0 +1,48 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using HotChocolate.Fusion.Rewriters; +using HotChocolate.Language; + +namespace Fusion.Execution.Benchmarks; + +[MemoryDiagnoser] +[ShortRunJob(RuntimeMoniker.Net10_0)] +[MarkdownExporter] +public class InlineFragmentOperationRewriterBenchmark : FusionBenchmarkBase +{ + private InlineFragmentOperationRewriter _rewriter = null!; + + private DocumentNode _simpleQueryWithRequirements = null!; + private DocumentNode _complexQuery = null!; + private DocumentNode _conditionalRedundancyQuery = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _simpleQueryWithRequirements = CreateSimpleQueryWithRequirementsDocument(); + _complexQuery = CreateComplexDocument(); + _conditionalRedundancyQuery = CreateConditionalRedundancyDocument(); + + var schema = CreateFusionSchema(); + + _rewriter = new InlineFragmentOperationRewriter(schema); + } + + [Benchmark] + public InlineFragmentOperationRewriterResult Rewrite_Simple_Query_With_Requirements() + { + return _rewriter.RewriteDocument(_simpleQueryWithRequirements); + } + + [Benchmark] + public InlineFragmentOperationRewriterResult Rewrite_Complex_Query() + { + return _rewriter.RewriteDocument(_complexQuery); + } + + [Benchmark] + public InlineFragmentOperationRewriterResult Rewrite_ConditionalRedundancy_Query() + { + return _rewriter.RewriteDocument(_conditionalRedundancyQuery); + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationCompilerBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationCompilerBenchmark.cs new file mode 100644 index 00000000000..e3ea7694bac --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationCompilerBenchmark.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnostics.dotMemory; +using BenchmarkDotNet.Jobs; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Planning; +using HotChocolate.Fusion.Rewriters; +using HotChocolate.Language; +using Microsoft.Extensions.ObjectPool; + +namespace Fusion.Execution.Benchmarks; + +[DotMemoryDiagnoser] +[MemoryDiagnoser] +[ShortRunJob(RuntimeMoniker.Net10_0)] +[MarkdownExporter] +public class OperationCompilerBenchmark : FusionBenchmarkBase +{ + private const string Id = "123456789101112"; + + private OperationCompiler _compiler = null!; + + private OperationDefinitionNode _simpleQueryWithRequirements = null!; + private OperationDefinitionNode _complexQuery = null!; + private OperationDefinitionNode _conditionalRedundancyQuery = null!; + + [GlobalSetup] + public void GlobalSetup() + { + var schema = CreateFusionSchema(); + + var documentRewriter = new DocumentRewriter(schema); + + _simpleQueryWithRequirements = documentRewriter.RewriteDocument(CreateSimpleQueryWithRequirementsDocument()).GetOperation(operationName: null); + _complexQuery = documentRewriter.RewriteDocument(CreateComplexDocument()).GetOperation(operationName: null); + _conditionalRedundancyQuery = documentRewriter.RewriteDocument(CreateConditionalRedundancyDocument()).GetOperation(operationName: null); + + var pool = new NoOpObjectPool>>(); + _compiler = new OperationCompiler(schema, pool); + } + + [Benchmark] + public Operation Compile_Simple_Query_With_Requirements() + { + return _compiler.Compile(Id, Id, _simpleQueryWithRequirements); + } + + [Benchmark] + public Operation Compile_Complex_Query() + { + return _compiler.Compile(Id, Id, _complexQuery); + } + + [Benchmark] + public Operation Compile_ConditionalRedundancy_Query() + { + return _compiler.Compile(Id, Id, _conditionalRedundancyQuery); + } + + private sealed class NoOpObjectPool : ObjectPool where T : class, new() + { + public override T Get() + { + return new T(); + } + + public override void Return(T obj) + { + } + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs new file mode 100644 index 00000000000..8291f8fe825 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Planning; +using HotChocolate.Fusion.Rewriters; +using HotChocolate.Language; +using Microsoft.Extensions.ObjectPool; + +namespace Fusion.Execution.Benchmarks; + +[MemoryDiagnoser] +[ShortRunJob(RuntimeMoniker.Net10_0)] +[MarkdownExporter] +public class OperationPlannerBenchmark : FusionBenchmarkBase +{ + private const string Id = "123456789101112"; + + private OperationPlanner _planner = null!; + + private OperationDefinitionNode _simpleQueryWithRequirements = null!; + private OperationDefinitionNode _complexQuery = null!; + private OperationDefinitionNode _conditionalRedundancyQuery = null!; + + [GlobalSetup] + public void GlobalSetup() + { + var schema = CreateFusionSchema(); + + var documentRewriter = new DocumentRewriter(schema); + + _simpleQueryWithRequirements = documentRewriter.RewriteDocument(CreateSimpleQueryWithRequirementsDocument()).GetOperation(operationName: null); + _complexQuery = documentRewriter.RewriteDocument(CreateComplexDocument()).GetOperation(operationName: null); + _conditionalRedundancyQuery = documentRewriter.RewriteDocument(CreateConditionalRedundancyDocument()).GetOperation(operationName: null); + + var pool = new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>()); + var operationCompiler = new OperationCompiler(schema, pool); + + _planner = new OperationPlanner(schema, operationCompiler); + } + + [Benchmark] + public int Plan_Simple_Query_With_Requirements() + { + return _planner.CreatePlan(Id, Id, Id, _simpleQueryWithRequirements).SearchSpace; + } + + [Benchmark] + public int Plan_Complex_Query() + { + return _planner.CreatePlan(Id, Id, Id, _complexQuery).SearchSpace; + } + + [Benchmark] + public int Plan_ConditionalRedundancy_Query() + { + return _planner.CreatePlan(Id, Id, Id, _conditionalRedundancyQuery).SearchSpace; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/FusionBenchmarkTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/FusionBenchmarkTests.cs new file mode 100644 index 00000000000..a284fcfd864 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/FusionBenchmarkTests.cs @@ -0,0 +1,898 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Execution; + +// If one of these tests fails, when fixing, you also need to update the +// FusionBenchmarkBase.cs in Fusion.Execution.Benchmarks. +public class FusionBenchmarkTests : FusionTestBase +{ + [Fact] + public void Simple_Query_With_Requirements() + { + // arrange + var schema = CreateSchema(); + + var doc = CreateSimpleQueryWithRequirementsDocument().ToString(); + + // act + var plan = PlanOperation(schema, doc); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Complex_Query() + { + // arrange + var schema = CreateSchema(); + + var doc = CreateComplexDocument().ToString(); + + // act + var plan = PlanOperation(schema, doc); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Conditional_Redundancy_Query() + { + // arrange + var schema = CreateSchema(); + + var doc = CreateConditionalRedundancyDocument().ToString(); + + // act + var plan = PlanOperation(schema, doc); + + // assert + MatchSnapshot(plan); + } + + private FusionSchemaDefinition CreateSchema() + { + var sourceSchemas = CreateSourceSchemas(); + + var compositionLog = new CompositionLog(); + var composerOptions = new SchemaComposerOptions + { + Merger = new SourceSchemaMergerOptions { EnableGlobalObjectIdentification = true } + }; + var composer = new SchemaComposer(sourceSchemas, composerOptions, compositionLog); + var result = composer.Compose(); + + if (!result.IsSuccess) + { + throw new InvalidOperationException(result.Errors[0].Message); + } + + var compositeSchemaDoc = result.Value.ToSyntaxNode(); + return FusionSchemaDefinition.Create(compositeSchemaDoc); + } + + private List CreateSourceSchemas() + { + return [ + new SourceSchemaText( + "products", + """ + type Query { + productById(id: ID!): Product @lookup + products(first: Int, after: String, last: Int, before: String): ProductConnection + } + + type Product { + id: ID! + name: String! + description: String @shareable + price: Float! + dimension: ProductDimension! + estimatedDelivery(postCode: String): Int! + } + + type ProductDimension { + height: Int! + width: Int! + } + + type ProductConnection { + pageInfo: PageInfo! + edges: [ProductEdge!] + nodes: [Product!] + } + + type ProductEdge { + cursor: String! + node: Product! + } + + type PageInfo @shareable { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + """), + new SourceSchemaText( + "reviews", + """ + type Query { + reviewById(id: ID!): Review @lookup + productById(id: ID!): Product @lookup @internal + viewer: Viewer @shareable + } + + type Viewer { + reviews(first: Int, after: String, last: Int, before: String): ProductReviewConnection + } + + type Product { + id: ID! + averageRating: Int! + reviews(first: Int, after: String, last: Int, before: String): ProductReviewConnection + } + + type Review { + id: ID! + body: String! + stars: Int! + author: User + product: Product + } + + type User { + id: ID! @shareable + } + + type ProductReviewConnection { + pageInfo: PageInfo! + edges: [ProductReviewEdge!] + nodes: [Review!] + } + + type ProductReviewEdge { + cursor: String! + node: Review! + } + + type PageInfo @shareable { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + """), + new SourceSchemaText( + "users", + """ + type Query { + userById(id: ID!): User @lookup + viewer: Viewer @shareable + } + + type Viewer { + displayName: String! + } + + type User { + id: ID! + displayName: String! + reviews(first: Int, after: String, last: Int, before: String): UserReviewConnection + } + + type UserReviewConnection { + pageInfo: PageInfo! + edges: [UserReviewEdge!] + nodes: [Review!] + } + + type UserReviewEdge { + cursor: String! + node: Review! + } + + type Review { + id: ID! @shareable + } + + type PageInfo @shareable { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + """), + new SourceSchemaText( + "search", + """ + type Query { + searchContent(query: String!): [SearchResult!]! + productById(id: ID!): Product @lookup @internal + } + + interface SearchResult { + id: ID! + title: String! + description: String + } + + type Product implements SearchResult { + id: ID! + title: String! + description: String @shareable + } + + type Article implements SearchResult { + id: ID! + title: String! + description: String + content: String! + author: User! + publishedAt: String! + tags: [String!]! + } + + type User { + id: ID! @shareable + } + """) + ]; + } + + private DocumentNode CreateSimpleQueryWithRequirementsDocument() + { + return Utf8GraphQLParser.Parse( + """ + query($productId: ID!) { + productById(id: $id) { + name + reviews { + nodes { + id + body + author { + displayName + } + } + } + } + } + """); + } + + private DocumentNode CreateComplexDocument() + { + return Utf8GraphQLParser.Parse( + """ + query($level1: Boolean!, $level2: Boolean!, $level3: Boolean!, $level4: Boolean!, $level5: Boolean!, $level6: Boolean!, $includeExpensive: Boolean!, $includeReviews: Boolean!, $includeMetadata: Boolean!) { + # Deep conditional nesting (6+ levels) + productById(id: "1") { + id + name + ... @include(if: $level1) { + description + ... @skip(if: $level2) { + price + ... @include(if: $level3) { + dimension { + height + width + } + ... @skip(if: $level4) { + # Level 4 nesting + ... @include(if: $level5) { + # Level 5 nesting + ... @skip(if: $level6) { + # Level 6 nesting - extreme depth + reviews(first: 10) { + pageInfo { + hasNextPage + ... @include(if: $includeMetadata) { + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + ... @include(if: $includeReviews) { + author { + id + displayName + ... @skip(if: $level1) { + reviews(first: 5) { + nodes { + id + body + ... @include(if: $level2) { + stars + product { + id + name + ... @skip(if: $level3) { + price + ... @include(if: $level4) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + # Many fields with same response name but different conditionals + products(first: 3) { + edges { + node { + # Multiple 'name' fields with different conditionals + name + name @include(if: $level1) + name @skip(if: $level2) + name @include(if: $level3) @skip(if: $level4) + name @skip(if: $level5) @include(if: $level6) + # Multiple 'description' fields with complex conditionals + description + description @include(if: $includeExpensive) + description @skip(if: $level1) @include(if: $level2) + description @include(if: $level3) @skip(if: $level4) @include(if: $level5) + # Multiple 'price' fields with nested conditionals + price + price @include(if: $includeExpensive) + price @skip(if: $level1) + price @include(if: $level2) @skip(if: $level3) + price @skip(if: $level4) @include(if: $level5) @skip(if: $level6) + # Complex field merging with fragments + ... ProductBasicFields + ... @include(if: $level1) { + ... ProductBasicFields + } + ... @skip(if: $level2) { + ... ProductBasicFields + } + ... @include(if: $level3) @skip(if: $level4) { + ... ProductBasicFields + } + # Interface type refinements with deep conditionals + ... on Product { + ... @include(if: $includeReviews) { + reviews(first: 5) { + nodes { + id + body + stars + ... @skip(if: $level1) { + author { + id + displayName + ... @include(if: $level2) { + reviews(first: 3) { + nodes { + id + body + ... @skip(if: $level3) { + stars + product { + id + name + ... @include(if: $level4) { + price + ... @skip(if: $level5) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + # Complex conditional merging scenarios + searchContent(query: "extreme") { + # Interface field with multiple conditionals + id + id @include(if: $level1) + id @skip(if: $level2) + title + title @include(if: $includeExpensive) + title @skip(if: $level3) + description + description @include(if: $level4) + description @skip(if: $level5) @include(if: $level6) + # Type refinements with extreme conditional complexity + ... on Product { + name + name @include(if: $level1) + name @skip(if: $level2) @include(if: $level3) + price + price @include(if: $includeExpensive) + price @skip(if: $level4) @include(if: $level5) @skip(if: $level6) + ... @include(if: $includeReviews) { + reviews(first: 3) { + nodes { + id + body + stars + ... @skip(if: $level1) { + author { + id + displayName + ... @include(if: $level2) { + reviews(first: 2) { + nodes { + id + body + ... @skip(if: $level3) { + stars + product { + id + name + ... @include(if: $level4) { + price + ... @skip(if: $level5) { + dimension { + height + width + } + ... @include(if: $level6) { + # Extreme nesting level - move reviews to Product level + reviews(first: 1) { + nodes { + id + body + stars + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ... on Article { + content + content @include(if: $includeExpensive) + content @skip(if: $level1) @include(if: $level2) + author { + id + displayName + ... @include(if: $level3) { + reviews(first: 2) { + nodes { + id + body + ... @skip(if: $level4) { + stars + product { + id + name + ... @include(if: $level5) { + price + ... @skip(if: $level6) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + publishedAt + publishedAt @include(if: $includeMetadata) + publishedAt @skip(if: $level1) @include(if: $level2) @skip(if: $level3) + tags + tags @include(if: $level4) + tags @skip(if: $level5) @include(if: $level6) + } + } + # Viewer with extreme conditional complexity + viewer { + displayName + displayName @include(if: $level1) + displayName @skip(if: $level2) @include(if: $level3) + ... @include(if: $includeReviews) { + reviews(first: 5) { + pageInfo { + hasNextPage + hasNextPage @include(if: $level4) + hasNextPage @skip(if: $level5) @include(if: $level6) + hasPreviousPage + hasPreviousPage @include(if: $includeMetadata) + hasPreviousPage @skip(if: $level1) @include(if: $level2) + startCursor + startCursor @include(if: $level3) + startCursor @skip(if: $level4) @include(if: $level5) + endCursor + endCursor @include(if: $level6) + endCursor @skip(if: $level1) @include(if: $level2) @skip(if: $level3) + } + edges { + cursor + cursor @include(if: $level4) + cursor @skip(if: $level5) @include(if: $level6) + node { + id + id @include(if: $level1) + id @skip(if: $level2) @include(if: $level3) + body + body @include(if: $includeExpensive) + body @skip(if: $level4) @include(if: $level5) @skip(if: $level6) + stars + stars @include(if: $level1) + stars @skip(if: $level2) + ... @include(if: $includeReviews) { + author { + id + displayName + ... @skip(if: $level3) { + reviews(first: 3) { + nodes { + id + body + stars + ... @include(if: $level4) { + product { + id + name + ... @skip(if: $level5) { + price + ... @include(if: $level6) { + dimension { + height + width + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + fragment ProductBasicFields on Product { + id + name + description + price + averageRating + } + """); + } + + private DocumentNode CreateConditionalRedundancyDocument() + { + return Utf8GraphQLParser.Parse( + """ + query($includeExpensive: Boolean!, $includeReviews: Boolean!, $includeMetadata: Boolean!, $includeDetails: Boolean!) { + # Unconditional selections that are also inside conditionals + productById(id: "1") { + # These fields exist unconditionally + id + name + description + price + averageRating + # Same fields inside conditionals - should be deduplicated + ... @include(if: $includeExpensive) { + id + name + description + price + averageRating + } + # More redundancy with different conditionals + ... @include(if: $includeReviews) { + id + name + description + price + averageRating + } + # Nested redundancy + dimension { + height + width + ... @include(if: $includeDetails) { + height + width + } + } + # Reviews with redundant selections + reviews(first: 5) { + pageInfo { + hasNextPage + hasPreviousPage + ... @include(if: $includeMetadata) { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + # Same fields in conditional + ... @include(if: $includeReviews) { + id + body + stars + author { + id + displayName + # Nested redundancy + ... @include(if: $includeDetails) { + id + displayName + } + } + } + } + } + } + } + # Products with extensive redundancy + products(first: 3) { + edges { + node { + # Unconditional fields + id + name + description + price + averageRating + # Redundant conditional selections + ... @include(if: $includeExpensive) { + id + name + description + price + averageRating + dimension { + height + width + } + } + ... @include(if: $includeReviews) { + id + name + description + price + averageRating + reviews(first: 3) { + nodes { + id + body + stars + # More redundancy + ... @include(if: $includeDetails) { + id + body + stars + } + } + } + } + # Fragment redundancy + ... ProductBasicInfo + ... @include(if: $includeExpensive) { + ... ProductBasicInfo + } + ... @include(if: $includeReviews) { + ... ProductBasicInfo + } + } + } + } + # Search content with interface redundancy + searchContent(query: "redundant") { + # Interface fields unconditionally + id + title + description + # Same interface fields in conditionals + ... @include(if: $includeExpensive) { + id + title + description + } + # Type-specific redundancy + ... on Product { + # Unconditional product fields + id + name + price + averageRating + # Redundant conditional selections + ... @include(if: $includeExpensive) { + id + name + price + averageRating + dimension { + height + width + } + } + ... @include(if: $includeReviews) { + id + name + price + averageRating + reviews(first: 2) { + nodes { + id + body + stars + # Nested redundancy + ... @include(if: $includeDetails) { + id + body + stars + } + } + } + } + } + ... on Article { + # Unconditional article fields + id + title + description + content + publishedAt + # Redundant conditional selections + ... @include(if: $includeExpensive) { + id + title + description + content + publishedAt + tags + } + ... @include(if: $includeDetails) { + id + title + description + content + publishedAt + author { + id + displayName + # More redundancy + ... @include(if: $includeReviews) { + id + displayName + } + } + } + } + } + # Viewer with extensive redundancy + viewer { + # Unconditional fields + displayName + # Redundant conditional selections + ... @include(if: $includeReviews) { + displayName + reviews(first: 5) { + pageInfo { + hasNextPage + hasPreviousPage + # Redundant pageInfo fields + ... @include(if: $includeMetadata) { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + # Unconditional review fields + id + body + stars + # Redundant conditional selections + ... @include(if: $includeDetails) { + id + body + stars + author { + id + displayName + # Nested redundancy + ... @include(if: $includeReviews) { + id + displayName + reviews(first: 2) { + nodes { + id + body + stars + # Deep redundancy + ... @include(if: $includeExpensive) { + id + body + stars + } + } + } + } + } + } + } + } + } + } + } + } + + fragment ProductBasicInfo on Product { + id + name + description + price + averageRating + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml new file mode 100644 index 00000000000..6d12f5f078a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml @@ -0,0 +1,1023 @@ +operation: + - document: | + query( + $level1: Boolean! + $level2: Boolean! + $level3: Boolean! + $level4: Boolean! + $level5: Boolean! + $level6: Boolean! + $includeExpensive: Boolean! + $includeReviews: Boolean! + $includeMetadata: Boolean! + ) { + productById(id: "1") { + id + name + ... @include(if: $level1) { + description + ... @skip(if: $level2) { + price + ... @include(if: $level3) { + dimension { + height + width + } + ... @skip(if: $level4) { + ... @include(if: $level5) { + reviews(first: 10) @skip(if: $level6) { + pageInfo { + hasNextPage + ... @include(if: $includeMetadata) { + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + author @include(if: $includeReviews) { + id + id @fusion__requirement + displayName + } + } + } + } + id @fusion__requirement + } + } + } + } + } + } + products(first: 3) { + edges { + node { + name + description + price + id + id @fusion__requirement + averageRating + reviews(first: 5) @include(if: $includeReviews) { + nodes { + id + body + stars + author @skip(if: $level1) { + id + id @fusion__requirement + displayName + reviews(first: 3) @include(if: $level2) { + nodes { + id + id @fusion__requirement + body + ... @skip(if: $level3) { + stars + product { + id + id @fusion__requirement + name + ... @include(if: $level4) { + price + dimension @skip(if: $level5) { + height + width + } + id @fusion__requirement + } + } + id @fusion__requirement + } + } + } + } + } + } + } + } + } + searchContent(query: "extreme") { + __typename @fusion__requirement + id + title + description + ... on Product { + name + price + reviews(first: 3) @include(if: $includeReviews) { + nodes { + id + body + stars + author @skip(if: $level1) { + id + id @fusion__requirement + displayName + reviews(first: 2) @include(if: $level2) { + nodes { + id + id @fusion__requirement + body + ... @skip(if: $level3) { + stars + product { + id + id @fusion__requirement + name + ... @include(if: $level4) { + price + ... @skip(if: $level5) { + dimension { + height + width + } + reviews(first: 1) @include(if: $level6) { + nodes { + id + body + stars + } + } + id @fusion__requirement + } + id @fusion__requirement + } + } + id @fusion__requirement + } + } + } + } + } + } + id @fusion__requirement + } + ... on Article { + content + author { + id + id @fusion__requirement + displayName + reviews(first: 2) @include(if: $level3) { + nodes { + id + id @fusion__requirement + body + ... @skip(if: $level4) { + stars + product { + id + id @fusion__requirement + name + ... @include(if: $level5) { + price + dimension @skip(if: $level6) { + height + width + } + id @fusion__requirement + } + } + id @fusion__requirement + } + } + } + } + publishedAt + tags + } + } + viewer { + displayName + reviews(first: 5) @include(if: $includeReviews) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + id + body + stars + author { + id + id @fusion__requirement + displayName + reviews(first: 3) @skip(if: $level3) { + nodes { + id + id @fusion__requirement + body + stars + product @include(if: $level4) { + id + id @fusion__requirement + name + ... @skip(if: $level5) { + price + dimension @include(if: $level6) { + height + width + } + id @fusion__requirement + } + } + } + } + } + } + } + } + } + } + hash: 123456789101112 + searchSpace: 12 + expandedNodes: 66 +nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_1($includeReviews: Boolean!) { + viewer { + reviews(first: 5) @include(if: $includeReviews) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + id + body + stars + author { + id + } + } + } + } + } + } + forwardedVariables: + - includeReviews + - id: 2 + type: Operation + schema: products + operation: | + query Op_123456789101112_2( + $level1: Boolean! + $level2: Boolean! + $level3: Boolean! + $level4: Boolean! + $level5: Boolean! + ) { + productById(id: "1") { + id + name + ... @include(if: $level1) { + description + ... @skip(if: $level2) { + price + ... @include(if: $level3) { + dimension { + height + width + } + ... @skip(if: $level4) { + ... @include(if: $level5) { + id + } + } + } + } + } + } + products(first: 3) { + edges { + node { + name + description + price + id + } + } + } + } + forwardedVariables: + - level1 + - level2 + - level3 + - level4 + - level5 + - id: 3 + type: Operation + schema: search + operation: | + query Op_123456789101112_3 { + searchContent(query: "extreme") { + __typename + id + title + description + ... on Product { + id + } + ... on Article { + content + author { + id + } + publishedAt + tags + } + } + } + - id: 4 + type: Operation + schema: users + operation: | + query Op_123456789101112_4( + $level3: Boolean! + $level4: Boolean! + $__fusion_1_id: ID! + ) { + userById(id: $__fusion_1_id) { + displayName + reviews(first: 2) @include(if: $level3) { + nodes { + id + ... @skip(if: $level4) { + id + } + } + } + } + } + source: $.userById + target: $.searchContent
.author + batchingGroupId: 4 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + forwardedVariables: + - level3 + - level4 + dependencies: + - id: 3 + - id: 26 + type: Operation + schema: users + operation: | + query Op_123456789101112_26($level3: Boolean!, $__fusion_22_id: ID!) { + userById(id: $__fusion_22_id) { + displayName + reviews(first: 3) @skip(if: $level3) { + nodes { + id + } + } + } + } + source: $.userById + target: $.viewer.reviews.edges.node.author + batchingGroupId: 4 + requirements: + - name: __fusion_22_id + selectionMap: >- + id + conditions: + - variable: $includeReviews + passingValue: true + forwardedVariables: + - level3 + dependencies: + - id: 1 + - id: 5 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_5($__fusion_2_id: ID!) { + reviewById(id: $__fusion_2_id) { + body + } + } + source: $.reviewById + target: $.searchContent
.author.reviews.nodes + batchingGroupId: 5 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + conditions: + - variable: $level3 + passingValue: true + dependencies: + - id: 4 + - id: 6 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_6($level5: Boolean!, $__fusion_3_id: ID!) { + reviewById(id: $__fusion_3_id) { + stars + product { + id + ... @include(if: $level5) { + id + } + } + } + } + source: $.reviewById + target: $.searchContent
.author.reviews.nodes + batchingGroupId: 5 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + conditions: + - variable: $level3 + passingValue: true + - variable: $level4 + passingValue: false + forwardedVariables: + - level5 + dependencies: + - id: 4 + - id: 27 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_27( + $level4: Boolean! + $level5: Boolean! + $__fusion_23_id: ID! + ) { + reviewById(id: $__fusion_23_id) { + body + stars + product @include(if: $level4) { + id + ... @skip(if: $level5) { + id + } + } + } + } + source: $.reviewById + target: $.viewer.reviews.edges.node.author.reviews.nodes + batchingGroupId: 5 + requirements: + - name: __fusion_23_id + selectionMap: >- + id + conditions: + - variable: $level3 + passingValue: false + forwardedVariables: + - level4 + - level5 + dependencies: + - id: 26 + - id: 7 + type: OperationBatch + schema: products + operation: | + query Op_123456789101112_7($__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + name + } + } + source: $.productById + targets: + - $.searchContent
.author.reviews.nodes.product + - $.searchContent.reviews.nodes.author.reviews.nodes.product + - $.products.edges.node.reviews.nodes.author.reviews.nodes.product + batchingGroupId: 7 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 6 + - id: 13 + - id: 20 + - id: 15 + type: Operation + schema: products + operation: | + query Op_123456789101112_15($__fusion_12_id: ID!) { + productById(id: $__fusion_12_id) { + price + } + } + source: $.productById + target: $.searchContent.reviews.nodes.author.reviews.nodes.product + batchingGroupId: 7 + requirements: + - name: __fusion_12_id + selectionMap: >- + id + conditions: + - variable: $level4 + passingValue: true + dependencies: + - id: 13 + - id: 16 + type: Operation + schema: products + operation: | + query Op_123456789101112_16($__fusion_13_id: ID!) { + productById(id: $__fusion_13_id) { + dimension { + height + width + } + } + } + source: $.productById + target: $.searchContent.reviews.nodes.author.reviews.nodes.product + batchingGroupId: 7 + requirements: + - name: __fusion_13_id + selectionMap: >- + id + conditions: + - variable: $level4 + passingValue: true + - variable: $level5 + passingValue: false + dependencies: + - id: 13 + - id: 22 + type: Operation + schema: products + operation: | + query Op_123456789101112_22($level5: Boolean!, $__fusion_19_id: ID!) { + productById(id: $__fusion_19_id) { + price + dimension @skip(if: $level5) { + height + width + } + } + } + source: $.productById + target: $.products.edges.node.reviews.nodes.author.reviews.nodes.product + batchingGroupId: 7 + requirements: + - name: __fusion_19_id + selectionMap: >- + id + conditions: + - variable: $level4 + passingValue: true + forwardedVariables: + - level5 + dependencies: + - id: 20 + - id: 8 + type: Operation + schema: products + operation: | + query Op_123456789101112_8($level6: Boolean!, $__fusion_5_id: ID!) { + productById(id: $__fusion_5_id) { + price + dimension @skip(if: $level6) { + height + width + } + } + } + source: $.productById + target: $.searchContent
.author.reviews.nodes.product + batchingGroupId: 8 + requirements: + - name: __fusion_5_id + selectionMap: >- + id + conditions: + - variable: $level5 + passingValue: true + forwardedVariables: + - level6 + dependencies: + - id: 6 + - id: 28 + type: Operation + schema: products + operation: | + query Op_123456789101112_28($__fusion_24_id: ID!) { + productById(id: $__fusion_24_id) { + name + } + } + source: $.productById + target: $.viewer.reviews.edges.node.author.reviews.nodes.product + batchingGroupId: 8 + requirements: + - name: __fusion_24_id + selectionMap: >- + id + conditions: + - variable: $level4 + passingValue: true + dependencies: + - id: 27 + - id: 29 + type: Operation + schema: products + operation: | + query Op_123456789101112_29($level6: Boolean!, $__fusion_25_id: ID!) { + productById(id: $__fusion_25_id) { + price + dimension @include(if: $level6) { + height + width + } + } + } + source: $.productById + target: $.viewer.reviews.edges.node.author.reviews.nodes.product + batchingGroupId: 8 + requirements: + - name: __fusion_25_id + selectionMap: >- + id + conditions: + - variable: $level4 + passingValue: true + - variable: $level5 + passingValue: false + forwardedVariables: + - level6 + dependencies: + - id: 27 + - id: 9 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_9($level1: Boolean!, $__fusion_6_id: ID!) { + productById(id: $__fusion_6_id) { + reviews(first: 3) { + nodes { + id + body + stars + author @skip(if: $level1) { + id + } + } + } + } + } + source: $.productById + target: $.searchContent + batchingGroupId: 9 + requirements: + - name: __fusion_6_id + selectionMap: >- + id + conditions: + - variable: $includeReviews + passingValue: true + forwardedVariables: + - level1 + dependencies: + - id: 3 + - id: 17 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_17( + $level1: Boolean! + $includeReviews: Boolean! + $__fusion_14_id: ID! + ) { + productById(id: $__fusion_14_id) { + averageRating + reviews(first: 5) @include(if: $includeReviews) { + nodes { + id + body + stars + author @skip(if: $level1) { + id + } + } + } + } + } + source: $.productById + target: $.products.edges.node + batchingGroupId: 9 + requirements: + - name: __fusion_14_id + selectionMap: >- + id + forwardedVariables: + - level1 + - includeReviews + dependencies: + - id: 2 + - id: 23 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_23( + $includeReviews: Boolean! + $includeMetadata: Boolean! + $__fusion_20_id: ID! + ) { + productById(id: $__fusion_20_id) { + reviews(first: 10) { + pageInfo { + hasNextPage + ... @include(if: $includeMetadata) { + hasPreviousPage + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + author @include(if: $includeReviews) { + id + } + } + } + } + } + } + source: $.productById + target: $.productById + batchingGroupId: 9 + requirements: + - name: __fusion_20_id + selectionMap: >- + id + conditions: + - variable: $level1 + passingValue: true + - variable: $level2 + passingValue: false + - variable: $level3 + passingValue: true + - variable: $level4 + passingValue: false + - variable: $level5 + passingValue: true + - variable: $level6 + passingValue: false + forwardedVariables: + - includeReviews + - includeMetadata + dependencies: + - id: 2 + - id: 10 + type: Operation + schema: products + operation: | + query Op_123456789101112_10($__fusion_7_id: ID!) { + productById(id: $__fusion_7_id) { + name + price + } + } + source: $.productById + target: $.searchContent + requirements: + - name: __fusion_7_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 11 + type: Operation + schema: users + operation: | + query Op_123456789101112_11( + $level2: Boolean! + $level3: Boolean! + $__fusion_8_id: ID! + ) { + userById(id: $__fusion_8_id) { + displayName + reviews(first: 2) @include(if: $level2) { + nodes { + id + ... @skip(if: $level3) { + id + } + } + } + } + } + source: $.userById + target: $.searchContent.reviews.nodes.author + batchingGroupId: 11 + requirements: + - name: __fusion_8_id + selectionMap: >- + id + conditions: + - variable: $includeReviews + passingValue: true + - variable: $level1 + passingValue: false + forwardedVariables: + - level2 + - level3 + dependencies: + - id: 9 + - id: 18 + type: Operation + schema: users + operation: | + query Op_123456789101112_18( + $level2: Boolean! + $level3: Boolean! + $__fusion_15_id: ID! + ) { + userById(id: $__fusion_15_id) { + displayName + reviews(first: 3) @include(if: $level2) { + nodes { + id + ... @skip(if: $level3) { + id + } + } + } + } + } + source: $.userById + target: $.products.edges.node.reviews.nodes.author + batchingGroupId: 11 + requirements: + - name: __fusion_15_id + selectionMap: >- + id + conditions: + - variable: $includeReviews + passingValue: true + - variable: $level1 + passingValue: false + forwardedVariables: + - level2 + - level3 + dependencies: + - id: 17 + - id: 24 + type: Operation + schema: users + operation: | + query Op_123456789101112_24($__fusion_21_id: ID!) { + userById(id: $__fusion_21_id) { + displayName + } + } + source: $.userById + target: $.productById.reviews.edges.node.author + batchingGroupId: 11 + requirements: + - name: __fusion_21_id + selectionMap: >- + id + conditions: + - variable: $level6 + passingValue: false + - variable: $includeReviews + passingValue: true + dependencies: + - id: 23 + - id: 12 + type: OperationBatch + schema: reviews + operation: | + query Op_123456789101112_12($__fusion_9_id: ID!) { + reviewById(id: $__fusion_9_id) { + body + } + } + source: $.reviewById + targets: + - $.searchContent.reviews.nodes.author.reviews.nodes + - $.products.edges.node.reviews.nodes.author.reviews.nodes + batchingGroupId: 12 + requirements: + - name: __fusion_9_id + selectionMap: >- + id + conditions: + - variable: $level2 + passingValue: true + dependencies: + - id: 11 + - id: 18 + - id: 13 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_13( + $level4: Boolean! + $level5: Boolean! + $level6: Boolean! + $__fusion_10_id: ID! + ) { + reviewById(id: $__fusion_10_id) { + stars + product { + id + ... @include(if: $level4) { + ... @skip(if: $level5) { + reviews(first: 1) @include(if: $level6) { + nodes { + id + body + stars + } + } + id + } + id + } + } + } + } + source: $.reviewById + target: $.searchContent.reviews.nodes.author.reviews.nodes + batchingGroupId: 12 + requirements: + - name: __fusion_10_id + selectionMap: >- + id + conditions: + - variable: $level2 + passingValue: true + - variable: $level3 + passingValue: false + forwardedVariables: + - level4 + - level5 + - level6 + dependencies: + - id: 11 + - id: 20 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_20($level4: Boolean!, $__fusion_17_id: ID!) { + reviewById(id: $__fusion_17_id) { + stars + product { + id + ... @include(if: $level4) { + id + } + } + } + } + source: $.reviewById + target: $.products.edges.node.reviews.nodes.author.reviews.nodes + batchingGroupId: 12 + requirements: + - name: __fusion_17_id + selectionMap: >- + id + conditions: + - variable: $level2 + passingValue: true + - variable: $level3 + passingValue: false + forwardedVariables: + - level4 + dependencies: + - id: 18 + - id: 25 + type: Operation + schema: users + operation: | + query Op_123456789101112_25 { + viewer { + displayName + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Conditional_Redundancy_Query.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Conditional_Redundancy_Query.yaml new file mode 100644 index 00000000000..73c144a9d32 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Conditional_Redundancy_Query.yaml @@ -0,0 +1,463 @@ +operation: + - document: | + query( + $includeExpensive: Boolean! + $includeReviews: Boolean! + $includeMetadata: Boolean! + $includeDetails: Boolean! + ) { + productById(id: "1") { + id + id @fusion__requirement + name + description + price + averageRating + dimension { + height + width + } + reviews(first: 5) { + pageInfo { + hasNextPage + hasPreviousPage + ... @include(if: $includeMetadata) { + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + author @include(if: $includeReviews) { + id + id @fusion__requirement + displayName + } + } + } + } + } + products(first: 3) { + edges { + node { + id + id @fusion__requirement + name + description + price + averageRating + dimension @include(if: $includeExpensive) { + height + width + } + reviews(first: 3) @include(if: $includeReviews) { + nodes { + id + body + stars + } + } + } + } + } + searchContent(query: "redundant") { + __typename @fusion__requirement + id + title + description + ... on Product { + id + id @fusion__requirement + name + price + averageRating + dimension @include(if: $includeExpensive) { + height + width + } + reviews(first: 2) @include(if: $includeReviews) { + nodes { + id + body + stars + } + } + } + ... on Article { + id + title + description + content + publishedAt + tags @include(if: $includeExpensive) + author @include(if: $includeDetails) { + id + id @fusion__requirement + displayName + } + } + } + viewer { + displayName + reviews(first: 5) @include(if: $includeReviews) { + pageInfo { + hasNextPage + hasPreviousPage + ... @include(if: $includeMetadata) { + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + author @include(if: $includeDetails) { + id + id @fusion__requirement + displayName + reviews(first: 2) { + nodes { + id + id @fusion__requirement + body + stars + } + } + } + } + } + } + } + } + hash: 123456789101112 + searchSpace: 11 + expandedNodes: 26 +nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_1( + $includeReviews: Boolean! + $includeMetadata: Boolean! + $includeDetails: Boolean! + ) { + viewer { + reviews(first: 5) @include(if: $includeReviews) { + pageInfo { + hasNextPage + hasPreviousPage + ... @include(if: $includeMetadata) { + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + author @include(if: $includeDetails) { + id + } + } + } + } + } + } + forwardedVariables: + - includeReviews + - includeMetadata + - includeDetails + - id: 2 + type: Operation + schema: products + operation: | + query Op_123456789101112_2($includeExpensive: Boolean!) { + productById(id: "1") { + id + name + description + price + dimension { + height + width + } + } + products(first: 3) { + edges { + node { + id + name + description + price + dimension @include(if: $includeExpensive) { + height + width + } + } + } + } + } + forwardedVariables: + - includeExpensive + - id: 3 + type: Operation + schema: search + operation: | + query Op_123456789101112_3( + $includeExpensive: Boolean! + $includeDetails: Boolean! + ) { + searchContent(query: "redundant") { + __typename + id + title + description + ... on Product { + id + } + ... on Article { + id + title + description + content + publishedAt + tags @include(if: $includeExpensive) + author @include(if: $includeDetails) { + id + } + } + } + } + forwardedVariables: + - includeExpensive + - includeDetails + - id: 4 + type: Operation + schema: users + operation: | + query Op_123456789101112_4($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + displayName + } + } + source: $.userById + target: $.searchContent
.author + batchingGroupId: 4 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + conditions: + - variable: $includeDetails + passingValue: true + dependencies: + - id: 3 + - id: 11 + type: Operation + schema: users + operation: | + query Op_123456789101112_11($__fusion_7_id: ID!) { + userById(id: $__fusion_7_id) { + displayName + reviews(first: 2) { + nodes { + id + } + } + } + } + source: $.userById + target: $.viewer.reviews.edges.node.author + batchingGroupId: 4 + requirements: + - name: __fusion_7_id + selectionMap: >- + id + conditions: + - variable: $includeReviews + passingValue: true + - variable: $includeDetails + passingValue: true + dependencies: + - id: 1 + - id: 5 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_5($includeReviews: Boolean!, $__fusion_2_id: ID!) { + productById(id: $__fusion_2_id) { + averageRating + reviews(first: 2) @include(if: $includeReviews) { + nodes { + id + body + stars + } + } + } + } + source: $.productById + target: $.searchContent + batchingGroupId: 5 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + forwardedVariables: + - includeReviews + dependencies: + - id: 3 + - id: 7 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_7($includeReviews: Boolean!, $__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + averageRating + reviews(first: 3) @include(if: $includeReviews) { + nodes { + id + body + stars + } + } + } + } + source: $.productById + target: $.products.edges.node + batchingGroupId: 5 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + forwardedVariables: + - includeReviews + dependencies: + - id: 2 + - id: 8 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_8( + $includeReviews: Boolean! + $includeMetadata: Boolean! + $__fusion_5_id: ID! + ) { + productById(id: $__fusion_5_id) { + averageRating + reviews(first: 5) { + pageInfo { + hasNextPage + hasPreviousPage + ... @include(if: $includeMetadata) { + startCursor + endCursor + } + } + edges { + cursor + node { + id + body + stars + author @include(if: $includeReviews) { + id + } + } + } + } + } + } + source: $.productById + target: $.productById + batchingGroupId: 5 + requirements: + - name: __fusion_5_id + selectionMap: >- + id + forwardedVariables: + - includeReviews + - includeMetadata + dependencies: + - id: 2 + - id: 6 + type: Operation + schema: products + operation: | + query Op_123456789101112_6($includeExpensive: Boolean!, $__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + name + price + dimension @include(if: $includeExpensive) { + height + width + } + } + } + source: $.productById + target: $.searchContent + requirements: + - name: __fusion_3_id + selectionMap: >- + id + forwardedVariables: + - includeExpensive + dependencies: + - id: 3 + - id: 9 + type: Operation + schema: users + operation: | + query Op_123456789101112_9($__fusion_6_id: ID!) { + userById(id: $__fusion_6_id) { + displayName + } + } + source: $.userById + target: $.productById.reviews.edges.node.author + requirements: + - name: __fusion_6_id + selectionMap: >- + id + conditions: + - variable: $includeReviews + passingValue: true + dependencies: + - id: 5 + - id: 10 + type: Operation + schema: users + operation: | + query Op_123456789101112_10 { + viewer { + displayName + } + } + - id: 12 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_12($__fusion_8_id: ID!) { + reviewById(id: $__fusion_8_id) { + body + stars + } + } + source: $.reviewById + target: $.viewer.reviews.edges.node.author.reviews.nodes + requirements: + - name: __fusion_8_id + selectionMap: >- + id + dependencies: + - id: 4 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Simple_Query_With_Requirements.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Simple_Query_With_Requirements.yaml new file mode 100644 index 00000000000..a499083ea76 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Simple_Query_With_Requirements.yaml @@ -0,0 +1,74 @@ +operation: + - document: | + query($productId: ID!) { + productById(id: $id) { + name + reviews { + nodes { + id + body + author { + displayName + id @fusion__requirement + } + } + } + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 3 +nodes: + - id: 1 + type: Operation + schema: products + operation: | + query Op_123456789101112_1 { + productById(id: $id) { + name + id + } + } + - id: 2 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + reviews { + nodes { + id + body + author { + id + } + } + } + } + } + source: $.productById + target: $.productById + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: users + operation: | + query Op_123456789101112_3($__fusion_2_id: ID!) { + userById(id: $__fusion_2_id) { + displayName + } + } + source: $.userById + target: $.productById.reviews.nodes.author + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 From 772a1d89738e3c77162f0ff54f99ba2a84cde1aa Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Wed, 13 May 2026 15:52:24 +0200 Subject: [PATCH 2/2] Properly handle @defer in DocumentRewriter --- .../Rewriters/DocumentRewriter.cs | 458 +++++++- .../FusionBenchmarkTests.Complex_Query.yaml | 12 +- .../Rewriters/DocumentRewriterTests.cs | 1016 +++++++++++++++++ 3 files changed, 1442 insertions(+), 44 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Utilities/Rewriters/DocumentRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Utilities/Rewriters/DocumentRewriter.cs index f5052b2bdbb..5dece8db673 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Utilities/Rewriters/DocumentRewriter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Utilities/Rewriters/DocumentRewriter.cs @@ -10,7 +10,7 @@ namespace HotChocolate.Fusion.Rewriters; public sealed class DocumentRewriter(ISchemaDefinition schema, bool removeStaticallyExcludedSelections = false) { private static readonly FieldNode s_typeNameField = - new FieldNode( + new( null, new NameNode(IntrospectionFieldNames.TypeName), null, @@ -46,15 +46,13 @@ private SelectionSetNode RewriteSelectionSet( ITypeDefinition type, Dictionary? fragmentLookup) { - var context = new Context(null, null, type, null, fragmentLookup ?? []); + var context = new Context(null, null, null, type, null, null, fragmentLookup ?? []); CollectSelections(selectionSetNode, context); var newSelections = RewriteSelections(context) ?? [s_typeNameField]; - var newSelectionSetNode = new SelectionSetNode(newSelections); - - return newSelectionSetNode; + return new SelectionSetNode(newSelections); } #region Collecting @@ -87,7 +85,7 @@ private void CollectSelections(SelectionSetNode selectionSet, Context context) private void CollectField(FieldNode fieldNode, Context context) { - var (conditional, directives) = DivideDirectives( + var (conditional, _, directives) = DivideDirectives( fieldNode, Types.DirectiveLocation.Field); @@ -135,7 +133,7 @@ private void CollectInlineFragment(InlineFragmentNode inlineFragment, Context co ? schema.Types[inlineFragment.TypeCondition.Name.Value] : context.Type; - var (conditional, directives) = DivideDirectives( + var (conditional, defer, directives) = DivideDirectives( inlineFragment, Types.DirectiveLocation.InlineFragment); @@ -143,6 +141,7 @@ private void CollectInlineFragment(InlineFragmentNode inlineFragment, Context co inlineFragment.SelectionSet, typeCondition, conditional, + defer, directives, context); } @@ -152,7 +151,7 @@ private void CollectFragmentSpread(FragmentSpreadNode fragmentSpread, Context co var fragmentDefinition = context.GetFragmentDefinition(fragmentSpread.Name.Value); var typeCondition = schema.Types[fragmentDefinition.TypeCondition.Name.Value]; - var (conditional, directives) = DivideDirectives( + var (conditional, defer, directives) = DivideDirectives( fragmentSpread, Types.DirectiveLocation.InlineFragment); @@ -160,6 +159,7 @@ private void CollectFragmentSpread(FragmentSpreadNode fragmentSpread, Context co fragmentDefinition.SelectionSet, typeCondition, conditional, + defer, directives, context); } @@ -168,6 +168,7 @@ private void CollectFragment( SelectionSetNode selectionSet, ITypeDefinition typeCondition, Conditional? conditional, + Defer? defer, IReadOnlyList? otherDirectives, Context context) { @@ -184,6 +185,11 @@ private void CollectFragment( } } + if (defer is not null) + { + context = context.AddDeferContext(defer); + } + var isTypeRefinement = !typeCondition.IsAssignableFrom(context.Type); var fragmentContext = context; @@ -205,6 +211,51 @@ private void CollectFragment( private static Context? GetOrAddContextForField(Context context, FieldNode fieldNode, ITypeDefinition? fieldType) { + if (context.IsDeferContext) + { + // Walk the full ancestor chain (through defer/conditional scopes, up to and + // including the nearest non-scope ancestor) and check whether any of them + // already contains the field. + var ancestor = context.Parent; + while (ancestor is not null) + { + if (ancestor.HasField(fieldNode, out var ancestorFieldContext)) + { + if (fieldNode.SelectionSet is null) + { + return null; + } + + if (ancestorFieldContext is null) + { + throw new InvalidOperationException("Expected to have a field context"); + } + + var deferredContextBelowAncestorFieldContext = + RecreateScopeHierarchy(ancestorFieldContext, context, ancestor); + + return deferredContextBelowAncestorFieldContext; + } + + if (!ancestor.IsConditionalContext && !ancestor.IsDeferContext) + { + // The non-scope boundary has been checked. Stop walking. + break; + } + + ancestor = ancestor.Parent; + } + + if (!context.HasField(fieldNode, out var fieldContext)) + { + fieldContext = context.AddField(fieldNode, fieldType); + + context.NonDeferredContext.RecordReferenceInDeferContext(fieldNode, context); + } + + return fieldContext; + } + if (context.IsConditionalContext) { var unconditionalContext = context.UnconditionalContext; @@ -222,7 +273,7 @@ private void CollectFragment( } var conditionalContextBelowUnconditionalFieldContext = - RecreateConditionalContextHierarchy(unconditionalFieldContext, context); + RecreateScopeHierarchy(unconditionalFieldContext, context, unconditionalContext); return conditionalContextBelowUnconditionalFieldContext; } @@ -234,6 +285,28 @@ private void CollectFragment( unconditionalContext.RecordReferenceInConditionalContext(fieldNode, context); } + // Even though this is a conditional context, defer back-refs may have been + // registered here when a nested defer's NonDeferredContext was this conditional. + if (context.TryGetDeferContextsWithReferences(fieldNode, out var deferContextsInConditional)) + { + foreach (var deferContext in deferContextsInConditional) + { + if (fieldContext is not null + && deferContext.HasField(fieldNode, out var deferFieldContext) + && deferFieldContext is not null) + { + var deferContextBelowField = + RecreateScopeHierarchy(fieldContext, deferContext, context); + + MergeContexts(deferFieldContext, deferContextBelowField); + } + + deferContext.RemoveField(fieldNode); + } + + context.RemoveReferenceToDeferContext(fieldNode); + } + return fieldContext; } else @@ -252,7 +325,7 @@ private void CollectFragment( && conditionalFieldContext is not null) { var conditionalContextBelowUnconditionalField = - RecreateConditionalContextHierarchy(fieldContext, conditionalContext); + RecreateScopeHierarchy(fieldContext, conditionalContext, context); MergeContexts(conditionalFieldContext, conditionalContextBelowUnconditionalField); } @@ -263,6 +336,26 @@ private void CollectFragment( context.RemoveReferenceToConditionalContext(fieldNode); } + if (context.TryGetDeferContextsWithReferences(fieldNode, out var deferContexts)) + { + foreach (var deferContext in deferContexts) + { + if (fieldContext is not null + && deferContext.HasField(fieldNode, out var deferFieldContext) + && deferFieldContext is not null) + { + var deferContextBelowField = + RecreateScopeHierarchy(fieldContext, deferContext, context); + + MergeContexts(deferFieldContext, deferContextBelowField); + } + + deferContext.RemoveField(fieldNode); + } + + context.RemoveReferenceToDeferContext(fieldNode); + } + return fieldContext; } } @@ -272,6 +365,37 @@ private static Context GetOrAddContextForFragment( InlineFragmentNode inlineFragmentNode, ITypeDefinition typeCondition) { + if (context.IsDeferContext) + { + var ancestor = context.Parent; + while (ancestor is not null) + { + if (ancestor.HasFragment(inlineFragmentNode, out var ancestorFragmentContext)) + { + var deferredContextBelowAncestorFragmentContext = + RecreateScopeHierarchy(ancestorFragmentContext, context, ancestor); + + return deferredContextBelowAncestorFragmentContext; + } + + if (!ancestor.IsConditionalContext && !ancestor.IsDeferContext) + { + break; + } + + ancestor = ancestor.Parent; + } + + if (!context.HasFragment(inlineFragmentNode, out var fragmentContext)) + { + fragmentContext = context.AddFragment(inlineFragmentNode, typeCondition); + + context.NonDeferredContext.RecordReferenceInDeferContext(inlineFragmentNode, context); + } + + return fragmentContext; + } + if (context.IsConditionalContext) { var unconditionalContext = context.UnconditionalContext; @@ -279,7 +403,7 @@ private static Context GetOrAddContextForFragment( if (unconditionalContext.HasFragment(inlineFragmentNode, out var unconditionalFragmentContext)) { var conditionalContextBelowUnconditionalFragmentContext = - RecreateConditionalContextHierarchy(unconditionalFragmentContext, context); + RecreateScopeHierarchy(unconditionalFragmentContext, context, unconditionalContext); return conditionalContextBelowUnconditionalFragmentContext; } @@ -291,6 +415,27 @@ private static Context GetOrAddContextForFragment( unconditionalContext.RecordReferenceInConditionalContext(inlineFragmentNode, context); } + // Even though this is a conditional context, defer back-refs may have been + // registered here when a nested defer's NonDeferredContext was this conditional. + if (context.TryGetDeferContextsWithReferences(inlineFragmentNode, out var deferContextsInConditional)) + { + foreach (var deferContext in deferContextsInConditional) + { + if (deferContext.HasFragment(inlineFragmentNode, + out var deferFragmentContext)) + { + var deferContextBelowFragment = + RecreateScopeHierarchy(fragmentContext, deferContext, context); + + MergeContexts(deferFragmentContext, deferContextBelowFragment); + } + + deferContext.RemoveFragment(inlineFragmentNode); + } + + context.RemoveReferenceToDeferContext(inlineFragmentNode); + } + return fragmentContext; } else @@ -308,7 +453,7 @@ private static Context GetOrAddContextForFragment( out var conditionalFragmentContext)) { var conditionalContextBelowUnconditionalFragment = - RecreateConditionalContextHierarchy(fragmentContext, conditionalContext); + RecreateScopeHierarchy(fragmentContext, conditionalContext, context); MergeContexts(conditionalFragmentContext, conditionalContextBelowUnconditionalFragment); } @@ -319,28 +464,59 @@ private static Context GetOrAddContextForFragment( context.RemoveReferenceToConditionalContext(inlineFragmentNode); } + if (context.TryGetDeferContextsWithReferences(inlineFragmentNode, out var deferContexts)) + { + foreach (var deferContext in deferContexts) + { + if (deferContext.HasFragment(inlineFragmentNode, + out var deferFragmentContext)) + { + var deferContextBelowFragment = + RecreateScopeHierarchy(fragmentContext, deferContext, context); + + MergeContexts(deferFragmentContext, deferContextBelowFragment); + } + + deferContext.RemoveFragment(inlineFragmentNode); + } + + context.RemoveReferenceToDeferContext(inlineFragmentNode); + } + return fragmentContext; } } /// - /// Rebuilds the conditional directive hierarchy of into - /// , returning the innermost, rebuilt conditional context. + /// Rebuilds the conditional and defer scope hierarchy of + /// up to (but not including) into , + /// returning the innermost rebuilt scope context. Each defer layer becomes a fresh defer + /// context (defers do not merge); each conditional layer is reused or created via the + /// existing conditional dictionary on the target. /// - private static Context RecreateConditionalContextHierarchy(Context targetContext, Context sourceContext) + private static Context RecreateScopeHierarchy(Context targetContext, Context sourceContext, Context boundary) { - var conditionalStack = new Stack(); + var stack = new Stack(); var current = sourceContext; - while (current?.IsConditionalContext == true) + while (current is not null + && !ReferenceEquals(current, boundary) + && (current.IsConditionalContext || current.IsDeferContext)) { - conditionalStack.Push(current); + stack.Push(current); current = current.Parent; } - while (conditionalStack.TryPop(out var conditionalContext)) + while (stack.TryPop(out var scopeContext)) { - targetContext = targetContext.GetOrAddConditionalContext(conditionalContext.Conditional!); + if (scopeContext.IsConditionalContext) + { + targetContext = targetContext.GetOrAddConditionalContext(scopeContext.Conditional); + } + else if (scopeContext.IsDeferContext) + { + targetContext = targetContext.AddDeferContext(scopeContext.Defer); + } } return targetContext; @@ -358,6 +534,16 @@ private static void MergeContexts(Context source, Context target) } } + if (source.Defers is not null) + { + foreach (var deferContext in source.Defers) + { + var targetDeferContext = target.AddDeferContext(deferContext.Defer!); + + MergeContexts(deferContext, targetDeferContext); + } + } + if (source.Fields is not null) { foreach (var (_, fieldContextLookup) in source.Fields) @@ -397,16 +583,17 @@ private static void MergeContexts(Context source, Context target) } } - private (Conditional? Conditional, IReadOnlyList? Directives) DivideDirectives( + private (Conditional? Conditional, Defer? Defer, IReadOnlyList? Directives) DivideDirectives( IHasDirectives directiveProvider, Types.DirectiveLocation targetLocation) { if (directiveProvider.Directives.Count == 0) { - return (null, null); + return (null, null, null); } Conditional? conditional = null; + Defer? defer = null; List? directives = null; foreach (var directive in directiveProvider.Directives) @@ -447,20 +634,51 @@ private static void MergeContexts(Context source, Context target) if (directive.Name.Value.Equals(DirectiveNames.Defer.Name, StringComparison.Ordinal)) { - var ifArgument = directive.Arguments - .FirstOrDefault(a => a.Name.Value.Equals("if", StringComparison.Ordinal)); + StringValueNode? label = null; + IValueNode? ifValue = null; + var dropDirective = false; - if (ifArgument?.Value is BooleanValueNode { Value: false }) + foreach (var argument in directive.Arguments) + { + if (argument.Name.Value.Equals(DirectiveNames.Defer.Arguments.If, StringComparison.Ordinal)) + { + if (argument.Value is BooleanValueNode { Value: false }) + { + dropDirective = true; + break; + } + + if (argument.Value is BooleanValueNode { Value: true }) + { + // Canonicalize: @defer(if: true) keeps its identity but the + // redundant `if` argument is dropped from the emitted directive. + continue; + } + + ifValue = argument.Value; + } + else if (argument.Name.Value.Equals(DirectiveNames.Defer.Arguments.Label, StringComparison.Ordinal) + && argument.Value is StringValueNode labelValue) + { + label = labelValue; + } + } + + if (dropDirective) { continue; } + + defer = new Defer { Label = label, If = ifValue }; + + continue; } directives ??= []; directives.Add(rewrittenDirective); } - return (conditional, directives); + return (conditional, defer, directives); } /// @@ -651,6 +869,22 @@ private static Dictionary CreateFragmentLookup(D } } + if (context.Defers is not null) + { + foreach (var deferContext in context.Defers) + { + var deferSelection = RewriteDeferred(deferContext); + + if (deferSelection is null) + { + continue; + } + + selections ??= []; + selections.Add(deferSelection); + } + } + return selections; } @@ -684,6 +918,32 @@ private static Dictionary CreateFragmentLookup(D }; } + private InlineFragmentNode? RewriteDeferred(Context deferContext) + { + var deferSelections = RewriteSelections(deferContext); + + if (deferSelections is null) + { + return null; + } + + var deferDirective = deferContext.Defer!.ToDirective(); + + // If we only have a single type-refining inline fragment without other directives, + // we can push the @defer directive down onto it. @defer is only valid on fragments, + // so we never push it onto a FieldNode. + if (deferSelections is [InlineFragmentNode { Directives.Count: 0 } inlineFragmentNode]) + { + return inlineFragmentNode.WithDirectives([deferDirective]); + } + + return new InlineFragmentNode( + null, + null, + [deferDirective], + new SelectionSetNode(deferSelections)); + } + private FieldNode? RewriteField( FieldNode fieldNode, Context? fieldContext) @@ -782,12 +1042,15 @@ private static IReadOnlyList RewriteArguments(IReadOnlyList fragmentLookup) { /// @@ -826,6 +1089,32 @@ private sealed class Context( /// private Dictionary>? ReferencesInConditionalContexts { get; set; } + [MemberNotNullWhen(true, nameof(Defer))] + [MemberNotNullWhen(true, nameof(NonDeferredContext))] + public bool IsDeferContext { get; } = defer is not null; + + /// + /// Contains the defer, if this context is a defer context. + /// + public Defer? Defer { get; } = defer; + + /// + /// If this context is a defer context, this points to the nearest enclosing + /// context that is not itself a defer (the transitive non-defer ancestor). + /// + public Context? NonDeferredContext { get; } = nonDeferredContext; + + /// + /// The contexts for each occurrence. + /// Each occurrence produces a distinct entry; defers are never merged by identity. + /// + public List? Defers { get; private set; } + + /// + /// Provides a way to find all defer contexts a given selection node is referenced in. + /// + private Dictionary>? ReferencesInDeferContexts { get; set; } + /// /// Provides a fast way to get all FieldNodes for the same response name. /// The key is the response name. @@ -851,8 +1140,10 @@ public Context GetOrAddConditionalContext(Conditional conditional) conditionalContext = new Context( this, GetUnconditionalContext(), + GetNonDeferredContext(), Type, conditional, + null, fragmentLookup); Conditionals[conditional] = conditionalContext; @@ -861,14 +1152,30 @@ public Context GetOrAddConditionalContext(Conditional conditional) return conditionalContext; } + public Context AddDeferContext(Defer defer) + { + var deferContext = new Context( + this, + GetUnconditionalContext(), + GetNonDeferredContext(), + Type, + null, + defer, + fragmentLookup); + + Defers ??= []; + Defers.Add(deferContext); + + return deferContext; + } + /// /// Records that is referenced in , /// so we can later quickly jump there. /// public void RecordReferenceInConditionalContext(ISelectionNode selectionNode, Context conditionalContext) { - ReferencesInConditionalContexts ??= - new Dictionary>(SyntaxNodeComparer.Instance); + ReferencesInConditionalContexts ??= new(SyntaxNodeComparer.Instance); if (!ReferencesInConditionalContexts.TryGetValue(selectionNode, out var conditionalContexts)) { @@ -898,6 +1205,42 @@ public void RemoveReferenceToConditionalContext(ISelectionNode selectionNode) ReferencesInConditionalContexts?.Remove(selectionNode); } + /// + /// Records that is referenced in , + /// so we can later quickly jump there. + /// + public void RecordReferenceInDeferContext(ISelectionNode selectionNode, Context deferContext) + { + ReferencesInDeferContexts ??= new(SyntaxNodeComparer.Instance); + + if (!ReferencesInDeferContexts.TryGetValue(selectionNode, out var deferContexts)) + { + deferContexts = []; + ReferencesInDeferContexts[selectionNode] = deferContexts; + } + + deferContexts.Add(deferContext); + } + + public bool TryGetDeferContextsWithReferences( + ISelectionNode selectionNode, + [NotNullWhen(true)] out List? deferContexts) + { + deferContexts = null; + + if (ReferencesInDeferContexts is null) + { + return false; + } + + return ReferencesInDeferContexts.TryGetValue(selectionNode, out deferContexts); + } + + public void RemoveReferenceToDeferContext(ISelectionNode selectionNode) + { + ReferencesInDeferContexts?.Remove(selectionNode); + } + public bool HasField(FieldNode fieldNode, out Context? fieldContext) { fieldContext = null; @@ -926,8 +1269,10 @@ public bool HasField(FieldNode fieldNode, out Context? fieldContext) fieldContext = new Context( this, GetUnconditionalContext(), + GetNonDeferredContext(), fieldType, null, + null, fragmentLookup); } @@ -944,7 +1289,7 @@ public void AddField(FieldNode fieldNode, Context? fieldContext) if (!Fields.TryGetValue(responseName, out var existingFieldContextLookup)) { - existingFieldContextLookup = new Dictionary(FieldNodeComparer.Instance); + existingFieldContextLookup = new(FieldNodeComparer.Instance); Fields[responseName] = existingFieldContextLookup; } @@ -994,8 +1339,10 @@ public Context AddFragment(InlineFragmentNode inlineFragmentNode, ITypeDefinitio var fragmentContext = new Context( this, GetUnconditionalContext(), + GetNonDeferredContext(), typeCondition, null, + null, fragmentLookup); AddFragment(inlineFragmentNode, fragmentContext); @@ -1011,8 +1358,7 @@ public void AddFragment(InlineFragmentNode inlineFragmentNode, Context fragmentC if (!Fragments.TryGetValue(typeName, out var existingFragmentContextLookup)) { - existingFragmentContextLookup = - new Dictionary(InlineFragmentNodeComparer.Instance); + existingFragmentContextLookup = new(InlineFragmentNodeComparer.Instance); Fragments[typeName] = existingFragmentContextLookup; } @@ -1045,6 +1391,16 @@ private Context GetUnconditionalContext() return this; } + + private Context GetNonDeferredContext() + { + if (IsDeferContext) + { + return NonDeferredContext; + } + + return this; + } } /// @@ -1115,6 +1471,40 @@ private static int GetDirectiveHashCode(DirectiveNode? node) } } + /// + /// Represents a single occurrence of @defer. Defer instances are identity-bearing + /// data carriers without structural equality; sibling defers are never merged. + /// + private sealed class Defer + { + public StringValueNode? Label { get; init; } + + /// + /// The if argument of the directive, or null when the defer is + /// unconditional. @defer(if: true) is canonicalized to null. + /// + public IValueNode? If { get; init; } + + public DirectiveNode ToDirective() + { + List? arguments = null; + + if (If is not null) + { + arguments ??= []; + arguments.Add(new ArgumentNode(DirectiveNames.Defer.Arguments.If, If)); + } + + if (Label is not null) + { + arguments ??= []; + arguments.Add(new ArgumentNode(DirectiveNames.Defer.Arguments.Label, Label)); + } + + return new DirectiveNode(DirectiveNames.Defer.Name, arguments?.ToArray() ?? []); + } + } + #region Comparers private sealed class SyntaxNodeComparer : IEqualityComparer @@ -1175,7 +1565,7 @@ public bool Equals(InlineFragmentNode? x, InlineFragmentNode? y) && Equals(x.Directives, y.Directives); } - private bool Equals(IReadOnlyList a, IReadOnlyList b) + private static bool Equals(IReadOnlyList a, IReadOnlyList b) { if (a.Count == 0 && b.Count == 0) { @@ -1230,7 +1620,7 @@ public bool Equals(FieldNode? x, FieldNode? y) && Equals(x.Arguments, y.Arguments); } - private bool Equals(IReadOnlyList a, IReadOnlyList b) + private static bool Equals(IReadOnlyList a, IReadOnlyList b) { if (a.Count == 0 && b.Count == 0) { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml index 6d12f5f078a..d496d4f6d55 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/FusionBenchmarkTests.Complex_Query.yaml @@ -25,6 +25,7 @@ operation: } ... @skip(if: $level4) { ... @include(if: $level5) { + id @fusion__requirement reviews(first: 10) @skip(if: $level6) { pageInfo { hasNextPage @@ -48,7 +49,6 @@ operation: } } } - id @fusion__requirement } } } @@ -90,10 +90,8 @@ operation: height width } - id @fusion__requirement } } - id @fusion__requirement } } } @@ -111,6 +109,7 @@ operation: ... on Product { name price + id @fusion__requirement reviews(first: 3) @include(if: $includeReviews) { nodes { id @@ -145,19 +144,15 @@ operation: stars } } - id @fusion__requirement } - id @fusion__requirement } } - id @fusion__requirement } } } } } } - id @fusion__requirement } ... on Article { content @@ -182,10 +177,8 @@ operation: height width } - id @fusion__requirement } } - id @fusion__requirement } } } @@ -229,7 +222,6 @@ operation: height width } - id @fusion__requirement } } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Utilities.Tests/Rewriters/DocumentRewriterTests.cs b/src/HotChocolate/Fusion/test/Fusion.Utilities.Tests/Rewriters/DocumentRewriterTests.cs index 47de51660b1..887a5f84c1c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Utilities.Tests/Rewriters/DocumentRewriterTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Utilities.Tests/Rewriters/DocumentRewriterTests.cs @@ -2553,4 +2553,1020 @@ dimension @skip(if: $conditional) { } """); } + + [Fact] + public void Defer_Stripped_When_Field_Also_Selected_Outside_Defer() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + } + name + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + name + } + } + """); + } + + [Fact] + public void Defer_Moves_Down_To_Children_When_Parent_Field_Selected_Outside_Defer() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + dimension { + height + } + } + dimension { + width + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + dimension { + width + ... @defer { + height + } + } + } + } + """); + } + + [Fact] + public void Defer_Preserved_When_Field_Only_In_Defer() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + description + ... @defer { + name + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + description + ... @defer { + name + } + } + } + """); + } + + [Fact] + public void Defer_With_If_False_Is_Statically_Removed() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer(if: false) { + name + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + name + } + } + """); + } + + [Fact] + public void Defer_With_If_True_Is_Canonicalized_To_No_If_But_Not_Merged() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + } + ... @defer(if: true) { + description + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + } + ... @defer { + description + } + } + } + """); + } + + [Fact] + public void Defer_With_Different_Labels_Not_Merged() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "a") { + name + } + ... @defer(label: "b") { + description + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "a") { + name + } + ... @defer(label: "b") { + description + } + } + } + """); + } + + [Fact] + public void Defer_With_Same_Label_Not_Merged() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "a") { + name + } + ... @defer(label: "a") { + description + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "a") { + name + } + ... @defer(label: "a") { + description + } + } + } + """); + } + + [Fact] + public void Defer_With_Different_If_Variables_Not_Merged() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + query($a: Boolean!, $b: Boolean!) { + productBySlug(slug: "a") { + ... @defer(if: $a) { + name + } + ... @defer(if: $b) { + description + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + query($a: Boolean!, $b: Boolean!) { + productBySlug(slug: "a") { + ... @defer(if: $a) { + name + } + ... @defer(if: $b) { + description + } + } + } + """); + } + + [Fact] + public void Defer_With_Same_If_Variable_Not_Merged() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + query($a: Boolean!) { + productBySlug(slug: "a") { + ... @defer(if: $a) { + name + } + ... @defer(if: $a) { + description + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + query($a: Boolean!) { + productBySlug(slug: "a") { + ... @defer(if: $a) { + name + } + ... @defer(if: $a) { + description + } + } + } + """); + } + + [Fact] + public void Nested_Defer_Field_Pulled_Out_When_Selected_Outside() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + dimension { + height + width + } + } + dimension { + height + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + dimension { + height + ... @defer { + width + } + } + } + } + """); + } + + [Fact] + public void Defer_Inside_Conditional_Pulled_To_Conditional_When_Same_Field_Outside_Defer() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + query($skip: Boolean!) { + productBySlug(slug: "a") { + ... @skip(if: $skip) { + ... @defer { + name + } + } + ... @skip(if: $skip) { + name + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + query($skip: Boolean!) { + productBySlug(slug: "a") { + name @skip(if: $skip) + } + } + """); + } + + [Fact] + public void Defer_Inside_Conditional_Kept_When_Field_Not_Selected_Outside_Defer() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + query($skip: Boolean!) { + productBySlug(slug: "a") { + ... @skip(if: $skip) { + ... @defer { + name + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + query($skip: Boolean!) { + productBySlug(slug: "a") { + ... @skip(if: $skip) { + ... @defer { + name + } + } + } + } + """); + } + + [Fact] + public void Defer_With_Multiple_Children_Only_Overlapping_Pulled_Out() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + description + price + } + name + price + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + name + price + ... @defer { + description + } + } + } + """); + } + + [Fact] + public void Defer_FragmentSpread_Stripped_When_Field_Also_Selected_Outside() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ...ProductFields @defer + name + } + } + + fragment ProductFields on Product { + name + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + name + } + } + """); + } + + [Fact] + public void Defer_On_TypedInlineFragment_Pushed_Down_When_Field_Selected_Outside() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... on Product @defer { + dimension { + height + } + } + dimension { + width + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + dimension { + width + ... @defer { + height + } + } + } + } + """); + } + + [Fact] + public void Defer_On_TypedInlineFragment_With_Refinement_Preserved_When_No_Overlap() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + reviewById(id: 1) { + id + ... on Review @defer { + body + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + reviewById(id: 1) { + id + ... @defer { + body + } + } + } + """); + } + + [Fact] + public void Defer_On_TypedInlineFragment_With_Refinement_Stays_On_InlineFragment() + { + // arrange + var schemaDefinition = SchemaParser.Parse( + """ + type Query { + votables: [Votable!]! + } + + interface Votable { + viewerCanVote: Boolean! + voteCount: Int + } + + type Product implements Votable { + id: ID! + viewerCanVote: Boolean! + voteCount: Int + } + """); + + var doc = Utf8GraphQLParser.Parse( + """ + { + votables { + ... on Product @defer { + voteCount + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + votables { + ... on Product @defer { + voteCount + } + } + } + """); + } + + [Fact] + public void Defer_On_TypedInlineFragment_With_Refinement_Moves_Down_When_Sibling_Without_Defer() + { + // arrange + var schemaDefinition = SchemaParser.Parse( + """ + type Query { + votables: [Votable!]! + } + + interface Votable { + viewerCanVote: Boolean! + voteCount: Int + } + + type Product implements Votable { + id: ID! + viewerCanVote: Boolean! + voteCount: Int + } + """); + + var doc = Utf8GraphQLParser.Parse( + """ + { + votables { + ... on Product @defer { + voteCount + } + ... on Product { + viewerCanVote + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + votables { + ... on Product { + viewerCanVote + ... @defer { + voteCount + } + } + } + } + """); + } + + [Fact] + public void Defer_Nested_Inside_Defer_Field_Pulled_Out_When_Selected_Outside_Outer_Defer() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "outer") { + ... @defer(label: "inner") { + name + } + } + name + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + name + } + } + """); + } + + [Fact] + public void Nested_Defer_With_Same_Identity_Kept_Intact() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + ... @defer { + name + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer { + ... @defer { + name + } + } + } + } + """); + } + + [Fact] + public void Nested_Defer_With_Same_Label_Kept_Intact() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "a") { + ... @defer(label: "a") { + name + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "a") { + ... @defer(label: "a") { + name + } + } + } + } + """); + } + + [Fact] + public void Nested_Defer_With_Different_Labels_Kept_Intact() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "outer") { + ... @defer(label: "inner") { + name + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer(label: "outer") { + ... @defer(label: "inner") { + name + } + } + } + } + """); + } + + [Fact] + public void Nested_Defer_With_Field_Already_In_Parent_Defer_Is_Stripped() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + ... @defer { + name + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + } + } + } + """); + } + + [Fact] + public void Nested_Defer_With_Some_Fields_Already_In_Parent_Defer_Moves_Down() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + ... @defer { + name + description + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer { + name + ... @defer { + description + } + } + } + } + """); + } + + [Fact] + public void Nested_Defer_With_Parent_Composite_Pushed_Down() + { + // arrange + var sourceText = FileResource.Open("schema1.graphql"); + var schemaDefinition = SchemaParser.Parse(sourceText); + + var doc = Utf8GraphQLParser.Parse( + """ + { + productBySlug(slug: "a") { + ... @defer { + dimension { + height + } + ... @defer { + dimension { + width + } + } + } + } + } + """); + + // act + var rewriter = new DocumentRewriter(schemaDefinition); + var rewritten = rewriter.RewriteDocument(doc); + + // assert + rewritten.MatchInlineSnapshot( + """ + { + productBySlug(slug: "a") { + ... @defer { + dimension { + height + ... @defer { + width + } + } + } + } + } + """); + } }