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 c790dfa40d1..2db769da056 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -498,7 +498,7 @@ context.SubscriptionType is not null context.Interceptor.OnAfterCompleteSchema(context, schema); schema.Seal(); context.Complete(schema); - schema.InitializePlannerTopologyCache(); + schema.EnsurePlannerTopologyCacheInitialized(); return schema; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FieldResolutionInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FieldResolutionInfo.cs new file mode 100644 index 00000000000..2b7b4623897 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FieldResolutionInfo.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Types; + +internal readonly record struct FieldResolutionInfo( + ImmutableArray Schemas, + ImmutableArray SchemasWithRequirements) +{ + public bool ContainsSchema(string schemaName) + { + foreach (var candidate in Schemas) + { + if (candidate.Equals(schemaName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + public bool HasRequirements(string schemaName) + { + foreach (var candidate in SchemasWithRequirements) + { + if (candidate.Equals(schemaName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs index 9d9b82f3a89..cff3fdf5f1d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs @@ -1,12 +1,12 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using HotChocolate.Features; using HotChocolate.Fusion.Types.Collections; using HotChocolate.Fusion.Types.Completion; using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; -using HotChocolate.Language.Visitors; using HotChocolate.Serialization; using HotChocolate.Types; @@ -21,7 +21,6 @@ public sealed class FusionSchemaDefinition : ISchemaDefinition, IAsyncDisposable #endif private readonly ConcurrentDictionary> _possibleTypes = new(); private readonly ConcurrentDictionary<(string, string?), ImmutableArray> _possibleLookups = new(); - private readonly ConcurrentDictionary _bestDirectLookup = new(); private readonly IServiceProvider _services; private PlannerTopologyCache? _plannerTopologyCache; private ImmutableArray _unionTypes; @@ -225,7 +224,7 @@ static ImmutableArray BuildPossibleTypes( { if (abstractType is FusionUnionTypeDefinition unionType) { - return [.. unionType.Types.AsEnumerable()]; + return [.. unionType.Types.AsEnumerable().OrderBy(t => t.Name, StringComparer.Ordinal)]; } if (abstractType is FusionInterfaceTypeDefinition interfaceType) @@ -240,6 +239,7 @@ static ImmutableArray BuildPossibleTypes( } } + builder.Sort((a, b) => StringComparer.Ordinal.Compare(a.Name, b.Name)); return builder.ToImmutable(); } @@ -283,13 +283,14 @@ internal ImmutableArray GetPossibleLookupsOrdered( { ArgumentNullException.ThrowIfNull(type); - if (_plannerTopologyCache is { } topology - && topology.TryGetOrderedLookups(type.Name, schemaName, out var lookups)) + EnsurePlannerTopologyCacheInitialized(); + + if (_plannerTopologyCache.TryGetOrderedLookups(type.Name, schemaName, out var lookups)) { return lookups; } - return [.. GetPossibleLookups(type, schemaName).OrderBy(CreateLookupOrderingKey, StringComparer.Ordinal)]; + return []; } internal bool TryGetFieldResolution( @@ -300,21 +301,10 @@ internal bool TryGetFieldResolution( ArgumentNullException.ThrowIfNull(type); ArgumentException.ThrowIfNullOrEmpty(fieldName); - if (_plannerTopologyCache is { } topology - && topology.TryGetFieldResolution(type.Name, fieldName, out fieldResolution)) - { - return true; - } + EnsurePlannerTopologyCacheInitialized(); - if (type.Fields.TryGetField(fieldName, allowInaccessibleFields: true, out var field)) + if (_plannerTopologyCache.TryGetFieldResolution(type.Name, fieldName, out fieldResolution)) { - fieldResolution = new FieldResolutionInfo( - field.Sources.Schemas.OrderBy(static s => s, StringComparer.Ordinal).ToImmutableArray(), - field.Sources.Members - .Where(static s => s.Requirements is not null) - .Select(static s => s.SchemaName) - .OrderBy(static s => s, StringComparer.Ordinal) - .ToImmutableArray()); return true; } @@ -328,8 +318,9 @@ internal bool TryGetTypeScatter( { ArgumentNullException.ThrowIfNull(type); - if (_plannerTopologyCache is { } topology - && topology.TryGetTypeScatter(type.Name, out typeScatter)) + EnsurePlannerTopologyCacheInitialized(); + + if (_plannerTopologyCache.TryGetTypeScatter(type.Name, out typeScatter)) { return true; } @@ -454,83 +445,15 @@ public bool TryGetBestDirectLookup( ArgumentException.ThrowIfNullOrEmpty(fromSchema); ArgumentException.ThrowIfNullOrEmpty(toSchema); - if (_plannerTopologyCache is { } topology) - { - if (topology.TryGetDirectTransition(type.Name, fromSchema, toSchema, out lookup)) - { - return true; - } + EnsurePlannerTopologyCacheInitialized(); - if (topology.IsDirectTransitionImpossible(type.Name, fromSchema, toSchema)) - { - lookup = null; - return false; - } - } - - if (!_bestDirectLookup.TryGetValue(new TransitionKey(type.Name, fromSchema, toSchema), out lookup)) + if (_plannerTopologyCache.TryGetDirectTransition(type.Name, fromSchema, toSchema, out lookup)) { - var keyTransitionVisitor = new KeyTransitionVisitor(); - - var context = new KeyTransitionVisitor.Context - { - CompositeSchema = this, - SourceSchema = fromSchema, - Types = [type] - }; - - Lookup? bestLookup = null; - var fields = 0; - var fragments = 0; - - foreach (var possibleLookup in GetPossibleLookups(type, toSchema)) - { - context.Reset(); - keyTransitionVisitor.Visit(possibleLookup.Requirements, context); - - if (context.NeedsTransition) - { - continue; - } - - if (context is { Fields: 1, Fragments: 0 }) - { - bestLookup = possibleLookup; - break; - } - - if (bestLookup is null) - { - bestLookup = possibleLookup; - fields = context.Fields; - fragments = context.Fragments; - continue; - } - - if (context.Fields < fields) - { - bestLookup = possibleLookup; - fields = context.Fields; - fragments = context.Fragments; - } - - if (context.Fields == fields && context.Fragments < fragments) - { - bestLookup = possibleLookup; - fields = context.Fields; - fragments = context.Fragments; - } - } - - if (bestLookup is not null) - { - _bestDirectLookup.TryAdd(new TransitionKey(type.Name, fromSchema, toSchema), bestLookup); - } - - lookup = bestLookup; + return true; } - return lookup is not null; + lookup = null; + return false; } public IEnumerable GetAllDefinitions() @@ -558,9 +481,17 @@ internal void Seal() _unionTypes = [.. Types.AsEnumerable().OfType()]; } - internal void InitializePlannerTopologyCache() + [MemberNotNull(nameof(_plannerTopologyCache))] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void EnsurePlannerTopologyCacheInitialized() { - _plannerTopologyCache ??= PlannerTopologyCache.Build(this); + if (_plannerTopologyCache is null) + { + lock (_lock) + { + _plannerTopologyCache ??= PlannerTopologyCache.Build(this); + } + } } public override string ToString() @@ -589,96 +520,4 @@ public async ValueTask DisposeAsync() _disposed = true; } } - - private readonly record struct TransitionKey(string TypeName, string From, string To); - - private static string CreateLookupOrderingKey(Lookup lookup) - { - var path = lookup.Path.Length == 0 - ? string.Empty - : string.Join('.', lookup.Path); - - return string.Concat( - lookup.SchemaName, - ":", - lookup.FieldName, - ":", - path, - ":", - lookup.Arguments.Length.ToString(), - ":", - lookup.Fields.Length.ToString()); - } -} - -internal sealed class KeyTransitionVisitor : SyntaxWalker -{ - protected override ISyntaxVisitorAction Enter(FieldNode node, Context context) - { - var type = (FusionComplexTypeDefinition)context.Types.Peek(); - var field = type.Fields.GetField(node.Name.Value, allowInaccessibleFields: true); - - if (!field.Sources.TryGetMember(context.SourceSchema, out var member) || member.Requirements is not null) - { - context.NeedsTransition = true; - return Break; - } - - context.Fields++; - context.Types.Push(field.Type.NamedType()); - return base.Enter(node, context); - } - - protected override ISyntaxVisitorAction Leave(FieldNode node, Context context) - { - context.Types.Pop(); - return base.Leave(node, context); - } - - protected override ISyntaxVisitorAction Enter(InlineFragmentNode node, Context context) - { - context.Fragments++; - - if (node.TypeCondition is { Name: { } typeName }) - { - context.Types.Push(context.CompositeSchema.Types[typeName.Value]); - } - - return base.Enter(node, context); - } - - protected override ISyntaxVisitorAction Leave(InlineFragmentNode node, Context context) - { - if (node.TypeCondition is not null) - { - context.Types.Pop(); - } - - return base.Leave(node, context); - } - - public sealed class Context - { - public required FusionSchemaDefinition CompositeSchema { get; init; } - - public required string SourceSchema { get; init; } - - public required List Types { get; init; } - - public bool NeedsTransition { get; set; } - - public int Fields { get; set; } - - public int Fragments { get; set; } - - public void Reset() - { - var first = Types[0]; - Types.Clear(); - NeedsTransition = false; - Fields = 0; - Fragments = 0; - Types.Push(first); - } - } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/KeyTransitionVisitor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/KeyTransitionVisitor.cs new file mode 100644 index 00000000000..d5a7f3fbd05 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/KeyTransitionVisitor.cs @@ -0,0 +1,78 @@ +using System.Collections.Immutable; +using HotChocolate.Language; +using HotChocolate.Language.Visitors; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Types; + +internal sealed class KeyTransitionVisitor : SyntaxWalker +{ + protected override ISyntaxVisitorAction Enter(FieldNode node, Context context) + { + var type = (FusionComplexTypeDefinition)context.Types.Peek(); + var field = type.Fields.GetField(node.Name.Value, allowInaccessibleFields: true); + + if (!field.Sources.TryGetMember(context.SourceSchema, out var member) || member.Requirements is not null) + { + context.NeedsTransition = true; + return Break; + } + + context.Fields++; + context.Types.Push(field.Type.NamedType()); + return base.Enter(node, context); + } + + protected override ISyntaxVisitorAction Leave(FieldNode node, Context context) + { + context.Types.Pop(); + return base.Leave(node, context); + } + + protected override ISyntaxVisitorAction Enter(InlineFragmentNode node, Context context) + { + context.Fragments++; + + if (node.TypeCondition is { Name: { } typeName }) + { + context.Types.Push(context.CompositeSchema.Types[typeName.Value]); + } + + return base.Enter(node, context); + } + + protected override ISyntaxVisitorAction Leave(InlineFragmentNode node, Context context) + { + if (node.TypeCondition is not null) + { + context.Types.Pop(); + } + + return base.Leave(node, context); + } + + public sealed class Context + { + public required FusionSchemaDefinition CompositeSchema { get; init; } + + public required string SourceSchema { get; init; } + + public required List Types { get; init; } + + public bool NeedsTransition { get; set; } + + public int Fields { get; set; } + + public int Fragments { get; set; } + + public void Reset() + { + var first = Types[0]; + Types.Clear(); + NeedsTransition = false; + Fields = 0; + Fragments = 0; + Types.Push(first); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs index f0f18d08979..6c93004384d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs @@ -153,6 +153,7 @@ private static ( { var directTransitions = new Dictionary(); var impossibleDirectTransitions = new HashSet(); + var visitor = new KeyTransitionVisitor(); foreach (var complexType in complexTypes) { @@ -161,10 +162,11 @@ private static ( foreach (var toSchema in schemaNames) { var key = new TransitionKey(complexType.Name, fromSchema, toSchema); + var bestLookup = FindBestDirectLookup(schema, visitor, complexType, fromSchema, toSchema); - if (schema.TryGetBestDirectLookup(complexType, fromSchema, toSchema, out var lookup)) + if (bestLookup is not null) { - directTransitions[key] = lookup; + directTransitions[key] = bestLookup; } else { @@ -177,6 +179,65 @@ private static ( return (directTransitions, impossibleDirectTransitions); } + private static Lookup? FindBestDirectLookup( + FusionSchemaDefinition schema, + KeyTransitionVisitor visitor, + FusionComplexTypeDefinition type, + string fromSchema, + string toSchema) + { + var context = new KeyTransitionVisitor.Context + { + CompositeSchema = schema, + SourceSchema = fromSchema, + Types = [type] + }; + + Lookup? bestLookup = null; + var fields = 0; + var fragments = 0; + + foreach (var possibleLookup in schema.GetPossibleLookups(type, toSchema)) + { + context.Reset(); + visitor.Visit(possibleLookup.Requirements, context); + + if (context.NeedsTransition) + { + continue; + } + + if (context is { Fields: 1, Fragments: 0 }) + { + return possibleLookup; + } + + if (bestLookup is null) + { + bestLookup = possibleLookup; + fields = context.Fields; + fragments = context.Fragments; + continue; + } + + if (context.Fields < fields) + { + bestLookup = possibleLookup; + fields = context.Fields; + fragments = context.Fragments; + } + + if (context.Fields == fields && context.Fragments < fragments) + { + bestLookup = possibleLookup; + fields = context.Fields; + fragments = context.Fragments; + } + } + + return bestLookup; + } + private static Dictionary BuildTypeScatter( IEnumerable complexTypes, IReadOnlyDictionary fieldResolutions) @@ -243,40 +304,3 @@ private static string CreateLookupOrderingKey(Lookup lookup) private readonly record struct TransitionKey(string TypeName, string FromSchema, string ToSchema); } - -internal readonly record struct FieldResolutionInfo( - ImmutableArray Schemas, - ImmutableArray SchemasWithRequirements) -{ - public bool ContainsSchema(string schemaName) - { - foreach (var candidate in Schemas) - { - if (candidate.Equals(schemaName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - public bool HasRequirements(string schemaName) - { - foreach (var candidate in SchemasWithRequirements) - { - if (candidate.Equals(schemaName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } -} - -internal readonly record struct TypeScatterInfo( - int TotalFields, - int SchemaCount, - int MaxCoverage, - double ScatterRatio); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/TypeScatterInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/TypeScatterInfo.cs new file mode 100644 index 00000000000..3c5e1402ff7 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/TypeScatterInfo.cs @@ -0,0 +1,7 @@ +namespace HotChocolate.Fusion.Types; + +internal readonly record struct TypeScatterInfo( + int TotalFields, + int SchemaCount, + int MaxCoverage, + double ScatterRatio); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs index a47925a4d40..02080b4fdd9 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using HotChocolate.Fusion.Language; using HotChocolate.Fusion.Types; +using HotChocolate.Types; namespace HotChocolate.Fusion.Planning; @@ -126,6 +127,14 @@ double EstimateBranchLowerBound(Backlog branchBacklog) continue; } + // if the target schema has no lookup for the abstract type itself, + // try resolving through per-concrete-type lookups instead. + if (type.Kind is TypeKind.Interface or TypeKind.Union + && TryEnqueueConcreteTypeLookupPlanNode(toSchema, resolutionCost)) + { + continue; + } + if (schema.TryGetBestDirectLookup( type, allCandidateSchemas.Remove(toSchema), @@ -191,6 +200,70 @@ planNodeTemplate with } } } + + // When the target schema has no lookup that returns the abstract type directly, + // we try to resolve through per-concrete-type lookups instead. + bool TryEnqueueConcreteTypeLookupPlanNode(string toSchema, double resolutionCost) + { + // if the target schema already has a lookup returning the abstract type, + // let the normal lookup path handle it. + var hasAbstractLookups = schema + .GetPossibleLookupsOrdered(type, toSchema) + .Any(t => t.FieldType.Name.Equals(type.Name, StringComparison.Ordinal)); + + if (hasAbstractLookups) + { + return false; + } + + var branchBacklog = backlog; + var fromSchemas = allCandidateSchemas.Remove(toSchema); + + // for each concrete type that implements the abstract type, + // find a lookup in the target schema. + foreach (var possibleType in schema.GetPossibleTypes(type)) + { + if (!schema.TryGetBestDirectLookup(possibleType, fromSchemas, toSchema, out var concreteLookup)) + { + concreteLookup = schema + .GetPossibleLookupsOrdered(possibleType, toSchema) + .FirstOrDefault( + t => !t.IsInternal + && t.FieldType.Name.Equals(possibleType.Name, StringComparison.Ordinal)); + } + + // If any concrete type lacks a lookup we bail out so the normal path can try instead. + // otherwise, we could end up with silent failures at runtime. + if (concreteLookup is null) + { + return false; + } + + // rewrite the selection set to target the concrete type with a + // fragment path so the executor can match the runtime type. + var selectionSet = new SelectionSet( + workItem.SelectionSet.Id, + workItem.SelectionSet.Node, + possibleType, + workItem.SelectionSet.Path.AppendFragment(possibleType.Name)); + + var lookupWorkItem = workItem with { SelectionSet = selectionSet, Lookup = concreteLookup }; + branchBacklog = branchBacklog.Push(lookupWorkItem); + } + + // all concrete types have lookups, enqueue a single plan node + // that fans out to each concrete type at execution time. + var branchRemainingCost = EstimateBranchLowerBound(branchBacklog); + Enqueue(planNodeTemplate with + { + SchemaName = toSchema, + ResolutionCost = resolutionCost, + Backlog = branchBacklog, + RemainingCost = branchRemainingCost + }); + + return true; + } } private void EnqueueNodeLookupPlanNodes( diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs index dbd6e154d97..d42252d5d05 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs @@ -278,6 +278,650 @@ ... on Author { await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task Interface_Field_Without_Type_Refinements_With_Interface_Lookup() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + votable: Votable + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + votableById(id: ID!): Votable @lookup + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + votable { + id + viewerCanVote + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Interface_Field_Without_Type_Refinements_With_Concrete_Lookups() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + votable: Votable + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + + type Comment implements Votable @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + discussionById(id: ID!): Discussion @lookup + commentById(id: ID!): Comment @lookup + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Comment implements Votable { + id: ID! + viewerCanVote: Boolean! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + votable { + id + viewerCanVote + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + votable: Votable + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + votableById(id: ID!): Votable @lookup @shareable + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + """); + + using var serverC = CreateSourceSchema( + "C", + """ + type Query { + votableById(id: ID!): Votable @lookup @shareable + } + + interface Votable { + id: ID! + totalVotes: Int! + } + + type Discussion implements Votable { + id: ID! + totalVotes: Int! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB), + ("C", serverC) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + votable { + id + totalVotes + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Interface_Field_Without_Type_Refinements_With_Concrete_Lookups_And_Field_From_Specific_Source() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + votable: Votable + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + + type Comment implements Votable @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + discussionById(id: ID!): Discussion @lookup @shareable + commentById(id: ID!): Comment @lookup @shareable + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Comment implements Votable { + id: ID! + viewerCanVote: Boolean! + } + """); + + using var serverC = CreateSourceSchema( + "C", + """ + type Query { + discussionById(id: ID!): Discussion @lookup @shareable + commentById(id: ID!): Comment @lookup @shareable + } + + interface Votable { + id: ID! + totalVotes: Int! + } + + type Discussion implements Votable { + id: ID! + totalVotes: Int! + } + + type Comment implements Votable { + id: ID! + totalVotes: Int! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB), + ("C", serverC) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + votable { + id + totalVotes + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Union_Field_With_Type_Refinements_And_Concrete_Lookups() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + search: SearchResult + } + + union SearchResult = User | Product + + type User @key(fields: "id") { + id: ID! + } + + type Product @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup @shareable + productById(id: ID!): Product @lookup + } + + type User { + id: ID! + reputation: Int! + } + + type Product { + id: ID! + price: Float! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + search { + ... on User { + reputation + } + ... on Product { + price + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Union_Field_With_Type_Refinements_And_Union_Lookup() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + search: SearchResult + } + + union SearchResult = User | Product + + type User @key(fields: "id") { + id: ID! + } + + type Product @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + searchResultById(id: ID!): SearchResult @lookup + } + + union SearchResult = User | Product + + type User { + id: ID! + reputation: Int! + } + + type Product { + id: ID! + price: Float! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + search { + ... on User { + reputation + } + ... on Product { + price + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Union_Field_With_Type_Refinements_And_Concrete_Lookups_With_Additional_Concrete_Dependency() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + search: SearchResult + } + + union SearchResult = User | Product + + type User @key(fields: "id") { + id: ID! + } + + type Product @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup @shareable + productById(id: ID!): Product @lookup + } + + type User { + id: ID! + reputation: Int! + } + + type Product { + id: ID! + price: Float! + } + """); + + using var serverC = CreateSourceSchema( + "C", + """ + type Query { + userById(id: ID!): User @lookup @shareable + } + + type User { + id: ID! + profile: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB), + ("C", serverC) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + search { + ... on User { + reputation + profile + } + ... on Product { + price + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Union_Field_With_Type_Refinements_And_Union_Lookup_With_Additional_Concrete_Dependency() + { + // arrange + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + search: SearchResult + } + + union SearchResult = User | Product + + type User @key(fields: "id") { + id: ID! + } + + type Product @key(fields: "id") { + id: ID! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + searchResultById(id: ID!): SearchResult @lookup + } + + union SearchResult = User | Product + + type User { + id: ID! + reputation: Int! + } + + type Product { + id: ID! + price: Float! + } + """); + + using var serverC = CreateSourceSchema( + "C", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User { + id: ID! + profile: String! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB), + ("C", serverC) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + search { + ... on User { + reputation + profile + } + ... on Product { + price + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + public static class SourceSchema1 { public class Query diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Concrete_Lookups.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Concrete_Lookups.yaml new file mode 100644 index 00000000000..a8244d7da65 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Concrete_Lookups.yaml @@ -0,0 +1,179 @@ +title: Interface_Field_Without_Type_Refinements_With_Concrete_Lookups +request: + document: | + { + votable { + id + viewerCanVote + } + } +response: + body: | + { + "data": { + "votable": { + "id": "RGlzY3Vzc2lvbjox", + "viewerCanVote": true + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Comment implements Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + + type Query { + votable: Votable + } + interactions: + - request: + document: | + query Op_fb4e28df_1 { + votable { + __typename + id + } + } + response: + results: + - | + { + "data": { + "votable": { + "__typename": "Discussion", + "id": "RGlzY3Vzc2lvbjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Comment implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Query { + discussionById(id: ID!): Discussion @lookup + commentById(id: ID!): Comment @lookup + } + interactions: + - request: + document: | + query Op_fb4e28df_2( + $__fusion_1_id: ID! + ) { + discussionById(id: $__fusion_1_id) { + __typename + viewerCanVote + } + } + variables: | + { + "__fusion_1_id": "RGlzY3Vzc2lvbjox" + } + response: + results: + - | + { + "data": { + "discussionById": { + "__typename": "Discussion", + "viewerCanVote": true + } + } + } +operationPlan: + operation: + - document: | + { + votable { + __typename @fusion__requirement + id + id @fusion__requirement + viewerCanVote + } + } + hash: fb4e28df20f489a10c0f71a560445d04 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_fb4e28df_1 { + votable { + __typename + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_fb4e28df_2( + $__fusion_1_id: ID! + ) { + discussionById(id: $__fusion_1_id) { + __typename + viewerCanVote + } + } + source: $.discussionById + target: $.votable + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query Op_fb4e28df_3( + $__fusion_2_id: ID! + ) { + commentById(id: $__fusion_2_id) { + __typename + viewerCanVote + } + } + source: $.commentById + target: $.votable + batchingGroupId: 1 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Concrete_Lookups_And_Field_From_Specific_Source.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Concrete_Lookups_And_Field_From_Specific_Source.yaml new file mode 100644 index 00000000000..3a99a10b171 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Concrete_Lookups_And_Field_From_Specific_Source.yaml @@ -0,0 +1,204 @@ +title: Interface_Field_Without_Type_Refinements_With_Concrete_Lookups_And_Field_From_Specific_Source +request: + document: | + { + votable { + id + totalVotes + } + } +response: + body: | + { + "data": { + "votable": { + "id": "RGlzY3Vzc2lvbjox", + "totalVotes": 123 + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Comment implements Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + + type Query { + votable: Votable + } + interactions: + - request: + document: | + query Op_1bcbe788_1 { + votable { + __typename + id + } + } + response: + results: + - | + { + "data": { + "votable": { + "__typename": "Discussion", + "id": "RGlzY3Vzc2lvbjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Comment implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Query { + discussionById(id: ID!): Discussion @lookup @shareable + commentById(id: ID!): Comment @lookup @shareable + } + - name: C + schema: | + schema { + query: Query + } + + interface Votable { + id: ID! + totalVotes: Int! + } + + type Comment implements Votable { + id: ID! + totalVotes: Int! + } + + type Discussion implements Votable { + id: ID! + totalVotes: Int! + } + + type Query { + discussionById(id: ID!): Discussion @lookup @shareable + commentById(id: ID!): Comment @lookup @shareable + } + interactions: + - request: + document: | + query Op_1bcbe788_2( + $__fusion_1_id: ID! + ) { + discussionById(id: $__fusion_1_id) { + __typename + totalVotes + } + } + variables: | + { + "__fusion_1_id": "RGlzY3Vzc2lvbjox" + } + response: + results: + - | + { + "data": { + "discussionById": { + "__typename": "Discussion", + "totalVotes": 123 + } + } + } +operationPlan: + operation: + - document: | + { + votable { + __typename @fusion__requirement + id + id @fusion__requirement + totalVotes + } + } + hash: 1bcbe788569dbb79a305829bc99385cf + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_1bcbe788_1 { + votable { + __typename + id + } + } + - id: 2 + type: Operation + schema: C + operation: | + query Op_1bcbe788_2( + $__fusion_1_id: ID! + ) { + discussionById(id: $__fusion_1_id) { + __typename + totalVotes + } + } + source: $.discussionById + target: $.votable + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: C + operation: | + query Op_1bcbe788_3( + $__fusion_2_id: ID! + ) { + commentById(id: $__fusion_2_id) { + __typename + totalVotes + } + } + source: $.commentById + target: $.votable + batchingGroupId: 1 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml new file mode 100644 index 00000000000..0893713efba --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml @@ -0,0 +1,147 @@ +title: Interface_Field_Without_Type_Refinements_With_Interface_Lookup +request: + document: | + { + votable { + id + viewerCanVote + } + } +response: + body: | + { + "data": { + "votable": { + "id": "RGlzY3Vzc2lvbjox", + "viewerCanVote": true + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + + type Query { + votable: Votable + } + interactions: + - request: + document: | + query Op_fb4e28df_1 { + votable { + __typename + id + } + } + response: + results: + - | + { + "data": { + "votable": { + "__typename": "Discussion", + "id": "RGlzY3Vzc2lvbjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Query { + votableById(id: ID!): Votable @lookup + } + interactions: + - request: + document: | + query Op_fb4e28df_2( + $__fusion_1_id: ID! + ) { + votableById(id: $__fusion_1_id) { + __typename + viewerCanVote + } + } + variables: | + { + "__fusion_1_id": "RGlzY3Vzc2lvbjox" + } + response: + results: + - | + { + "data": { + "votableById": { + "__typename": "Discussion", + "viewerCanVote": true + } + } + } +operationPlan: + operation: + - document: | + { + votable { + __typename @fusion__requirement + id + id @fusion__requirement + viewerCanVote + } + } + hash: fb4e28df20f489a10c0f71a560445d04 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_fb4e28df_1 { + votable { + __typename + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_fb4e28df_2( + $__fusion_1_id: ID! + ) { + votableById(id: $__fusion_1_id) { + __typename + viewerCanVote + } + } + source: $.votableById + target: $.votable + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml new file mode 100644 index 00000000000..06a4602b6b2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml @@ -0,0 +1,166 @@ +title: Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source +request: + document: | + { + votable { + id + totalVotes + } + } +response: + body: | + { + "data": { + "votable": { + "id": "RGlzY3Vzc2lvbjox", + "totalVotes": 123 + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + interface Votable @key(fields: "id") { + id: ID! + } + + type Discussion implements Votable @key(fields: "id") { + id: ID! + } + + type Query { + votable: Votable + } + interactions: + - request: + document: | + query Op_1bcbe788_1 { + votable { + __typename + id + } + } + response: + results: + - | + { + "data": { + "votable": { + "__typename": "Discussion", + "id": "RGlzY3Vzc2lvbjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + interface Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Discussion implements Votable { + id: ID! + viewerCanVote: Boolean! + } + + type Query { + votableById(id: ID!): Votable @lookup @shareable + } + - name: C + schema: | + schema { + query: Query + } + + interface Votable { + id: ID! + totalVotes: Int! + } + + type Discussion implements Votable { + id: ID! + totalVotes: Int! + } + + type Query { + votableById(id: ID!): Votable @lookup @shareable + } + interactions: + - request: + document: | + query Op_1bcbe788_2( + $__fusion_1_id: ID! + ) { + votableById(id: $__fusion_1_id) { + __typename + totalVotes + } + } + variables: | + { + "__fusion_1_id": "RGlzY3Vzc2lvbjox" + } + response: + results: + - | + { + "data": { + "votableById": { + "__typename": "Discussion", + "totalVotes": 123 + } + } + } +operationPlan: + operation: + - document: | + { + votable { + __typename @fusion__requirement + id + id @fusion__requirement + totalVotes + } + } + hash: 1bcbe788569dbb79a305829bc99385cf + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_1bcbe788_1 { + votable { + __typename + id + } + } + - id: 2 + type: Operation + schema: C + operation: | + query Op_1bcbe788_2( + $__fusion_1_id: ID! + ) { + votableById(id: $__fusion_1_id) { + __typename + totalVotes + } + } + source: $.votableById + target: $.votable + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Concrete_Lookups.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Concrete_Lookups.yaml new file mode 100644 index 00000000000..e41ab8a3ee0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Concrete_Lookups.yaml @@ -0,0 +1,186 @@ +title: Union_Field_With_Type_Refinements_And_Concrete_Lookups +request: + document: | + { + search { + ... on User { + reputation + } + ... on Product { + price + } + } + } +response: + body: | + { + "data": { + "search": { + "reputation": 123 + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Product @key(fields: "id") { + id: ID! + } + + type Query { + search: SearchResult + } + + type User @key(fields: "id") { + id: ID! + } + + union SearchResult = User | Product + interactions: + - request: + document: | + query Op_465e7f4e_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + response: + results: + - | + { + "data": { + "search": { + "__typename": "User", + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Product { + id: ID! + price: Float! + } + + type Query { + userById(id: ID!): User @lookup @shareable + productById(id: ID!): Product @lookup + } + + type User { + id: ID! + reputation: Int! + } + interactions: + - request: + document: | + query Op_465e7f4e_3( + $__fusion_2_id: ID! + ) { + userById(id: $__fusion_2_id) { + reputation + } + } + variables: | + { + "__fusion_2_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "reputation": 123 + } + } + } +operationPlan: + operation: + - document: | + { + search { + __typename @fusion__requirement + ... on User { + reputation + id @fusion__requirement + } + ... on Product { + price + id @fusion__requirement + } + } + } + hash: 465e7f4e2fce633f9dddf88285e5c3b1 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_465e7f4e_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_465e7f4e_2( + $__fusion_1_id: ID! + ) { + productById(id: $__fusion_1_id) { + price + } + } + source: $.productById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query Op_465e7f4e_3( + $__fusion_2_id: ID! + ) { + userById(id: $__fusion_2_id) { + reputation + } + } + source: $.userById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Concrete_Lookups_With_Additional_Concrete_Dependency.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Concrete_Lookups_With_Additional_Concrete_Dependency.yaml new file mode 100644 index 00000000000..a1166f5a98d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Concrete_Lookups_With_Additional_Concrete_Dependency.yaml @@ -0,0 +1,246 @@ +title: Union_Field_With_Type_Refinements_And_Concrete_Lookups_With_Additional_Concrete_Dependency +request: + document: | + { + search { + ... on User { + reputation + profile + } + ... on Product { + price + } + } + } +response: + body: | + { + "data": { + "search": { + "reputation": 123, + "profile": "User: VXNlcjox" + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Product @key(fields: "id") { + id: ID! + } + + type Query { + search: SearchResult + } + + type User @key(fields: "id") { + id: ID! + } + + union SearchResult = User | Product + interactions: + - request: + document: | + query Op_e8751f64_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + response: + results: + - | + { + "data": { + "search": { + "__typename": "User", + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Product { + id: ID! + price: Float! + } + + type Query { + userById(id: ID!): User @lookup @shareable + productById(id: ID!): Product @lookup + } + + type User { + id: ID! + reputation: Int! + } + interactions: + - request: + document: | + query Op_e8751f64_3( + $__fusion_2_id: ID! + ) { + userById(id: $__fusion_2_id) { + reputation + } + } + variables: | + { + "__fusion_2_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "reputation": 123 + } + } + } + - name: C + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @shareable + } + + type User { + id: ID! + profile: String! + } + interactions: + - request: + document: | + query Op_e8751f64_4( + $__fusion_3_id: ID! + ) { + userById(id: $__fusion_3_id) { + profile + } + } + variables: | + { + "__fusion_3_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "profile": "User: VXNlcjox" + } + } + } +operationPlan: + operation: + - document: | + { + search { + __typename @fusion__requirement + ... on User { + reputation + profile + id @fusion__requirement + } + ... on Product { + price + id @fusion__requirement + } + } + } + hash: e8751f643c686c83b20ce5da12fee405 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_e8751f64_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_e8751f64_2( + $__fusion_1_id: ID! + ) { + productById(id: $__fusion_1_id) { + price + } + } + source: $.productById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query Op_e8751f64_3( + $__fusion_2_id: ID! + ) { + userById(id: $__fusion_2_id) { + reputation + } + } + source: $.userById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 4 + type: Operation + schema: C + operation: | + query Op_e8751f64_4( + $__fusion_3_id: ID! + ) { + userById(id: $__fusion_3_id) { + profile + } + } + source: $.userById + target: $.search + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Union_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Union_Lookup.yaml new file mode 100644 index 00000000000..518505ae9de --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Union_Lookup.yaml @@ -0,0 +1,197 @@ +title: Union_Field_With_Type_Refinements_And_Union_Lookup +request: + document: | + { + search { + ... on User { + reputation + } + ... on Product { + price + } + } + } +response: + body: | + { + "data": { + "search": { + "reputation": 123 + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Product @key(fields: "id") { + id: ID! + } + + type Query { + search: SearchResult + } + + type User @key(fields: "id") { + id: ID! + } + + union SearchResult = User | Product + interactions: + - request: + document: | + query Op_465e7f4e_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + response: + results: + - | + { + "data": { + "search": { + "__typename": "User", + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Product { + id: ID! + price: Float! + } + + type Query { + searchResultById(id: ID!): SearchResult @lookup + } + + type User { + id: ID! + reputation: Int! + } + + union SearchResult = User | Product + interactions: + - request: + document: | + query Op_465e7f4e_3( + $__fusion_2_id: ID! + ) { + searchResultById(id: $__fusion_2_id) { + __typename + ... on User { + reputation + } + } + } + variables: | + { + "__fusion_2_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "searchResultById": { + "__typename": "User", + "reputation": 123 + } + } + } +operationPlan: + operation: + - document: | + { + search { + __typename @fusion__requirement + ... on User { + reputation + id @fusion__requirement + } + ... on Product { + price + id @fusion__requirement + } + } + } + hash: 465e7f4e2fce633f9dddf88285e5c3b1 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_465e7f4e_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_465e7f4e_2( + $__fusion_1_id: ID! + ) { + searchResultById(id: $__fusion_1_id) { + __typename + ... on Product { + price + } + } + } + source: $.searchResultById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query Op_465e7f4e_3( + $__fusion_2_id: ID! + ) { + searchResultById(id: $__fusion_2_id) { + __typename + ... on User { + reputation + } + } + } + source: $.searchResultById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Union_Lookup_With_Additional_Concrete_Dependency.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Union_Lookup_With_Additional_Concrete_Dependency.yaml new file mode 100644 index 00000000000..869b14d6b16 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Union_Field_With_Type_Refinements_And_Union_Lookup_With_Additional_Concrete_Dependency.yaml @@ -0,0 +1,257 @@ +title: Union_Field_With_Type_Refinements_And_Union_Lookup_With_Additional_Concrete_Dependency +request: + document: | + { + search { + ... on User { + reputation + profile + } + ... on Product { + price + } + } + } +response: + body: | + { + "data": { + "search": { + "reputation": 123, + "profile": "User: VXNlcjox" + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Product @key(fields: "id") { + id: ID! + } + + type Query { + search: SearchResult + } + + type User @key(fields: "id") { + id: ID! + } + + union SearchResult = User | Product + interactions: + - request: + document: | + query Op_e8751f64_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + response: + results: + - | + { + "data": { + "search": { + "__typename": "User", + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Product { + id: ID! + price: Float! + } + + type Query { + searchResultById(id: ID!): SearchResult @lookup + } + + type User { + id: ID! + reputation: Int! + } + + union SearchResult = User | Product + interactions: + - request: + document: | + query Op_e8751f64_3( + $__fusion_2_id: ID! + ) { + searchResultById(id: $__fusion_2_id) { + __typename + ... on User { + reputation + } + } + } + variables: | + { + "__fusion_2_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "searchResultById": { + "__typename": "User", + "reputation": 123 + } + } + } + - name: C + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type User { + id: ID! + profile: String! + } + interactions: + - request: + document: | + query Op_e8751f64_4( + $__fusion_3_id: ID! + ) { + userById(id: $__fusion_3_id) { + profile + } + } + variables: | + { + "__fusion_3_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "profile": "User: VXNlcjox" + } + } + } +operationPlan: + operation: + - document: | + { + search { + __typename @fusion__requirement + ... on User { + reputation + profile + id @fusion__requirement + } + ... on Product { + price + id @fusion__requirement + } + } + } + hash: e8751f643c686c83b20ce5da12fee405 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_e8751f64_1 { + search { + __typename + ... on User { + id + } + ... on Product { + id + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_e8751f64_2( + $__fusion_1_id: ID! + ) { + searchResultById(id: $__fusion_1_id) { + __typename + ... on Product { + price + } + } + } + source: $.searchResultById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query Op_e8751f64_3( + $__fusion_2_id: ID! + ) { + searchResultById(id: $__fusion_2_id) { + __typename + ... on User { + reputation + } + } + } + source: $.searchResultById + target: $.search + batchingGroupId: 1 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 4 + type: Operation + schema: C + operation: | + query Op_e8751f64_4( + $__fusion_3_id: ID! + ) { + userById(id: $__fusion_3_id) { + profile + } + } + source: $.userById + target: $.search + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerTopologyCacheTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerTopologyCacheTests.cs deleted file mode 100644 index 5f39764cc2e..00000000000 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerTopologyCacheTests.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Immutable; -using System.Reflection; -using HotChocolate.Fusion.Execution.Nodes.Serialization; -using HotChocolate.Fusion.Types; -using HotChocolate.Fusion.Types.Metadata; - -namespace HotChocolate.Fusion.Planning; - -public class OperationPlannerTopologyCacheTests : FusionTestBase -{ - [Fact] - public void TransitionLookup_Fallback_Without_TopologyCache_Is_Equivalent() - { - var schema = ComposeSchema( - """ - schema { - query: Query - } - - type Query { - topProducts: [Product!] - } - - type Product @key(fields: "id") { - id: ID! - name: String! - region: String! - } - """, - """ - schema { - query: Query - } - - type Query { - productById(id: ID!): Product @lookup @internal - } - - type Product { - id: ID! - price(region: String! @require(field: "region")): Float! - } - """); - - var productType = (FusionComplexTypeDefinition)schema.Types["Product"]; - var fromA = ImmutableHashSet.Create("a"); - var fromB = ImmutableHashSet.Create("b"); - - var directWithCache = TryGetLookupSignature(schema, productType, fromA, "b"); - var impossibleWithCache = TryGetLookupSignature(schema, productType, fromB, "a"); - - DisableTopologyCache(schema); - - var directWithoutCache = TryGetLookupSignature(schema, productType, fromA, "b"); - var impossibleWithoutCache = TryGetLookupSignature(schema, productType, fromB, "a"); - - Assert.Equal(directWithCache, directWithoutCache); - Assert.Equal(impossibleWithCache, impossibleWithoutCache); - } - - [Fact] - public void PlannerPlan_Fallback_Without_TopologyCache_Is_Equivalent() - { - var schema = ComposeSchema( - """ - schema { - query: Query - } - - type Query { - topProducts: [Product!] - } - - type Product @key(fields: "id") { - id: ID! - name: String! - region: String! - } - """, - """ - schema { - query: Query - } - - type Query { - productById(id: ID!): Product @lookup @internal - } - - type Product { - id: ID! - price(region: String! @require(field: "region")): Float! - } - """); - - const string operation = - """ - { - topProducts { - id - name - price - } - } - """; - - var formatter = new YamlOperationPlanFormatter(); - var withCache = formatter.Format(PlanOperation(schema, operation)); - - DisableTopologyCache(schema); - - var withoutCache = formatter.Format(PlanOperation(schema, operation)); - - Assert.Equal(withCache, withoutCache); - } - - [Fact] - public void PlannerPlan_UnionOverfetching_Fallback_Without_TopologyCache_Is_Equivalent() - { - var schema = ComposeSchema( - """ - schema { - query: Query - } - - type Query { - review: Review - } - - union Review = AnonymousReview | UserReview - - type UserReview { - product: Product - } - - type AnonymousReview { - product: Product - } - - type Product @key(fields: "id") { - id: ID! - } - """, - """ - schema { - query: Query - } - - type Query { - productById(id: ID! @is(field: "id")): Product @lookup @internal - } - - type Product @key(fields: "id") { - id: ID! - b: String! - } - """, - """ - schema { - query: Query - } - - type Query { - productById(id: ID! @is(field: "id")): Product @lookup @internal - } - - type Product @key(fields: "id") { - id: ID! - c: String! - } - """, - """ - schema { - query: Query - } - - type Query { - productById(id: ID! @is(field: "id")): Product @lookup @internal - } - - type Product @key(fields: "id") { - id: ID! - d: String! - } - """); - - const string operation = - """ - { - review { - ... on AnonymousReview { - product { - b - } - } - ... on UserReview { - product { - c - d - } - } - } - } - """; - - var formatter = new YamlOperationPlanFormatter(); - var withCache = formatter.Format(PlanOperation(schema, operation)); - - DisableTopologyCache(schema); - - var withoutCache = formatter.Format(PlanOperation(schema, operation)); - - Assert.Equal(withCache, withoutCache); - } - - private static (bool Found, string? Signature) TryGetLookupSignature( - FusionSchemaDefinition schema, - FusionComplexTypeDefinition type, - ImmutableHashSet fromSchemas, - string toSchema) - { - if (schema.TryGetBestDirectLookup(type, fromSchemas, toSchema, out var lookup)) - { - return (true, CreateLookupSignature(lookup)); - } - - return (false, null); - } - - private static void DisableTopologyCache(FusionSchemaDefinition schema) - { - var cacheField = typeof(FusionSchemaDefinition) - .GetField("_plannerTopologyCache", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("Could not find topology cache field."); - cacheField.SetValue(schema, null); - - var directLookupField = typeof(FusionSchemaDefinition) - .GetField("_bestDirectLookup", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("Could not find direct lookup cache field."); - var clear = directLookupField.FieldType.GetMethod(nameof(ConcurrentDictionary.Clear)) - ?? throw new InvalidOperationException("Could not clear direct lookup cache field."); - clear.Invoke(directLookupField.GetValue(schema), []); - } - - private static string CreateLookupSignature(Lookup lookup) - { - var path = lookup.Path.Length == 0 - ? string.Empty - : string.Join('.', lookup.Path); - - return string.Concat( - lookup.SchemaName, - ":", - lookup.FieldName, - ":", - path, - ":", - lookup.Arguments.Length.ToString(), - ":", - lookup.Fields.Length.ToString()); - } -}