diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs
index 02080b4fdd9..b71f97a265f 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs
@@ -2,6 +2,7 @@
using HotChocolate.Fusion.Language;
using HotChocolate.Fusion.Types;
using HotChocolate.Types;
+using Lookup = HotChocolate.Fusion.Types.Metadata.Lookup;
namespace HotChocolate.Fusion.Planning;
@@ -36,6 +37,12 @@ public bool TryPeek(out PlanNode node, out double priority)
///
public void Clear() => _queue.Clear();
+ ///
+ /// Enqueues and scores a single plan node to this queue.
+ ///
+ public void Enqueue(PlanNode node)
+ => _queue.Enqueue(node, PlannerCostEstimator.ScoreNode(node, schema));
+
///
/// Expands a plan node's next work item into all possible branches
/// (one per candidate schema or lookup) and enqueues each branch.
@@ -80,24 +87,17 @@ public void EnqueueBranches(PlanNode planNodeTemplate)
}
}
- ///
- /// Enqueues and scores a single plan node to this queue.
- ///
- public void Enqueue(PlanNode node)
- => _queue.Enqueue(node, PlannerCostEstimator.ScoreNode(node, schema));
-
private void EnqueueRootPlanNodes(
PlanNode planNodeTemplate,
OperationWorkItem workItem)
{
foreach (var (schemaName, resolutionCost) in schema.GetPossibleSchemas(workItem.SelectionSet))
{
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = schemaName,
- ResolutionCost = resolutionCost
- });
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = schemaName,
+ ResolutionCost = resolutionCost
+ });
}
}
@@ -109,28 +109,36 @@ private void EnqueueLookupPlanNodes(
var allCandidateSchemas = planNodeTemplate.GetCandidateSchemas(workItem.SelectionSet.Id);
var type = (FusionComplexTypeDefinition)workItem.SelectionSet.Type;
- double EstimateBranchLowerBound(Backlog branchBacklog)
- => PlannerCostEstimator.EstimateRemainingCost(
- planNodeTemplate.Options,
- planNodeTemplate.MaxDepth,
- planNodeTemplate.OpsPerLevel,
- branchBacklog.Cost);
-
- // Each branch starts from the same popped template and
- // mutates a local copy of backlog state.
- // This avoids recomputing backlog shape
- // from collections for every candidate.
- foreach (var (toSchema, resolutionCost) in schema.GetPossibleSchemas(workItem.SelectionSet))
+ // If this work item already carries a chosen lookup, keep it and
+ // only align the node schema to the lookup schema.
+ if (workItem.Lookup is not null)
{
- if (toSchema.Equals(workItem.FromSchema, StringComparison.Ordinal))
+ var branchBacklog = backlog.Push(workItem);
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+
+ Enqueue(planNodeTemplate with
{
- continue;
- }
+ SchemaName = workItem.Lookup.SchemaName,
+ ResolutionCost = GetResolutionCost(workItem.SelectionSet, workItem.Lookup.SchemaName),
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
+ return;
+ }
+
+ // For abstract types, try to resolve through per-concrete-type lookups
+ // before falling through to the standard abstract-type lookup path.
+ if (type.Kind is TypeKind.Interface or TypeKind.Union)
+ {
+ TryEnqueueConcreteTypeLookupPlanNodes(planNodeTemplate, workItem, backlog, allCandidateSchemas, type);
+ }
- // 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))
+ // Each branch starts from the same popped template and mutates a local copy
+ // of backlog state. This avoids recomputing backlog shape from collections
+ // for every candidate.
+ foreach (var (toSchema, resolutionCost) in schema.GetPossibleSchemas(workItem.SelectionSet))
+ {
+ if (toSchema.Equals(workItem.FromSchema, StringComparison.Ordinal))
{
continue;
}
@@ -143,15 +151,14 @@ double EstimateBranchLowerBound(Backlog branchBacklog)
{
var lookupWorkItem = workItem with { Lookup = bestLookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = toSchema,
- ResolutionCost = resolutionCost,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = toSchema,
+ ResolutionCost = resolutionCost,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
continue;
}
@@ -160,15 +167,14 @@ planNodeTemplate with
{
var lookupWorkItem = workItem with { Lookup = lookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = toSchema,
- ResolutionCost = resolutionCost,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = toSchema,
+ ResolutionCost = resolutionCost,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
hasEnqueuedDirectLookup = true;
}
@@ -187,24 +193,43 @@ planNodeTemplate with
StringComparer.Ordinal))
{
var branchBacklog = backlog.Push(lookupThroughPathWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = toSchema,
- SelectionSetIndex = index,
- ResolutionCost = resolutionCost + cost,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = toSchema,
+ SelectionSetIndex = index,
+ ResolutionCost = resolutionCost + cost,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
}
}
}
+ }
+
+ ///
+ /// Resolves an abstract type (interface/union) by finding per-concrete-type lookups.
+ /// First tries to keep all concrete types on a single schema (fewer network hops),
+ /// then falls back to spreading each concrete type to its best available schema.
+ ///
+ private bool TryEnqueueConcreteTypeLookupPlanNodes(
+ PlanNode planNodeTemplate,
+ OperationWorkItem workItem,
+ Backlog backlog,
+ ImmutableHashSet allCandidateSchemas,
+ FusionComplexTypeDefinition type)
+ {
+ var enqueued = false;
- // 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)
+ // Phase 1: we try to find a single schema that can resolve all concrete types as
+ // this would allow us to batch all requests to these into a single GraphQL batch request.
+ foreach (var (toSchema, resolutionCost) in schema.GetPossibleSchemas(workItem.SelectionSet))
{
+ if (toSchema.Equals(workItem.FromSchema, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
// if the target schema already has a lookup returning the abstract type,
// let the normal lookup path handle it.
var hasAbstractLookups = schema
@@ -213,11 +238,12 @@ bool TryEnqueueConcreteTypeLookupPlanNode(string toSchema, double resolutionCost
if (hasAbstractLookups)
{
- return false;
+ continue;
}
var branchBacklog = backlog;
var fromSchemas = allCandidateSchemas.Remove(toSchema);
+ var allFound = true;
// for each concrete type that implements the abstract type,
// find a lookup in the target schema.
@@ -232,11 +258,11 @@ bool TryEnqueueConcreteTypeLookupPlanNode(string toSchema, double resolutionCost
&& 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 any concrete type lacks a lookup we skip this schema.
if (concreteLookup is null)
{
- return false;
+ allFound = false;
+ break;
}
// rewrite the selection set to target the concrete type with a
@@ -251,9 +277,14 @@ bool TryEnqueueConcreteTypeLookupPlanNode(string toSchema, double resolutionCost
branchBacklog = branchBacklog.Push(lookupWorkItem);
}
- // all concrete types have lookups, enqueue a single plan node
+ if (!allFound)
+ {
+ continue;
+ }
+
+ // all concrete types have lookups in this schema, enqueue a single plan node
// that fans out to each concrete type at execution time.
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
Enqueue(planNodeTemplate with
{
SchemaName = toSchema,
@@ -262,8 +293,123 @@ bool TryEnqueueConcreteTypeLookupPlanNode(string toSchema, double resolutionCost
RemainingCost = branchRemainingCost
});
+ enqueued = true;
+ }
+
+ if (enqueued)
+ {
return true;
}
+
+ // Phase 2: if we do not find a single schema that can can resolve all concrete types we
+ // try to distribute each concrete type to the best available schema.
+ var crossBacklog = backlog;
+ string? topSchema = null;
+ double topCost = 0;
+
+ foreach (var possibleType in schema.GetPossibleTypes(type))
+ {
+ // 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));
+
+ Lookup? concreteLookup = null;
+ string? lookupSchema = null;
+ double lookupCost = 0;
+
+ // scan candidate schemas for the best lookup for this concrete type.
+ foreach (var (candidateSchema, candidateCost) in schema.GetPossibleSchemas(selectionSet))
+ {
+ if (candidateSchema.Equals(workItem.FromSchema, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (schema.TryGetBestDirectLookup(
+ possibleType,
+ allCandidateSchemas.Remove(candidateSchema),
+ candidateSchema,
+ out var directLookup))
+ {
+ concreteLookup = directLookup;
+ lookupSchema = candidateSchema;
+ lookupCost = candidateCost;
+ break;
+ }
+
+ var fallbackLookup = schema
+ .GetPossibleLookupsOrdered(possibleType, candidateSchema)
+ .FirstOrDefault(
+ t => !t.IsInternal
+ && t.FieldType.Name.Equals(possibleType.Name, StringComparison.Ordinal));
+
+ if (fallbackLookup is not null)
+ {
+ concreteLookup = fallbackLookup;
+ lookupSchema = candidateSchema;
+ lookupCost = candidateCost;
+ break;
+ }
+ }
+
+ // If any concrete type lacks a lookup we bail out;
+ // otherwise, we could end up with silent failures at runtime.
+ if (concreteLookup is null)
+ {
+ return false;
+ }
+
+ // The backlog is LIFO, so the last pushed item is processed first.
+ // Track its schema so the plan node's SchemaName matches.
+ topSchema = lookupSchema;
+ topCost = lookupCost;
+
+ var lookupWorkItem = workItem with { SelectionSet = selectionSet, Lookup = concreteLookup };
+ crossBacklog = crossBacklog.Push(lookupWorkItem);
+ }
+
+ if (topSchema is null)
+ {
+ return false;
+ }
+
+ // all concrete types have lookups, enqueue a single plan node
+ // that fans out to each concrete type at execution time.
+ var crossRemainingCost = EstimateRemainingCost(planNodeTemplate, crossBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = topSchema,
+ ResolutionCost = topCost,
+ Backlog = crossBacklog,
+ RemainingCost = crossRemainingCost
+ });
+
+ return true;
+ }
+
+ private static double EstimateRemainingCost(PlanNode planNodeTemplate, Backlog branchBacklog)
+ => PlannerCostEstimator.EstimateRemainingCost(
+ planNodeTemplate.Options,
+ planNodeTemplate.MaxDepth,
+ planNodeTemplate.OpsPerLevel,
+ branchBacklog.Cost);
+
+ private double GetResolutionCost(SelectionSet selectionSet, string schemaName)
+ {
+ foreach (var (candidateSchema, candidateCost) in schema.GetPossibleSchemas(selectionSet))
+ {
+ if (candidateSchema.Equals(schemaName, StringComparison.Ordinal))
+ {
+ return candidateCost;
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Schema '{schemaName}' is not a valid candidate for selection set '{selectionSet.Type.Name}'.");
}
private void EnqueueNodeLookupPlanNodes(
@@ -274,13 +420,6 @@ private void EnqueueNodeLookupPlanNodes(
var type = workItem.SelectionSet.Type;
var hasEnqueuedLookup = false;
- double EstimateBranchLowerBound(Backlog branchBacklog)
- => PlannerCostEstimator.EstimateRemainingCost(
- planNodeTemplate.Options,
- planNodeTemplate.MaxDepth,
- planNodeTemplate.OpsPerLevel,
- branchBacklog.Cost);
-
// Same branching rule as lookup work items:
// copy backlog state per branch, then
// materialize a new node with the
@@ -301,15 +440,14 @@ double EstimateBranchLowerBound(Backlog branchBacklog)
var lookupWorkItem = workItem with { Lookup = byIdLookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = schemaName,
- ResolutionCost = resolutionCost,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = schemaName,
+ ResolutionCost = resolutionCost,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
hasEnqueuedLookup = true;
}
@@ -327,14 +465,13 @@ planNodeTemplate with
var lookupWorkItem = workItem with { Lookup = byIdLookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = byIdLookup.SchemaName,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = byIdLookup.SchemaName,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
}
}
@@ -346,13 +483,6 @@ private void EnqueueRequirePlanNodes(
var allCandidateSchemas = planNodeTemplate.GetCandidateSchemas(workItem.Selection.SelectionSetId);
var selectionSetType = workItem.Selection.Field.DeclaringType;
- double EstimateBranchLowerBound(Backlog branchBacklog)
- => PlannerCostEstimator.EstimateRemainingCost(
- planNodeTemplate.Options,
- planNodeTemplate.MaxDepth,
- planNodeTemplate.OpsPerLevel,
- branchBacklog.Cost);
-
// Requirement planning can fork into inline and lookup paths.
// Both are scored from the same popped template by cloning and
// mutating backlog state per candidate.
@@ -368,13 +498,12 @@ double EstimateBranchLowerBound(Backlog branchBacklog)
if (schemaName == planNodeTemplate.SchemaName)
{
var inlineBacklog = backlog.Push(workItem);
- var inlineRemainingCost = EstimateBranchLowerBound(inlineBacklog);
- Enqueue(
- planNodeTemplate with
- {
- Backlog = inlineBacklog,
- RemainingCost = inlineRemainingCost,
- });
+ var inlineRemainingCost = EstimateRemainingCost(planNodeTemplate, inlineBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ Backlog = inlineBacklog,
+ RemainingCost = inlineRemainingCost,
+ });
if (schema.TryGetBestDirectLookup(
selectionSetType,
@@ -384,14 +513,13 @@ planNodeTemplate with
{
var lookupWorkItem = workItem with { Lookup = bestLookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = schemaName,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = schemaName,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
continue;
}
@@ -399,14 +527,13 @@ planNodeTemplate with
{
var lookupWorkItem = workItem with { Lookup = lookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = schemaName,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = schemaName,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
}
}
else
@@ -419,14 +546,13 @@ planNodeTemplate with
{
var lookupWorkItem = workItem with { Lookup = bestLookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = schemaName,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = schemaName,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
continue;
}
@@ -434,14 +560,13 @@ planNodeTemplate with
{
var lookupWorkItem = workItem with { Lookup = lookup };
var branchBacklog = backlog.Push(lookupWorkItem);
- var branchRemainingCost = EstimateBranchLowerBound(branchBacklog);
- Enqueue(
- planNodeTemplate with
- {
- SchemaName = schemaName,
- Backlog = branchBacklog,
- RemainingCost = branchRemainingCost
- });
+ var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog);
+ Enqueue(planNodeTemplate with
+ {
+ SchemaName = schemaName,
+ Backlog = branchBacklog,
+ RemainingCost = branchRemainingCost
+ });
}
}
}
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
index 0893713efba..b8e0cb08705 100644
--- 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
@@ -82,7 +82,10 @@ sourceSchemas:
) {
votableById(id: $__fusion_1_id) {
__typename
- viewerCanVote
+ ... on Discussion {
+ __typename
+ viewerCanVote
+ }
}
}
variables: |
@@ -112,8 +115,8 @@ operationPlan:
}
}
hash: fb4e28df20f489a10c0f71a560445d04
- searchSpace: 1
- expandedNodes: 2
+ searchSpace: 2
+ expandedNodes: 3
nodes:
- id: 1
type: Operation
@@ -134,11 +137,14 @@ operationPlan:
) {
votableById(id: $__fusion_1_id) {
__typename
- viewerCanVote
+ ... on Discussion {
+ __typename
+ viewerCanVote
+ }
}
}
- source: $.votableById
- target: $.votable
+ source: $.votableById
+ target: $.votable
requirements:
- name: __fusion_1_id
selectionMap: >-
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
index 06a4602b6b2..285a8291783 100644
--- 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
@@ -101,7 +101,10 @@ sourceSchemas:
) {
votableById(id: $__fusion_1_id) {
__typename
- totalVotes
+ ... on Discussion {
+ __typename
+ totalVotes
+ }
}
}
variables: |
@@ -131,8 +134,8 @@ operationPlan:
}
}
hash: 1bcbe788569dbb79a305829bc99385cf
- searchSpace: 1
- expandedNodes: 2
+ searchSpace: 2
+ expandedNodes: 3
nodes:
- id: 1
type: Operation
@@ -153,11 +156,14 @@ operationPlan:
) {
votableById(id: $__fusion_1_id) {
__typename
- totalVotes
+ ... on Discussion {
+ __typename
+ totalVotes
+ }
}
}
- source: $.votableById
- target: $.votable
+ source: $.votableById
+ target: $.votable
requirements:
- name: __fusion_1_id
selectionMap: >-
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/InterfaceLookupPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/InterfaceLookupPlanningTests.cs
new file mode 100644
index 00000000000..34bba39bd86
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/InterfaceLookupPlanningTests.cs
@@ -0,0 +1,144 @@
+using HotChocolate.Fusion.Types;
+
+namespace HotChocolate.Fusion.Planning;
+
+public sealed class InterfaceLookupPlanningTests : FusionTestBase
+{
+ [Fact]
+ public void Abstract_Customer_Interface_With_Id_Only_Is_Plannable()
+ {
+ // arrange
+ var schema = CreateSplitCustomerSchema();
+
+ // act
+ var error = Record.Exception(
+ () => PlanOperation(
+ schema,
+ """
+ query {
+ ordersByAgent {
+ edges {
+ node {
+ id
+ customer {
+ __typename
+ id
+ }
+ }
+ }
+ }
+ }
+ """));
+
+ // assert
+ Assert.Null(error);
+ }
+
+ [Fact]
+ public void Abstract_Customer_Interface_With_Email_Is_Plannable()
+ {
+ // arrange
+ var schema = CreateSplitCustomerSchema();
+
+ // act
+ var plan = PlanOperation(
+ schema,
+ """
+ query {
+ ordersByAgent {
+ edges {
+ node {
+ id
+ customer {
+ __typename
+ id
+ email
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ // assert
+ MatchSnapshot(plan);
+ }
+
+ private static FusionSchemaDefinition CreateSplitCustomerSchema()
+ => ComposeSchema(
+ """
+ # name: common
+ schema {
+ query: Query
+ }
+
+ type Query {
+ ordersByAgent: OrderConnection
+ }
+
+ type OrderConnection {
+ edges: [OrderEdge!]
+ }
+
+ type OrderEdge {
+ node: Order!
+ }
+
+ type Order @key(fields: "id") {
+ id: ID!
+ customer: Customer!
+ }
+
+ interface Customer {
+ id: ID!
+ }
+
+ type ch_Customer implements Customer @key(fields: "id") {
+ id: ID!
+ }
+
+ type de_Customer implements Customer @key(fields: "id") {
+ id: ID!
+ }
+ """,
+ """
+ # name: ch
+ schema {
+ query: Query
+ }
+
+ type Query {
+ ch_customerById(id: ID! @is(field: "id")): ch_Customer @lookup @internal
+ }
+
+ interface Customer {
+ id: ID!
+ email: String!
+ }
+
+ type ch_Customer implements Customer @key(fields: "id") {
+ id: ID!
+ email: String!
+ }
+ """,
+ """
+ # name: de
+ schema {
+ query: Query
+ }
+
+ type Query {
+ de_customerById(id: ID! @is(field: "id")): de_Customer @lookup @internal
+ }
+
+ interface Customer {
+ id: ID!
+ email: String!
+ }
+
+ type de_Customer implements Customer @key(fields: "id") {
+ id: ID!
+ email: String!
+ }
+ """);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/InterfaceLookupPlanningTests.Abstract_Customer_Interface_With_Email_Is_Plannable.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/InterfaceLookupPlanningTests.Abstract_Customer_Interface_With_Email_Is_Plannable.yaml
new file mode 100644
index 00000000000..7d69b1eade0
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/InterfaceLookupPlanningTests.Abstract_Customer_Interface_With_Email_Is_Plannable.yaml
@@ -0,0 +1,78 @@
+operation:
+ - document: |
+ {
+ ordersByAgent {
+ edges {
+ node {
+ id
+ customer {
+ __typename
+ id
+ id @fusion__requirement
+ email
+ }
+ }
+ }
+ }
+ }
+ hash: 123456789101112
+ searchSpace: 1
+ expandedNodes: 2
+nodes:
+ - id: 1
+ type: Operation
+ schema: common
+ operation: |
+ query Op_123456789101112_1 {
+ ordersByAgent {
+ edges {
+ node {
+ id
+ customer {
+ __typename
+ id
+ }
+ }
+ }
+ }
+ }
+ - id: 2
+ type: Operation
+ schema: de
+ operation: |
+ query Op_123456789101112_2(
+ $__fusion_1_id: ID!
+ ) {
+ de_customerById(id: $__fusion_1_id) {
+ __typename
+ email
+ }
+ }
+ source: $.de_customerById
+ target: $.ordersByAgent.edges.node.customer
+ requirements:
+ - name: __fusion_1_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1
+ - id: 3
+ type: Operation
+ schema: ch
+ operation: |
+ query Op_123456789101112_3(
+ $__fusion_2_id: ID!
+ ) {
+ ch_customerById(id: $__fusion_2_id) {
+ __typename
+ email
+ }
+ }
+ source: $.ch_customerById
+ target: $.ordersByAgent.edges.node.customer
+ requirements:
+ - name: __fusion_2_id
+ selectionMap: >-
+ id
+ dependencies:
+ - id: 1