Skip to content

Commit e10c13a

Browse files
Federation: handle shared root fields in optimal way (#6188)
* chore(dependencies): updated changesets for modified dependencies * chore(dependencies): updated changesets for modified dependencies * Federation: handle shared root fields in optimal way --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent dfccfbf commit e10c13a

File tree

10 files changed

+271
-21
lines changed

10 files changed

+271
-21
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphql-tools/delegate": patch
3+
---
4+
5+
Add `subtractSelectionSets` to get the diff of two selection sets

.changeset/nervous-buses-obey.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphql-tools/utils": patch
3+
---
4+
5+
Add `respectArrayLength` flag to `mergeDeep` so instead of concatenating the arrays, elements of them will be merged if they have the same length

.changeset/short-moles-own.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphql-tools/stitch": patch
3+
---
4+
5+
Handle nested selections in `calculateSelectionScore`

.changeset/tame-meals-compare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphql-tools/federation": patch
3+
---
4+
5+
If two different subschemas have the root field, use the same field to resolve missing fields instead of applying a type merging in advance

packages/delegate/src/extractUnavailableFields.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Kind,
1616
SelectionNode,
1717
SelectionSetNode,
18+
visit,
1819
} from 'graphql';
1920
import { Maybe, memoize4 } from '@graphql-tools/utils';
2021

@@ -135,3 +136,48 @@ export const extractUnavailableFields = memoize4(function extractUnavailableFiel
135136
}
136137
return [];
137138
});
139+
140+
function getByPath<T>(object: unknown, path: readonly (string | number)[]) {
141+
let current = object;
142+
for (const pathSegment of path) {
143+
if (current == null) {
144+
return;
145+
}
146+
current = current[pathSegment];
147+
}
148+
return current as T | undefined;
149+
}
150+
151+
export function subtractSelectionSets(
152+
selectionSetA: SelectionSetNode,
153+
selectionSetB: SelectionSetNode,
154+
) {
155+
return visit(selectionSetA, {
156+
[Kind.FIELD]: {
157+
enter(node, _key, _parent, path) {
158+
if (
159+
!node.selectionSet &&
160+
getByPath<SelectionNode[]>(selectionSetB, path.slice(0, -1))?.some(
161+
selection => selection.kind === Kind.FIELD && selection.name.value === node.name.value,
162+
)
163+
) {
164+
return null;
165+
}
166+
},
167+
},
168+
[Kind.SELECTION_SET]: {
169+
leave(node) {
170+
if (node.selections.length === 0) {
171+
return null;
172+
}
173+
},
174+
},
175+
[Kind.INLINE_FRAGMENT]: {
176+
leave(node) {
177+
if (node.selectionSet?.selections.length === 0) {
178+
return null;
179+
}
180+
},
181+
},
182+
});
183+
}

packages/federation/src/supergraph.ts

Lines changed: 119 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
EnumTypeDefinitionNode,
55
EnumValueDefinitionNode,
66
FieldDefinitionNode,
7+
FieldNode,
78
GraphQLOutputType,
89
GraphQLSchema,
910
InputValueDefinitionNode,
@@ -20,6 +21,7 @@ import {
2021
print,
2122
ScalarTypeDefinitionNode,
2223
SelectionNode,
24+
SelectionSetNode,
2325
TypeDefinitionNode,
2426
TypeInfo,
2527
UnionTypeDefinitionNode,
@@ -28,9 +30,10 @@ import {
2830
} from 'graphql';
2931
import {
3032
delegateToSchema,
31-
extractUnavailableFields,
33+
extractUnavailableFieldsFromSelectionSet,
3234
MergedTypeConfig,
3335
SubschemaConfig,
36+
subtractSelectionSets,
3437
} from '@graphql-tools/delegate';
3538
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
3639
import {
@@ -43,7 +46,9 @@ import {
4346
} from '@graphql-tools/stitch';
4447
import {
4548
createGraphQLError,
49+
isPromise,
4650
memoize1,
51+
mergeDeep,
4752
parseSelectionSet,
4853
type Executor,
4954
} from '@graphql-tools/utils';
@@ -109,41 +114,135 @@ export function getFieldMergerFromSupergraphSdl(
109114
return candidates[0].fieldConfig;
110115
}
111116
if (candidates.some(candidate => rootTypeMap.has(candidate.type.name))) {
117+
const candidateNames = new Set<string>();
118+
const realCandidates: MergeFieldConfigCandidate[] = [];
119+
for (const candidate of candidates.toReversed
120+
? candidates.toReversed()
121+
: [...candidates].reverse()) {
122+
if (
123+
candidate.transformedSubschema?.name &&
124+
!candidateNames.has(candidate.transformedSubschema.name)
125+
) {
126+
candidateNames.add(candidate.transformedSubschema.name);
127+
realCandidates.push(candidate);
128+
}
129+
}
130+
const defaultMergedField = defaultMerger(candidates);
112131
return {
113-
...defaultMerger(candidates),
132+
...defaultMergedField,
114133
resolve(_root, _args, context, info) {
115134
let currentSubschema: SubschemaConfig | undefined;
116135
let currentScore = Infinity;
117-
for (const fieldNode of info.fieldNodes) {
118-
const candidatesReversed = candidates.toReversed
119-
? candidates.toReversed()
120-
: [...candidates].reverse();
121-
for (const candidate of candidatesReversed) {
122-
const typeFieldMap = candidate.type.getFields();
123-
if (candidate.transformedSubschema) {
124-
const unavailableFields = extractUnavailableFields(
125-
candidate.transformedSubschema.transformedSchema,
126-
typeFieldMap[candidate.fieldName],
127-
fieldNode,
128-
() => true,
136+
let currentUnavailableSelectionSet: SelectionSetNode | undefined;
137+
let currentFriendSubschemas: Map<SubschemaConfig, SelectionSetNode> | undefined;
138+
let currentAvailableSelectionSet: SelectionSetNode | undefined;
139+
const originalSelectionSet: SelectionSetNode = {
140+
kind: Kind.SELECTION_SET,
141+
selections: info.fieldNodes,
142+
};
143+
// Find the best subschema to delegate this selection
144+
for (const candidate of realCandidates) {
145+
if (candidate.transformedSubschema) {
146+
const unavailableFields = extractUnavailableFieldsFromSelectionSet(
147+
candidate.transformedSubschema.transformedSchema,
148+
candidate.type,
149+
originalSelectionSet,
150+
() => true,
151+
);
152+
const score = calculateSelectionScore(unavailableFields);
153+
if (score < currentScore) {
154+
currentScore = score;
155+
currentSubschema = candidate.transformedSubschema;
156+
currentFriendSubschemas = new Map();
157+
currentUnavailableSelectionSet = {
158+
kind: Kind.SELECTION_SET,
159+
selections: unavailableFields,
160+
};
161+
currentAvailableSelectionSet = subtractSelectionSets(
162+
originalSelectionSet,
163+
currentUnavailableSelectionSet,
129164
);
130-
const score = calculateSelectionScore(unavailableFields);
131-
if (score < currentScore) {
132-
currentScore = score;
133-
currentSubschema = candidate.transformedSubschema;
165+
// Make parallel requests if there are other subschemas
166+
// that can resolve the remaining fields for this selection directly from the root field
167+
// instead of applying a type merging in advance
168+
for (const friendCandidate of realCandidates) {
169+
if (friendCandidate === candidate || !friendCandidate.transformedSubschema) {
170+
continue;
171+
}
172+
const unavailableFieldsInFriend = extractUnavailableFieldsFromSelectionSet(
173+
friendCandidate.transformedSubschema.transformedSchema,
174+
friendCandidate.type,
175+
currentUnavailableSelectionSet,
176+
() => true,
177+
);
178+
const friendScore = calculateSelectionScore(unavailableFieldsInFriend);
179+
if (friendScore < score) {
180+
const unavailableInFriendSelectionSet: SelectionSetNode = {
181+
kind: Kind.SELECTION_SET,
182+
selections: unavailableFieldsInFriend,
183+
};
184+
const subschemaSelectionSet = subtractSelectionSets(
185+
currentUnavailableSelectionSet,
186+
unavailableInFriendSelectionSet,
187+
);
188+
currentFriendSubschemas.set(
189+
friendCandidate.transformedSubschema,
190+
subschemaSelectionSet,
191+
);
192+
currentUnavailableSelectionSet = unavailableInFriendSelectionSet;
193+
}
134194
}
135195
}
136196
}
137197
}
138198
if (!currentSubschema) {
139199
throw new Error('Could not determine subschema');
140200
}
141-
return delegateToSchema({
201+
const jobs: Promise<void>[] = [];
202+
let hasPromise = false;
203+
const mainJob = delegateToSchema({
142204
schema: currentSubschema,
143205
operation: rootTypeMap.get(info.parentType.name) || ('query' as OperationTypeNode),
144206
context,
145-
info,
207+
info: currentFriendSubschemas?.size
208+
? {
209+
...info,
210+
fieldNodes: [
211+
...(currentAvailableSelectionSet?.selections || []),
212+
...(currentUnavailableSelectionSet?.selections || []),
213+
] as FieldNode[],
214+
}
215+
: info,
146216
});
217+
if (isPromise(mainJob)) {
218+
hasPromise = true;
219+
}
220+
jobs.push(mainJob);
221+
if (currentFriendSubschemas?.size) {
222+
for (const [friendSubschema, friendSelectionSet] of currentFriendSubschemas) {
223+
const friendJob = delegateToSchema({
224+
schema: friendSubschema,
225+
operation: rootTypeMap.get(info.parentType.name) || ('query' as OperationTypeNode),
226+
context,
227+
info: {
228+
...info,
229+
fieldNodes: friendSelectionSet.selections as FieldNode[],
230+
},
231+
skipTypeMerging: true,
232+
});
233+
if (isPromise(friendJob)) {
234+
hasPromise = true;
235+
}
236+
jobs.push(friendJob);
237+
}
238+
}
239+
if (jobs.length === 1) {
240+
return jobs[0];
241+
}
242+
if (hasPromise) {
243+
return Promise.all(jobs).then(results => mergeDeep(results));
244+
}
245+
return mergeDeep(jobs);
147246
},
148247
};
149248
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
schema
2+
@link(url: "https://specs.apollo.dev/link/v1.0")
3+
@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
4+
{
5+
query: Query
6+
}
7+
8+
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
9+
10+
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
11+
12+
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
13+
14+
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
15+
16+
directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR
17+
18+
directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
19+
20+
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
21+
22+
scalar join__FieldSet
23+
24+
enum join__Graph {
25+
CATEGORY @join__graph(name: "category", url: "https://federation-compatibility.the-guild.dev/shared-root/category")
26+
NAME @join__graph(name: "name", url: "https://federation-compatibility.the-guild.dev/shared-root/name")
27+
PRICE @join__graph(name: "price", url: "https://federation-compatibility.the-guild.dev/shared-root/price")
28+
}
29+
30+
scalar link__Import
31+
32+
enum link__Purpose {
33+
"""
34+
`SECURITY` features provide metadata necessary to securely resolve fields.
35+
"""
36+
SECURITY
37+
38+
"""
39+
`EXECUTION` features provide metadata necessary for operation execution.
40+
"""
41+
EXECUTION
42+
}
43+
44+
type Product
45+
@join__type(graph: CATEGORY, key: "id")
46+
@join__type(graph: NAME, key: "id")
47+
@join__type(graph: PRICE, key: "id")
48+
{
49+
id: ID!
50+
category: String @join__field(graph: CATEGORY)
51+
name: String @join__field(graph: NAME)
52+
price: Float @join__field(graph: PRICE)
53+
}
54+
55+
type Query
56+
@join__type(graph: CATEGORY)
57+
@join__type(graph: NAME)
58+
@join__type(graph: PRICE)
59+
{
60+
product: Product
61+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[
2+
{
3+
"query": "\n query {\n product {\n id\n name\n category\n price\n }\n }\n ",
4+
"expected": {
5+
"data": {
6+
"product": {
7+
"id": "1",
8+
"name": "Product 1",
9+
"price": 100,
10+
"category": "Category 1"
11+
}
12+
}
13+
}
14+
}
15+
]

packages/stitch/src/createDelegationPlanBuilder.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,10 @@ export function calculateSelectionScore(selections: readonly SelectionNode[]) {
202202
for (const selectionNode of selections) {
203203
switch (selectionNode.kind) {
204204
case Kind.FIELD:
205-
score += 1;
205+
score++;
206+
if (selectionNode.selectionSet?.selections) {
207+
score += calculateSelectionScore(selectionNode.selectionSet.selections);
208+
}
206209
break;
207210
case Kind.INLINE_FRAGMENT:
208211
score += calculateSelectionScore(selectionNode.selectionSet.selections);

packages/utils/src/mergeDeep.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function mergeDeep<S extends any[]>(
1010
sources: S,
1111
respectPrototype = false,
1212
respectArrays = false,
13+
respectArrayLength = false,
1314
): UnboxIntersection<UnionToIntersection<BoxedTupleTypes<S>>> & any {
1415
const target = sources[0] || {};
1516
const output = {};
@@ -54,6 +55,11 @@ export function mergeDeep<S extends any[]>(
5455
}
5556
} else if (respectArrays && Array.isArray(target)) {
5657
if (Array.isArray(source)) {
58+
if (respectArrayLength && source.length === target.length) {
59+
return target.map((targetElem, i) =>
60+
mergeDeep([targetElem, source[i]], respectPrototype, respectArrays, respectArrayLength),
61+
);
62+
}
5763
target.push(...source);
5864
} else {
5965
target.push(source);

0 commit comments

Comments
 (0)