diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs index 9526f8270eb..085e491f481 100644 --- a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs @@ -58,8 +58,7 @@ public EntityKey(SelectionSetNode fields) public EntityKey(string fields) { ArgumentNullException.ThrowIfNull(fields); - fields = $"{{ {fields.Trim('{', '}')} }}"; - Fields = Utf8GraphQLParser.Syntax.ParseSelectionSet(fields); + Fields = FieldSelectionSetType.ParseSelectionSet(fields); } /// diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKeyDescriptorExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKeyDescriptorExtensions.cs index a1513d5544a..d7abf4ee272 100644 --- a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKeyDescriptorExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKeyDescriptorExtensions.cs @@ -39,8 +39,7 @@ public static IObjectTypeDescriptor EntityKey( try { - fields = $"{{ {fields.Trim('{', '}')} }}"; - selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(fields); + selectionSet = FieldSelectionSetType.ParseSelectionSet(fields); } catch (SyntaxException ex) { @@ -87,8 +86,7 @@ public static IInterfaceTypeDescriptor EntityKey( try { - fields = $"{{ {fields.Trim('{', '}')} }}"; - selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(fields); + selectionSet = FieldSelectionSetType.ParseSelectionSet(fields); } catch (SyntaxException ex) { diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Provides.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Provides.cs index 26335549b50..9ff5631ca84 100644 --- a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Provides.cs +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Provides.cs @@ -19,8 +19,7 @@ public Provides(SelectionSetNode fields) public Provides(string fields) { ArgumentNullException.ThrowIfNull(fields); - fields = $"{{ {fields.Trim('{', '}')} }}"; - Fields = Utf8GraphQLParser.Syntax.ParseSelectionSet(fields); + Fields = FieldSelectionSetType.ParseSelectionSet(fields); } [GraphQLType>] @@ -43,8 +42,7 @@ public static IObjectFieldDescriptor Provides( try { - fields = $"{{ {fields.Trim('{', '}')} }}"; - selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(fields); + selectionSet = FieldSelectionSetType.ParseSelectionSet(fields); } catch (SyntaxException ex) { diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Types/FieldSelectionSetType.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Types/FieldSelectionSetType.cs index 43f2f13270c..5fefc57cddc 100644 --- a/src/HotChocolate/Core/src/Types/Types/Composite/Types/FieldSelectionSetType.cs +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Types/FieldSelectionSetType.cs @@ -89,7 +89,11 @@ protected override StringValueNode OnValueToLiteral(SelectionSetNode runtimeValu /// internal static SelectionSetNode ParseSelectionSet(string s) { - s = $"{{ {s.Trim('{', '}')} }}"; + s = s.Trim(); + if (s.Length < 2 || s[0] != '{' || s[^1] != '}') + { + s = $"{{ {s} }}"; + } return Utf8GraphQLParser.Syntax.ParseSelectionSet(s); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs index 0ff38fd3f93..cdff19dd49f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -793,7 +793,9 @@ private static SourceObjectFieldCollection BuildSourceOutputFieldCollection( fieldDirective.SourceName ?? fieldDefinition.Name, context.GetSchemaName(fieldDirective.SchemaKey), requirements, - CompleteType(fieldDef.Type, fieldDirective.SourceType, context))); + CompleteType(fieldDef.Type, fieldDirective.SourceType, context), + fieldDirective.IsExternal, + fieldDirective.Provides)); } return new SourceObjectFieldCollection(temp.ToImmutable()); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs index b7b79ddce18..54a3322c582 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs @@ -1,3 +1,4 @@ +using HotChocolate.Language; using HotChocolate.Types; namespace HotChocolate.Fusion.Types.Metadata; @@ -6,7 +7,9 @@ public sealed class SourceOutputField( string name, string schemaName, FieldRequirements? requirements, - IType type) + IType type, + bool isExternal, + SelectionSetNode? provides) : ISourceMember { public string Name { get; } = name; @@ -17,5 +20,7 @@ public sealed class SourceOutputField( public IType Type { get; } = type; - public int BaseCost => 1; + public bool IsExternal { get; } = isExternal; + + public SelectionSetNode? Provides { get; } = provides; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs index 5794e7b140c..9f1bf8f4d84 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs @@ -239,12 +239,78 @@ void CompleteSelection(T original, T? resolvable, T? unresolvable, int index) } static SelectionSetNode? GetProvidedSelectionSet( - ITypeDefinition _1, - FusionSchemaDefinition _2, + ITypeDefinition type, + FusionSchemaDefinition schema, SelectionSetNode? providedSelectionSetNode) { - // todo match correct inline fragment - return providedSelectionSetNode; + if (providedSelectionSetNode is null) + { + return null; + } + + List? flattened = null; + var hasFragment = false; + + for (var i = 0; i < providedSelectionSetNode.Selections.Count; i++) + { + var selection = providedSelectionSetNode.Selections[i]; + + if (selection is InlineFragmentNode fragment) + { + hasFragment = true; + + if (fragment.TypeCondition is null) + { + flattened ??= CopyUpTo(providedSelectionSetNode.Selections, i); + flattened.AddRange(fragment.SelectionSet.Selections); + continue; + } + + if (!schema.Types.TryGetType(fragment.TypeCondition.Name.Value, out var fragmentType)) + { + flattened ??= CopyUpTo(providedSelectionSetNode.Selections, i); + continue; + } + + if (fragmentType.IsAssignableFrom(type)) + { + flattened ??= CopyUpTo(providedSelectionSetNode.Selections, i); + flattened.AddRange(fragment.SelectionSet.Selections); + } + else if (type.IsAssignableFrom(fragmentType)) + { + flattened ??= CopyUpTo(providedSelectionSetNode.Selections, i); + flattened.Add(fragment); + } + else + { + flattened ??= CopyUpTo(providedSelectionSetNode.Selections, i); + } + } + else + { + flattened?.Add(selection); + } + } + + if (!hasFragment) + { + return providedSelectionSetNode; + } + + return flattened is null + ? providedSelectionSetNode + : new SelectionSetNode(flattened); + } + + static List CopyUpTo(IReadOnlyList selections, int exclusiveEnd) + { + var copy = new List(selections.Count); + for (var j = 0; j < exclusiveEnd; j++) + { + copy.Add(selections[j]); + } + return copy; } static bool IsTypeNameSelection(ISelectionNode selection) @@ -385,29 +451,29 @@ private static SelectionSetNode WrapInExtraConditions( FieldNode? providedFieldNode) { var field = complexType.Fields.GetField(fieldNode.Name.Value, allowInaccessibleFields: true); + field.Sources.TryGetMember(context.SchemaName, out var source); + + // The field is resolvable from the current schema when either the parent's provides + // scope covers it, or the schema declares a non-external source for it. + var isResolvable = providedFieldNode is not null || source is { IsExternal: false }; - if (providedFieldNode is null) + if (!isResolvable) { - // if the field is not available in the current schema we return null - // which will remove the field from the rewritten selection set. - if (!field.Sources.TryGetMember(context.SchemaName, out var source)) - { - return (null, fieldNode); - } + return (null, fieldNode); + } - if (source.Requirements is not null) - { - context.FieldsWithRequirements = - context.FieldsWithRequirements.Push( - new ConditionedFieldSelection( - new FieldSelection( - context.GetId((SelectionSetNode)context.Nodes.Peek()), - fieldNode, - field, - context.BuildPath()), - context.SnapshotConditions())); - return (null, null); - } + if (providedFieldNode is null && source?.Requirements is not null) + { + context.FieldsWithRequirements = + context.FieldsWithRequirements.Push( + new ConditionedFieldSelection( + new FieldSelection( + context.GetId((SelectionSetNode)context.Nodes.Peek()), + fieldNode, + field, + context.BuildPath()), + context.SnapshotConditions())); + return (null, null); } var selectionSet = fieldNode.SelectionSet; @@ -420,7 +486,7 @@ private static SelectionSetNode WrapInExtraConditions( context, field.Type.AsTypeDefinition(), selectionSet, - providedFieldNode?.SelectionSet); + MergeProvidedSelectionSets(providedFieldNode?.SelectionSet, source?.Provides)); context.Nodes.Pop(); @@ -437,6 +503,26 @@ private static SelectionSetNode WrapInExtraConditions( return (fieldNode, null); } + private static SelectionSetNode? MergeProvidedSelectionSets( + SelectionSetNode? inherited, + SelectionSetNode? fromSource) + { + if (inherited is null) + { + return fromSource; + } + + if (fromSource is null) + { + return inherited; + } + + var merged = new List(inherited.Selections.Count + fromSource.Selections.Count); + merged.AddRange(inherited.Selections); + merged.AddRange(fromSource.Selections); + return new SelectionSetNode(merged); + } + private (InlineFragmentNode?, InlineFragmentNode?) RewriteFragmentNode( Context context, ITypeDefinition type, diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs new file mode 100644 index 00000000000..94d084da529 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs @@ -0,0 +1,544 @@ +using System.Text.Json; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion; + +public class ProvidesTests : FusionTestBase +{ + [Fact] + public async Task Provides_Simple_Covers_Selection() + { + // arrange + using var serverReviews = CreateSourceSchema( + "reviews", + """ + directive @external on FIELD_DEFINITION + + schema { + query: Query + } + + type Query { + reviews: [Review] + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + """); + + using var serverUsers = CreateSourceSchema( + "users", + """ + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("reviews", serverReviews), + ("users", serverUsers) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + reviews { + author { + username + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + Assert.False(gateway.Interactions.ContainsKey("users")); + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Provides_Partial_Covers_Some() + { + // arrange + using var serverReviews = CreateSourceSchema( + "reviews", + """ + directive @external on FIELD_DEFINITION + + schema { + query: Query + } + + type Query { + reviews: [Review] + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + """); + + using var serverUsers = CreateSourceSchema( + "users", + """ + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + email: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("reviews", serverReviews), + ("users", serverUsers) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + reviews { + author { + username + email + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Provides_On_Interface() + { + // arrange + using var serverA = CreateSourceSchema( + "a", + """ + directive @external on FIELD_DEFINITION + + schema { + query: Query + } + + type Query { + book: Book + } + + type Book @key(fields: "id") { + id: ID! + featured: Animal @provides(fields: "... on Cat { age } ... on Dog { tricks }") + } + + interface Animal { + id: ID! + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + age: Int @external + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + tricks: [String] @external + } + """); + + using var serverB = CreateSourceSchema( + "b", + """ + schema { + query: Query + } + + type Query { + catById(id: ID! @is(field: "id")): Cat @lookup @internal + dogById(id: ID! @is(field: "id")): Dog @lookup @internal + } + + interface Animal { + id: ID! + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + age: Int + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + tricks: [String] + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("a", serverA), + ("b", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + book { + featured { + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + // note: The composite-schemas spec forbids @provides on fields that return a + // union type (ProvidesOnNonCompositeFieldRule). The planner-level + // Provides_On_Union scenario was dropped in Step 3 for the same reason; the + // integration counterpart is unreachable and intentionally absent here. + + [Fact] + public async Task Provides_External_Without_Cover() + { + // arrange + using var serverReviews = CreateSourceSchema( + "reviews", + """ + directive @external on FIELD_DEFINITION + + schema { + query: Query + } + + type Query { + reviews: [Review] + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + """); + + using var serverUsers = CreateSourceSchema( + "users", + """ + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + email: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("reviews", serverReviews), + ("users", serverUsers) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + reviews { + author { + email + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Provides_Deeply_Nested_Chain() + { + // arrange + using var serverA = CreateSourceSchema( + "a", + """ + directive @external on FIELD_DEFINITION + + schema { + query: Query + } + + type Query { + root: Root + } + + type Root { + level1: Level1 @provides(fields: "level2 { level3 { value } }") + } + + type Level1 @key(fields: "id") { + id: ID! + level2: Level2 @external + } + + type Level2 @key(fields: "id") { + id: ID! + level3: Level3 @external + } + + type Level3 @key(fields: "id") { + id: ID! + value: String @external + } + """); + + using var serverB = CreateSourceSchema( + "b", + """ + schema { + query: Query + } + + type Query { + level1ById(id: ID! @is(field: "id")): Level1 @lookup @internal + level2ById(id: ID! @is(field: "id")): Level2 @lookup @internal + level3ById(id: ID! @is(field: "id")): Level3 @lookup @internal + } + + type Level1 @key(fields: "id") { + id: ID! + level2: Level2 + } + + type Level2 @key(fields: "id") { + id: ID! + level3: Level3 + } + + type Level3 @key(fields: "id") { + id: ID! + value: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("a", serverA), + ("b", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External() + { + // arrange + // The query enters the 'reviews' source (which owns Query.reviews). 'reviews' + // also declares Product.name, but as @external with no @provides on the query + // path referencing it. The off-path productByName root field exists only to + // satisfy the composite-schemas-spec ExternalUnusedRule. The planner must not + // trust 'reviews' for 'name' and must route it to 'products' via productById. + using var serverReviews = CreateSourceSchema( + "reviews", + """ + directive @external on FIELD_DEFINITION + + schema { + query: Query + } + + type Query { + reviews: [Review] + productByName(name: String!): Product @provides(fields: "name") + } + + type Review @key(fields: "id") { + id: ID! + body: String + product: Product + } + + type Product @key(fields: "id") { + id: ID! + name: String @external + } + """); + + using var serverProducts = CreateSourceSchema( + "products", + """ + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + name: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("reviews", serverReviews), + ("products", serverProducts) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + reviews { + product { + name + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + // Every interaction with the 'reviews' source must fetch product { id } only, + // never 'name'. A 'name' fetch from 'reviews' would mean the planner wrongly + // trusted the orphan @external declaration. Every interaction with the + // 'products' source must be a productById lookup that returns 'name'. + var reviewsInteractions = gateway.Interactions.GetValueOrDefault("reviews"); + Assert.NotNull(reviewsInteractions); + foreach (var interaction in reviewsInteractions!.Values) + { + Assert.NotNull(interaction.Request); + interaction.Request!.Body.Position = 0; + using var body = JsonDocument.Parse(interaction.Request.Body); + var query = body.RootElement.GetProperty("query").GetString()!; + Assert.DoesNotContain("name", query); + Assert.Contains("product", query); + interaction.Request.Body.Position = 0; + } + + var productsInteractions = gateway.Interactions.GetValueOrDefault("products"); + Assert.NotNull(productsInteractions); + foreach (var interaction in productsInteractions!.Values) + { + Assert.NotNull(interaction.Request); + interaction.Request!.Body.Position = 0; + using var body = JsonDocument.Parse(interaction.Request.Body); + var query = body.RootElement.GetProperty("query").GetString()!; + Assert.Contains("productById", query); + Assert.Contains("name", query); + interaction.Request.Body.Position = 0; + } + + await MatchSnapshotAsync(gateway, request, result); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External.yaml new file mode 100644 index 00000000000..db0594e3794 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External.yaml @@ -0,0 +1,195 @@ +title: Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External +request: + document: | + { + reviews { + product { + name + } + } + } +response: + body: | + { + "data": { + "reviews": [ + { + "product": { + "name": "Product: UHJvZHVjdDo0" + } + }, + { + "product": { + "name": "Product: UHJvZHVjdDo1" + } + }, + { + "product": { + "name": "Product: UHJvZHVjdDo2" + } + } + ] + } + } +sourceSchemas: + - name: reviews + schema: | + schema { + query: Query + } + + type Product @key(fields: "id") { + id: ID! + name: String @external + } + + type Query { + reviews: [Review] + productByName(name: String!): Product @provides(fields: "name") + } + + type Review @key(fields: "id") { + id: ID! + body: String + product: Product + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_ba13c690_1 { + reviews { + product { + id + } + } + } + response: + results: + - | + { + "data": { + "reviews": [ + { + "product": { + "id": "UHJvZHVjdDo0" + } + }, + { + "product": { + "id": "UHJvZHVjdDo1" + } + }, + { + "product": { + "id": "UHJvZHVjdDo2" + } + } + ] + } + } + - name: products + schema: | + schema { + query: Query + } + + type Product @key(fields: "id") { + id: ID! + name: String + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_ba13c690_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + name + } + } + variables: | + [ + { + "__fusion_1_id": "UHJvZHVjdDo0" + }, + { + "__fusion_1_id": "UHJvZHVjdDo1" + }, + { + "__fusion_1_id": "UHJvZHVjdDo2" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "productById": { + "name": "Product: UHJvZHVjdDo0" + } + } + } + - | + { + "data": { + "productById": { + "name": "Product: UHJvZHVjdDo1" + } + } + } + - | + { + "data": { + "productById": { + "name": "Product: UHJvZHVjdDo2" + } + } + } +operationPlan: + operation: + - document: | + { + reviews { + product { + name + id @fusion__requirement + } + } + } + hash: ba13c690662a15c32dd1889fabb7dd37 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_ba13c690_1 { + reviews { + product { + id + } + } + } + - id: 2 + type: Operation + schema: products + operation: | + query Op_ba13c690_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + name + } + } + source: $.productById + target: $.reviews.product + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Deeply_Nested_Chain.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Deeply_Nested_Chain.yaml new file mode 100644 index 00000000000..847c3d5e7a3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Deeply_Nested_Chain.yaml @@ -0,0 +1,148 @@ +title: Provides_Deeply_Nested_Chain +request: + document: | + { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } +response: + body: | + { + "data": { + "root": { + "level1": { + "level2": { + "level3": { + "value": "Level3: TGV2ZWwzOjQ=" + } + } + } + } + } + } +sourceSchemas: + - name: a + schema: | + schema { + query: Query + } + + type Level1 @key(fields: "id") { + id: ID! + level2: Level2 @external + } + + type Level2 @key(fields: "id") { + id: ID! + level3: Level3 @external + } + + type Level3 @key(fields: "id") { + id: ID! + value: String @external + } + + type Query { + root: Root + } + + type Root { + level1: Level1 @provides(fields: "level2 { level3 { value } }") + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_2f5727f4_1 { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } + response: + results: + - | + { + "data": { + "root": { + "level1": { + "level2": { + "level3": { + "value": "Level3: TGV2ZWwzOjQ=" + } + } + } + } + } + } + - name: b + schema: | + schema { + query: Query + } + + type Level1 @key(fields: "id") { + id: ID! + level2: Level2 + } + + type Level2 @key(fields: "id") { + id: ID! + level3: Level3 + } + + type Level3 @key(fields: "id") { + id: ID! + value: String + } + + type Query { + level1ById(id: ID! @is(field: "id")): Level1 @lookup @internal + level2ById(id: ID! @is(field: "id")): Level2 @lookup @internal + level3ById(id: ID! @is(field: "id")): Level3 @lookup @internal + } +operationPlan: + operation: + - document: | + { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } + hash: 2f5727f414fe7f10e7369d17f2b3c158 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_2f5727f4_1 { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_External_Without_Cover.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_External_Without_Cover.yaml new file mode 100644 index 00000000000..19d3cef80a7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_External_Without_Cover.yaml @@ -0,0 +1,195 @@ +title: Provides_External_Without_Cover +request: + document: | + { + reviews { + author { + email + } + } + } +response: + body: | + { + "data": { + "reviews": [ + { + "author": { + "email": "User: VXNlcjo0" + } + }, + { + "author": { + "email": "User: VXNlcjo1" + } + }, + { + "author": { + "email": "User: VXNlcjo2" + } + } + ] + } + } +sourceSchemas: + - name: reviews + schema: | + schema { + query: Query + } + + type Query { + reviews: [Review] + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_20abc898_1 { + reviews { + author { + id + } + } + } + response: + results: + - | + { + "data": { + "reviews": [ + { + "author": { + "id": "VXNlcjo0" + } + }, + { + "author": { + "id": "VXNlcjo1" + } + }, + { + "author": { + "id": "VXNlcjo2" + } + } + ] + } + } + - name: users + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + email: String + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_20abc898_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + variables: | + [ + { + "__fusion_1_id": "VXNlcjo0" + }, + { + "__fusion_1_id": "VXNlcjo1" + }, + { + "__fusion_1_id": "VXNlcjo2" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "userById": { + "email": "User: VXNlcjo0" + } + } + } + - | + { + "data": { + "userById": { + "email": "User: VXNlcjo1" + } + } + } + - | + { + "data": { + "userById": { + "email": "User: VXNlcjo2" + } + } + } +operationPlan: + operation: + - document: | + { + reviews { + author { + email + id @fusion__requirement + } + } + } + hash: 20abc89872b18b088b01f58b845ed604 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_20abc898_1 { + reviews { + author { + id + } + } + } + - id: 2 + type: Operation + schema: users + operation: | + query Op_20abc898_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.reviews.author + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_On_Interface.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_On_Interface.yaml new file mode 100644 index 00000000000..4f0df0bee64 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_On_Interface.yaml @@ -0,0 +1,146 @@ +title: Provides_On_Interface +request: + document: | + { + book { + featured { + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } +response: + body: | + { + "data": { + "book": { + "featured": { + "age": 123 + } + } + } + } +sourceSchemas: + - name: a + schema: | + schema { + query: Query + } + + interface Animal { + id: ID! + } + + type Book @key(fields: "id") { + id: ID! + featured: Animal @provides(fields: "... on Cat { age } ... on Dog { tricks }") + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + age: Int @external + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + tricks: [String] @external + } + + type Query { + book: Book + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_8b7ea24b_1 { + book { + featured { + __typename + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } + response: + results: + - | + { + "data": { + "book": { + "featured": { + "__typename": "Cat", + "age": 123 + } + } + } + } + - name: b + schema: | + schema { + query: Query + } + + interface Animal { + id: ID! + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + age: Int + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + tricks: [String] + } + + type Query { + catById(id: ID! @is(field: "id")): Cat @lookup @internal + dogById(id: ID! @is(field: "id")): Dog @lookup @internal + } +operationPlan: + operation: + - document: | + { + book { + featured { + __typename @fusion__requirement + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } + hash: 8b7ea24bc6ecb9e6ae749a4304e01e70 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_8b7ea24b_1 { + book { + featured { + __typename + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Partial_Covers_Some.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Partial_Covers_Some.yaml new file mode 100644 index 00000000000..74a763563f0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Partial_Covers_Some.yaml @@ -0,0 +1,205 @@ +title: Provides_Partial_Covers_Some +request: + document: | + { + reviews { + author { + username + email + } + } + } +response: + body: | + { + "data": { + "reviews": [ + { + "author": { + "username": "User: VXNlcjo0", + "email": "User: VXNlcjo0" + } + }, + { + "author": { + "username": "User: VXNlcjo1", + "email": "User: VXNlcjo1" + } + }, + { + "author": { + "username": "User: VXNlcjo2", + "email": "User: VXNlcjo2" + } + } + ] + } + } +sourceSchemas: + - name: reviews + schema: | + schema { + query: Query + } + + type Query { + reviews: [Review] + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_f51d8b92_1 { + reviews { + author { + username + id + } + } + } + response: + results: + - | + { + "data": { + "reviews": [ + { + "author": { + "username": "User: VXNlcjo0", + "id": "VXNlcjo0" + } + }, + { + "author": { + "username": "User: VXNlcjo1", + "id": "VXNlcjo1" + } + }, + { + "author": { + "username": "User: VXNlcjo2", + "id": "VXNlcjo2" + } + } + ] + } + } + - name: users + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + email: String + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_f51d8b92_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + variables: | + [ + { + "__fusion_1_id": "VXNlcjo0" + }, + { + "__fusion_1_id": "VXNlcjo1" + }, + { + "__fusion_1_id": "VXNlcjo2" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "userById": { + "email": "User: VXNlcjo0" + } + } + } + - | + { + "data": { + "userById": { + "email": "User: VXNlcjo1" + } + } + } + - | + { + "data": { + "userById": { + "email": "User: VXNlcjo2" + } + } + } +operationPlan: + operation: + - document: | + { + reviews { + author { + username + email + id @fusion__requirement + } + } + } + hash: f51d8b925e7743130a41832f8fb9c5c1 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_f51d8b92_1 { + reviews { + author { + username + id + } + } + } + - id: 2 + type: Operation + schema: users + operation: | + query Op_f51d8b92_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.reviews.author + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Simple_Covers_Selection.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Simple_Covers_Selection.yaml new file mode 100644 index 00000000000..0bc5196f35a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/ProvidesTests.Provides_Simple_Covers_Selection.yaml @@ -0,0 +1,128 @@ +title: Provides_Simple_Covers_Selection +request: + document: | + { + reviews { + author { + username + } + } + } +response: + body: | + { + "data": { + "reviews": [ + { + "author": { + "username": "User: VXNlcjo0" + } + }, + { + "author": { + "username": "User: VXNlcjo1" + } + }, + { + "author": { + "username": "User: VXNlcjo2" + } + } + ] + } + } +sourceSchemas: + - name: reviews + schema: | + schema { + query: Query + } + + type Query { + reviews: [Review] + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_6ad87143_1 { + reviews { + author { + username + } + } + } + response: + results: + - | + { + "data": { + "reviews": [ + { + "author": { + "username": "User: VXNlcjo0" + } + }, + { + "author": { + "username": "User: VXNlcjo1" + } + }, + { + "author": { + "username": "User: VXNlcjo2" + } + } + ] + } + } + - name: users + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + } +operationPlan: + operation: + - document: | + { + reviews { + author { + username + } + } + } + hash: 6ad87143bdde6c7ca60b9c9755cba322 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_6ad87143_1 { + reviews { + author { + username + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/ProvidesPlannerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/ProvidesPlannerTests.cs new file mode 100644 index 00000000000..cad56854158 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/ProvidesPlannerTests.cs @@ -0,0 +1,516 @@ +using HotChocolate.Fusion.Types; + +namespace HotChocolate.Fusion.Planning; + +public class ProvidesPlannerTests : FusionTestBase +{ + [Fact] + public void Provides_Partial_Covers_Some() + { + // arrange + var schema = CreatePartialProvidesSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + products { + reviews { + author { + username + email + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Provides_On_Interface() + { + // arrange + var schema = CreateProvidesOnInterfaceSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + book { + featured { + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Provides_External_Without_Cover() + { + // arrange + var schema = CreatePartialProvidesSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + products { + reviews { + author { + email + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Provides_Deeply_Nested_Chain() + { + // arrange + var schema = CreateDeeplyNestedProvidesSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Provides_With_Requires_Interaction() + { + // arrange + var schema = CreateProvidesWithRequiresSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + orders { + item { + shippingCost + } + } + } + """); + + // assert + // op4 fetches 'weight' from 'orders' via the @provides inlining path, not despite @external. + MatchSnapshot(plan); + } + + [Fact] + public void Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External() + { + // arrange + var schema = CreateOrphanExternalSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + reviews { + product { + name + } + } + } + """); + + // assert + // The query enters the 'reviews' source (which owns Query.reviews). 'reviews' + // also declares Product.name, but as @external with no @provides on the query + // path referencing it. The partitioner must therefore refuse to resolve 'name' + // from 'reviews' and route it to 'products' via a productById lookup. + MatchSnapshot(plan); + } + + [Fact] + public void Provides_Shareable_Override() + { + // arrange + var schema = CreateProvidesShareableOverrideSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + products { + reviews { + author { + displayName + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + private static FusionSchemaDefinition CreatePartialProvidesSchema() + { + return ComposeSchema( + """ + # name: products + schema { + query: Query + } + + type Query { + products: [Product] + } + + type Product @key(fields: "upc") { + upc: String! + name: String + } + """, + """ + # name: reviews + schema { + query: Query + } + + type Query { + productByUpc(upc: String! @is(field: "upc")): Product @lookup @internal + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type Product @key(fields: "upc") { + upc: String! + reviews: [Review] + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "username") + } + + type User @key(fields: "id") { + id: ID! + username: String @external + } + """, + """ + # name: users + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + username: String + email: String + name: String + } + """); + } + + private static FusionSchemaDefinition CreateProvidesOnInterfaceSchema() + { + return ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + book: Book + } + + type Book @key(fields: "id") { + id: ID! + featured: Animal @provides(fields: "... on Cat { age } ... on Dog { tricks }") + } + + interface Animal { + id: ID! + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + age: Int @external + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + tricks: [String] @external + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + catById(id: ID! @is(field: "id")): Cat @lookup @internal + dogById(id: ID! @is(field: "id")): Dog @lookup @internal + } + + interface Animal { + id: ID! + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + age: Int + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + tricks: [String] + } + """); + } + + private static FusionSchemaDefinition CreateDeeplyNestedProvidesSchema() + { + return ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + root: Root + } + + type Root { + level1: Level1 @provides(fields: "level2 { level3 { value } }") + } + + type Level1 @key(fields: "id") { + id: ID! + level2: Level2 @external + } + + type Level2 @key(fields: "id") { + id: ID! + level3: Level3 @external + } + + type Level3 @key(fields: "id") { + id: ID! + value: String @external + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + level1ById(id: ID! @is(field: "id")): Level1 @lookup @internal + level2ById(id: ID! @is(field: "id")): Level2 @lookup @internal + level3ById(id: ID! @is(field: "id")): Level3 @lookup @internal + } + + type Level1 @key(fields: "id") { + id: ID! + level2: Level2 + } + + type Level2 @key(fields: "id") { + id: ID! + level3: Level3 + } + + type Level3 @key(fields: "id") { + id: ID! + value: String + } + """); + } + + private static FusionSchemaDefinition CreateProvidesWithRequiresSchema() + { + return ComposeSchema( + """ + # name: orders + schema { + query: Query + } + + type Query { + orders: [Order] + } + + type Order @key(fields: "id") { + id: ID! + item: Item @provides(fields: "weight") + } + + type Item @key(fields: "sku") { + sku: String! + weight: Int @external + } + """, + """ + # name: shipping + schema { + query: Query + } + + type Query { + itemBySku(sku: String! @is(field: "sku")): Item @lookup @internal + } + + type Item @key(fields: "sku") { + sku: String! + weight: Int + shippingCost(weight: Int @require(field: "weight")): Float + } + """); + } + + private static FusionSchemaDefinition CreateOrphanExternalSchema() + { + return ComposeSchema( + """ + # name: reviews + schema { + query: Query + } + + type Query { + reviews: [Review] + # off-path @provides exists only to satisfy the ExternalUnusedRule; + # it is never exercised by the query under test. + productByName(name: String!): Product @provides(fields: "name") + } + + type Review @key(fields: "id") { + id: ID! + body: String + product: Product + } + + type Product @key(fields: "id") { + id: ID! + name: String @external + } + """, + """ + # name: products + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + name: String + } + """); + } + + private static FusionSchemaDefinition CreateProvidesShareableOverrideSchema() + { + return ComposeSchema( + """ + # name: products + schema { + query: Query + } + + type Query { + products: [Product] + } + + type Product @key(fields: "upc") { + upc: String! + reviews: [Review] + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "displayName") + } + + type User @key(fields: "id") { + id: ID! + displayName: String @external + } + """, + """ + # name: users + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + displayName: String @shareable + } + """, + """ + # name: accounts + schema { + query: Query + } + + type Query { + userById(id: ID! @is(field: "id")): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + displayName: String @shareable + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External.yaml new file mode 100644 index 00000000000..920749323bf --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orphan_External.yaml @@ -0,0 +1,42 @@ +operation: + - document: | + { + reviews { + product { + name + id @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_1 { + reviews { + product { + id + } + } + } + - id: 2 + type: Operation + schema: products + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + name + } + } + source: $.productById + target: $.reviews.product + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Deeply_Nested_Chain.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Deeply_Nested_Chain.yaml new file mode 100644 index 00000000000..bc8c8ab5bf0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Deeply_Nested_Chain.yaml @@ -0,0 +1,32 @@ +operation: + - document: | + { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + root { + level1 { + level2 { + level3 { + value + } + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_External_Without_Cover.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_External_Without_Cover.yaml new file mode 100644 index 00000000000..5b855cab48c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_External_Without_Cover.yaml @@ -0,0 +1,64 @@ +operation: + - document: | + { + products { + reviews { + author { + email + id @fusion__requirement + } + } + upc @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 3 +nodes: + - id: 1 + type: Operation + schema: products + operation: | + query Op_123456789101112_1 { + products { + upc + } + } + - id: 2 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_2($__fusion_1_upc: String!) { + productByUpc(upc: $__fusion_1_upc) { + reviews { + author { + id + } + } + } + } + source: $.productByUpc + target: $.products + requirements: + - name: __fusion_1_upc + selectionMap: >- + upc + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: users + operation: | + query Op_123456789101112_3($__fusion_2_id: ID!) { + userById(id: $__fusion_2_id) { + email + } + } + source: $.userById + target: $.products.reviews.author + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_On_Interface.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_On_Interface.yaml new file mode 100644 index 00000000000..e1af226ff05 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_On_Interface.yaml @@ -0,0 +1,36 @@ +operation: + - document: | + { + book { + featured { + __typename @fusion__requirement + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + book { + featured { + __typename + ... on Cat { + age + } + ... on Dog { + tricks + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Partial_Covers_Some.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Partial_Covers_Some.yaml new file mode 100644 index 00000000000..be78d78a0d1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Partial_Covers_Some.yaml @@ -0,0 +1,66 @@ +operation: + - document: | + { + products { + reviews { + author { + username + email + id @fusion__requirement + } + } + upc @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 3 +nodes: + - id: 1 + type: Operation + schema: products + operation: | + query Op_123456789101112_1 { + products { + upc + } + } + - id: 2 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_2($__fusion_1_upc: String!) { + productByUpc(upc: $__fusion_1_upc) { + reviews { + author { + username + id + } + } + } + } + source: $.productByUpc + target: $.products + requirements: + - name: __fusion_1_upc + selectionMap: >- + upc + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: users + operation: | + query Op_123456789101112_3($__fusion_2_id: ID!) { + userById(id: $__fusion_2_id) { + email + } + } + source: $.userById + target: $.products.reviews.author + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Shareable_Override.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Shareable_Override.yaml new file mode 100644 index 00000000000..c253784435a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_Shareable_Override.yaml @@ -0,0 +1,28 @@ +operation: + - document: | + { + products { + reviews { + author { + displayName + } + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: products + operation: | + query Op_123456789101112_1 { + products { + reviews { + author { + displayName + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_With_Requires_Interaction.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_With_Requires_Interaction.yaml new file mode 100644 index 00000000000..33055ae1cb1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/ProvidesPlannerTests.Provides_With_Requires_Interaction.yaml @@ -0,0 +1,59 @@ +operation: + - document: | + { + orders { + item { + shippingCost + sku @fusion__requirement + weight @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 6 +nodes: + - id: 1 + type: Operation + schema: orders + operation: | + query Op_123456789101112_1 { + orders { + item { + sku + } + } + } + batchingGroupId: 1 + - id: 4 + type: Operation + schema: orders + operation: | + query Op_123456789101112_4 { + orders { + item { + weight + } + } + } + batchingGroupId: 1 + - id: 3 + type: Operation + schema: shipping + operation: | + query Op_123456789101112_3($__fusion_2_weight: Int, $__fusion_3_sku: String!) { + itemBySku(sku: $__fusion_3_sku) { + shippingCost(weight: $__fusion_2_weight) + } + } + source: $.itemBySku + target: $.orders.item + requirements: + - name: __fusion_2_weight + selectionMap: >- + weight + - name: __fusion_3_sku + selectionMap: >- + sku + dependencies: + - id: 1