Skip to content

Commit 67a9c49

Browse files
fix(stitch): handle unresolvable fields (#6117)
* fix(stitch): handle unresolvable fields * Correct * Fix TS * Update packages/delegate/src/delegateToSchema.ts Co-authored-by: Kamil Kisiela <[email protected]> * Update packages/delegate/src/delegateToSchema.ts --------- Co-authored-by: Kamil Kisiela <[email protected]>
1 parent d9bb19d commit 67a9c49

File tree

5 files changed

+338
-22
lines changed

5 files changed

+338
-22
lines changed

.changeset/green-sheep-bake.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@graphql-tools/stitch": patch
3+
---
4+
5+
Add field as an unavailable field only if it is not able to resolve by any other subschema;
6+
7+
When the following query is sent to the gateway with the following subschemas, the gateway should resolve `Category.details` from A Subschema using `Product` resolver instead of trying to resolve by using non-existing `Category` resolver from A Subschema.
8+
9+
Previously, the query planner decides to resolve `Category.details` after resolving `Category` from C Subschema. But it will be too late to resolve `details` because `Category` is not resolvable in A Subschema.
10+
11+
So the requests for `Category.details` and the rest of `Category` should be different.
12+
13+
So for the following query, we expect a full result;
14+
```graphql
15+
query {
16+
productFromA(id: "1") {
17+
id
18+
name
19+
category {
20+
id
21+
name
22+
details
23+
}
24+
}
25+
}
26+
```
27+
28+
29+
```graphql
30+
# A Subschema
31+
type Query {
32+
productFromA(id: ID): Product
33+
# No category resolver is present
34+
}
35+
36+
type Product {
37+
id: ID
38+
category: Category
39+
}
40+
41+
type Category {
42+
details: CategoryDetails
43+
}
44+
```
45+
46+
```graphql
47+
# B Subschema
48+
type Query {
49+
productFromB(id: ID): Product
50+
}
51+
type Product {
52+
id: ID
53+
name: String
54+
category: Category
55+
}
56+
type Category {
57+
id: ID
58+
}
59+
```
60+
61+
```graphql
62+
# C Subschema
63+
type Query {
64+
categoryFromC(id: ID): Category
65+
}
66+
67+
type Category {
68+
id: ID
69+
name: String
70+
}
71+
```

packages/stitch/src/createDelegationPlanBuilder.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,17 @@ function calculateDelegationStage(
145145
const fields = typeInSubschema.getFields();
146146
const field = fields[fieldNode.name.value];
147147
if (field != null) {
148-
const unavailableFields = extractUnavailableFields(field, fieldNode);
148+
const unavailableFields = extractUnavailableFields(field, fieldNode, fieldType => {
149+
if (!nonUniqueSubschema.merge?.[fieldType.name]) {
150+
delegationMap.set(nonUniqueSubschema, {
151+
kind: Kind.SELECTION_SET,
152+
selections: [fieldNode],
153+
});
154+
// Ignore unresolvable fields
155+
return false;
156+
}
157+
return true;
158+
});
149159
const currentScore = calculateScore(unavailableFields);
150160
if (currentScore < bestScore) {
151161
bestScore = currentScore;

packages/stitch/src/getFieldsNotInSubschema.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
FragmentDefinitionNode,
44
getNamedType,
55
GraphQLField,
6+
GraphQLInterfaceType,
67
GraphQLObjectType,
78
GraphQLSchema,
89
Kind,
@@ -43,7 +44,11 @@ export function getFieldsNotInSubschema(
4344
} else {
4445
const field = fields[fieldName];
4546
for (const subFieldNode of subFieldNodes) {
46-
const unavailableFields = extractUnavailableFields(field, subFieldNode);
47+
const unavailableFields = extractUnavailableFields(
48+
field,
49+
subFieldNode,
50+
(fieldType, selection) => !fieldNodesByField?.[fieldType.name]?.[selection.name.value],
51+
);
4752
if (unavailableFields.length) {
4853
fieldsNotInSchema.add({
4954
...subFieldNode,
@@ -63,7 +68,6 @@ export function getFieldsNotInSubschema(
6368
for (const subFieldNode of subFieldNodes) {
6469
fieldsNotInSchema.add(subFieldNode);
6570
}
66-
6771
fieldsNotInSchema.add(fieldNode);
6872
}
6973
}
@@ -73,7 +77,11 @@ export function getFieldsNotInSubschema(
7377
return Array.from(fieldsNotInSchema);
7478
}
7579

76-
export function extractUnavailableFields(field: GraphQLField<any, any>, fieldNode: FieldNode) {
80+
export function extractUnavailableFields(
81+
field: GraphQLField<any, any>,
82+
fieldNode: FieldNode,
83+
shouldAdd: (fieldType: GraphQLObjectType | GraphQLInterfaceType, selection: FieldNode) => boolean,
84+
) {
7785
if (fieldNode.selectionSet) {
7886
const fieldType = getNamedType(field.type);
7987
// TODO: Only object types are supported
@@ -82,18 +90,23 @@ export function extractUnavailableFields(field: GraphQLField<any, any>, fieldNod
8290
}
8391
const subFields = fieldType.getFields();
8492
const unavailableSelections: SelectionNode[] = [];
85-
let hasTypeName = false;
8693
for (const selection of fieldNode.selectionSet.selections) {
8794
if (selection.kind === Kind.FIELD) {
8895
if (selection.name.value === '__typename') {
89-
hasTypeName = true;
9096
continue;
9197
}
92-
const selectionField = subFields[selection.name.value];
98+
const fieldName = selection.name.value;
99+
const selectionField = subFields[fieldName];
93100
if (!selectionField) {
94-
unavailableSelections.push(selection);
101+
if (shouldAdd(fieldType, selection)) {
102+
unavailableSelections.push(selection);
103+
}
95104
} else {
96-
const unavailableSubFields = extractUnavailableFields(selectionField, selection);
105+
const unavailableSubFields = extractUnavailableFields(
106+
selectionField,
107+
selection,
108+
shouldAdd,
109+
);
97110
if (unavailableSubFields.length) {
98111
unavailableSelections.push({
99112
...selection,
@@ -108,15 +121,6 @@ export function extractUnavailableFields(field: GraphQLField<any, any>, fieldNod
108121
// TODO: Support for inline fragments
109122
}
110123
}
111-
if (unavailableSelections.length && hasTypeName) {
112-
unavailableSelections.unshift({
113-
kind: Kind.FIELD,
114-
name: {
115-
kind: Kind.NAME,
116-
value: '__typename',
117-
},
118-
});
119-
}
120124
return unavailableSelections;
121125
}
122126
return [];

packages/stitch/tests/extractUnavailableFields.test.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe('extractUnavailableFields', () => {
4848
if (!userField) {
4949
throw new Error('User field not found');
5050
}
51-
const unavailableFields = extractUnavailableFields(userField, userSelection);
51+
const unavailableFields = extractUnavailableFields(userField, userSelection, () => true);
5252
const extractedSelectionSet: SelectionSetNode = {
5353
kind: Kind.SELECTION_SET,
5454
selections: unavailableFields,
@@ -57,7 +57,7 @@ describe('extractUnavailableFields', () => {
5757
`{ email friends { id name email } }`,
5858
);
5959
});
60-
it('excludes the fields only with __typename', () => {
60+
it('excludes __typename', () => {
6161
const schema = makeExecutableSchema({
6262
typeDefs: /* GraphQL */ `
6363
type Query {
@@ -80,6 +80,7 @@ describe('extractUnavailableFields', () => {
8080
__typename
8181
id
8282
name
83+
description
8384
}
8485
}
8586
}
@@ -102,11 +103,73 @@ describe('extractUnavailableFields', () => {
102103
if (!userField) {
103104
throw new Error('User field not found');
104105
}
105-
const unavailableFields = extractUnavailableFields(userField, userSelection);
106+
const unavailableFields = extractUnavailableFields(userField, userSelection, () => true);
106107
const extractedSelectionSet: SelectionSetNode = {
107108
kind: Kind.SELECTION_SET,
108109
selections: unavailableFields,
109110
};
110-
expect(stripWhitespaces(print(extractedSelectionSet))).toBe('');
111+
expect(stripWhitespaces(print(extractedSelectionSet))).toBe('{ friends { description } }');
112+
});
113+
it('picks the subfields only when available to resolve', () => {
114+
const schema = makeExecutableSchema({
115+
typeDefs: /* GraphQL */ `
116+
type Query {
117+
post: Post
118+
}
119+
type Post {
120+
id: ID!
121+
}
122+
`,
123+
});
124+
const fieldNodesByField = {
125+
Post: {
126+
id: [],
127+
name: [],
128+
},
129+
Category: {
130+
id: [],
131+
// details: undefined, // This field is not available to resolve
132+
},
133+
};
134+
const postQuery = /* GraphQL */ `
135+
query {
136+
post {
137+
id
138+
name
139+
category {
140+
id
141+
details
142+
}
143+
}
144+
}
145+
`;
146+
const postQueryDoc = parse(postQuery, { noLocation: true });
147+
const operationAst = getOperationAST(postQueryDoc, null);
148+
if (!operationAst) {
149+
throw new Error('Operation AST not found');
150+
}
151+
const selectionSet = operationAst.selectionSet;
152+
const postSelection = selectionSet.selections[0];
153+
if (postSelection.kind !== 'Field') {
154+
throw new Error('Post selection not found');
155+
}
156+
const queryType = schema.getType('Query');
157+
if (!isObjectType(queryType)) {
158+
throw new Error('Query type not found');
159+
}
160+
const postField = queryType.getFields()['post'];
161+
if (!postField) {
162+
throw new Error('Post field not found');
163+
}
164+
const unavailableFields = extractUnavailableFields(
165+
postField,
166+
postSelection,
167+
(fieldType, selection) => !fieldNodesByField?.[fieldType.name]?.[selection.name.value],
168+
);
169+
const extractedSelectionSet: SelectionSetNode = {
170+
kind: Kind.SELECTION_SET,
171+
selections: unavailableFields,
172+
};
173+
expect(stripWhitespaces(print(extractedSelectionSet))).toBe('{ category { id details } }');
111174
});
112175
});

0 commit comments

Comments
 (0)