Skip to content

Commit

Permalink
Federation: handle shared root fields in optimal way (#6188)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
ardatan and github-actions[bot] authored May 17, 2024
1 parent dfccfbf commit e10c13a
Show file tree
Hide file tree
Showing 10 changed files with 271 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/gorgeous-crabs-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphql-tools/delegate": patch
---

Add `subtractSelectionSets` to get the diff of two selection sets
5 changes: 5 additions & 0 deletions .changeset/nervous-buses-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphql-tools/utils": patch
---

Add `respectArrayLength` flag to `mergeDeep` so instead of concatenating the arrays, elements of them will be merged if they have the same length
5 changes: 5 additions & 0 deletions .changeset/short-moles-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphql-tools/stitch": patch
---

Handle nested selections in `calculateSelectionScore`
5 changes: 5 additions & 0 deletions .changeset/tame-meals-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphql-tools/federation": patch
---

If two different subschemas have the root field, use the same field to resolve missing fields instead of applying a type merging in advance
46 changes: 46 additions & 0 deletions packages/delegate/src/extractUnavailableFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Kind,
SelectionNode,
SelectionSetNode,
visit,
} from 'graphql';
import { Maybe, memoize4 } from '@graphql-tools/utils';

Expand Down Expand Up @@ -135,3 +136,48 @@ export const extractUnavailableFields = memoize4(function extractUnavailableFiel
}
return [];
});

function getByPath<T>(object: unknown, path: readonly (string | number)[]) {
let current = object;
for (const pathSegment of path) {
if (current == null) {
return;
}
current = current[pathSegment];
}
return current as T | undefined;
}

export function subtractSelectionSets(
selectionSetA: SelectionSetNode,
selectionSetB: SelectionSetNode,
) {
return visit(selectionSetA, {
[Kind.FIELD]: {
enter(node, _key, _parent, path) {
if (
!node.selectionSet &&
getByPath<SelectionNode[]>(selectionSetB, path.slice(0, -1))?.some(
selection => selection.kind === Kind.FIELD && selection.name.value === node.name.value,
)
) {
return null;
}
},
},
[Kind.SELECTION_SET]: {
leave(node) {
if (node.selections.length === 0) {
return null;
}
},
},
[Kind.INLINE_FRAGMENT]: {
leave(node) {
if (node.selectionSet?.selections.length === 0) {
return null;
}
},
},
});
}
139 changes: 119 additions & 20 deletions packages/federation/src/supergraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
EnumTypeDefinitionNode,
EnumValueDefinitionNode,
FieldDefinitionNode,
FieldNode,
GraphQLOutputType,
GraphQLSchema,
InputValueDefinitionNode,
Expand All @@ -20,6 +21,7 @@ import {
print,
ScalarTypeDefinitionNode,
SelectionNode,
SelectionSetNode,
TypeDefinitionNode,
TypeInfo,
UnionTypeDefinitionNode,
Expand All @@ -28,9 +30,10 @@ import {
} from 'graphql';
import {
delegateToSchema,
extractUnavailableFields,
extractUnavailableFieldsFromSelectionSet,
MergedTypeConfig,
SubschemaConfig,
subtractSelectionSets,
} from '@graphql-tools/delegate';
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import {
Expand All @@ -43,7 +46,9 @@ import {
} from '@graphql-tools/stitch';
import {
createGraphQLError,
isPromise,
memoize1,
mergeDeep,
parseSelectionSet,
type Executor,
} from '@graphql-tools/utils';
Expand Down Expand Up @@ -109,41 +114,135 @@ export function getFieldMergerFromSupergraphSdl(
return candidates[0].fieldConfig;
}
if (candidates.some(candidate => rootTypeMap.has(candidate.type.name))) {
const candidateNames = new Set<string>();
const realCandidates: MergeFieldConfigCandidate[] = [];
for (const candidate of candidates.toReversed
? candidates.toReversed()
: [...candidates].reverse()) {
if (
candidate.transformedSubschema?.name &&
!candidateNames.has(candidate.transformedSubschema.name)
) {
candidateNames.add(candidate.transformedSubschema.name);
realCandidates.push(candidate);
}
}
const defaultMergedField = defaultMerger(candidates);
return {
...defaultMerger(candidates),
...defaultMergedField,
resolve(_root, _args, context, info) {
let currentSubschema: SubschemaConfig | undefined;
let currentScore = Infinity;
for (const fieldNode of info.fieldNodes) {
const candidatesReversed = candidates.toReversed
? candidates.toReversed()
: [...candidates].reverse();
for (const candidate of candidatesReversed) {
const typeFieldMap = candidate.type.getFields();
if (candidate.transformedSubschema) {
const unavailableFields = extractUnavailableFields(
candidate.transformedSubschema.transformedSchema,
typeFieldMap[candidate.fieldName],
fieldNode,
() => true,
let currentUnavailableSelectionSet: SelectionSetNode | undefined;
let currentFriendSubschemas: Map<SubschemaConfig, SelectionSetNode> | undefined;
let currentAvailableSelectionSet: SelectionSetNode | undefined;
const originalSelectionSet: SelectionSetNode = {
kind: Kind.SELECTION_SET,
selections: info.fieldNodes,
};
// Find the best subschema to delegate this selection
for (const candidate of realCandidates) {
if (candidate.transformedSubschema) {
const unavailableFields = extractUnavailableFieldsFromSelectionSet(
candidate.transformedSubschema.transformedSchema,
candidate.type,
originalSelectionSet,
() => true,
);
const score = calculateSelectionScore(unavailableFields);
if (score < currentScore) {
currentScore = score;
currentSubschema = candidate.transformedSubschema;
currentFriendSubschemas = new Map();
currentUnavailableSelectionSet = {
kind: Kind.SELECTION_SET,
selections: unavailableFields,
};
currentAvailableSelectionSet = subtractSelectionSets(
originalSelectionSet,
currentUnavailableSelectionSet,
);
const score = calculateSelectionScore(unavailableFields);
if (score < currentScore) {
currentScore = score;
currentSubschema = candidate.transformedSubschema;
// Make parallel requests if there are other subschemas
// that can resolve the remaining fields for this selection directly from the root field
// instead of applying a type merging in advance
for (const friendCandidate of realCandidates) {
if (friendCandidate === candidate || !friendCandidate.transformedSubschema) {
continue;
}
const unavailableFieldsInFriend = extractUnavailableFieldsFromSelectionSet(
friendCandidate.transformedSubschema.transformedSchema,
friendCandidate.type,
currentUnavailableSelectionSet,
() => true,
);
const friendScore = calculateSelectionScore(unavailableFieldsInFriend);
if (friendScore < score) {
const unavailableInFriendSelectionSet: SelectionSetNode = {
kind: Kind.SELECTION_SET,
selections: unavailableFieldsInFriend,
};
const subschemaSelectionSet = subtractSelectionSets(
currentUnavailableSelectionSet,
unavailableInFriendSelectionSet,
);
currentFriendSubschemas.set(
friendCandidate.transformedSubschema,
subschemaSelectionSet,
);
currentUnavailableSelectionSet = unavailableInFriendSelectionSet;
}
}
}
}
}
if (!currentSubschema) {
throw new Error('Could not determine subschema');
}
return delegateToSchema({
const jobs: Promise<void>[] = [];
let hasPromise = false;
const mainJob = delegateToSchema({
schema: currentSubschema,
operation: rootTypeMap.get(info.parentType.name) || ('query' as OperationTypeNode),
context,
info,
info: currentFriendSubschemas?.size
? {
...info,
fieldNodes: [
...(currentAvailableSelectionSet?.selections || []),
...(currentUnavailableSelectionSet?.selections || []),
] as FieldNode[],
}
: info,
});
if (isPromise(mainJob)) {
hasPromise = true;
}
jobs.push(mainJob);
if (currentFriendSubschemas?.size) {
for (const [friendSubschema, friendSelectionSet] of currentFriendSubschemas) {
const friendJob = delegateToSchema({
schema: friendSubschema,
operation: rootTypeMap.get(info.parentType.name) || ('query' as OperationTypeNode),
context,
info: {
...info,
fieldNodes: friendSelectionSet.selections as FieldNode[],
},
skipTypeMerging: true,
});
if (isPromise(friendJob)) {
hasPromise = true;
}
jobs.push(friendJob);
}
}
if (jobs.length === 1) {
return jobs[0];
}
if (hasPromise) {
return Promise.all(jobs).then(results => mergeDeep(results));
}
return mergeDeep(jobs);
},
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
{
query: Query
}

directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE

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

directive @join__graph(name: String!, url: String!) on ENUM_VALUE

directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE

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

directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION

directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA

scalar join__FieldSet

enum join__Graph {
CATEGORY @join__graph(name: "category", url: "https://federation-compatibility.the-guild.dev/shared-root/category")
NAME @join__graph(name: "name", url: "https://federation-compatibility.the-guild.dev/shared-root/name")
PRICE @join__graph(name: "price", url: "https://federation-compatibility.the-guild.dev/shared-root/price")
}

scalar link__Import

enum link__Purpose {
"""
`SECURITY` features provide metadata necessary to securely resolve fields.
"""
SECURITY

"""
`EXECUTION` features provide metadata necessary for operation execution.
"""
EXECUTION
}

type Product
@join__type(graph: CATEGORY, key: "id")
@join__type(graph: NAME, key: "id")
@join__type(graph: PRICE, key: "id")
{
id: ID!
category: String @join__field(graph: CATEGORY)
name: String @join__field(graph: NAME)
price: Float @join__field(graph: PRICE)
}

type Query
@join__type(graph: CATEGORY)
@join__type(graph: NAME)
@join__type(graph: PRICE)
{
product: Product
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[
{
"query": "\n query {\n product {\n id\n name\n category\n price\n }\n }\n ",
"expected": {
"data": {
"product": {
"id": "1",
"name": "Product 1",
"price": 100,
"category": "Category 1"
}
}
}
}
]
5 changes: 4 additions & 1 deletion packages/stitch/src/createDelegationPlanBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ export function calculateSelectionScore(selections: readonly SelectionNode[]) {
for (const selectionNode of selections) {
switch (selectionNode.kind) {
case Kind.FIELD:
score += 1;
score++;
if (selectionNode.selectionSet?.selections) {
score += calculateSelectionScore(selectionNode.selectionSet.selections);
}
break;
case Kind.INLINE_FRAGMENT:
score += calculateSelectionScore(selectionNode.selectionSet.selections);
Expand Down
6 changes: 6 additions & 0 deletions packages/utils/src/mergeDeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function mergeDeep<S extends any[]>(
sources: S,
respectPrototype = false,
respectArrays = false,
respectArrayLength = false,
): UnboxIntersection<UnionToIntersection<BoxedTupleTypes<S>>> & any {
const target = sources[0] || {};
const output = {};
Expand Down Expand Up @@ -54,6 +55,11 @@ export function mergeDeep<S extends any[]>(
}
} else if (respectArrays && Array.isArray(target)) {
if (Array.isArray(source)) {
if (respectArrayLength && source.length === target.length) {
return target.map((targetElem, i) =>
mergeDeep([targetElem, source[i]], respectPrototype, respectArrays, respectArrayLength),
);
}
target.push(...source);
} else {
target.push(source);
Expand Down

0 comments on commit e10c13a

Please sign in to comment.