Skip to content

Commit d5688a4

Browse files
michael-watsonmartijnwalravenabernix
authored
Remove undefined entities when executing Fetch of query plan (#612)
Improved query plan execution by pruning entities which are `undefined`, `null` or didn't match specified type conditions after a prior `FetchNode` execution. After pruning, only the remaining entities will be fetched in the dependent `FetchNode` execution. If NO entities remain, the request will not be made. Co-authored-by: Michael Watson <[email protected]> Co-authored-by: Martijn Walraven <[email protected]> Co-authored-by: Jesse Rosenberger <[email protected]>
1 parent 66f1b9a commit d5688a4

File tree

4 files changed

+338
-4
lines changed

4 files changed

+338
-4
lines changed

gateway-js/CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
> The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section.
66
7-
- _Nothing yet! Stay tuned!_
7+
- Improved query plan execution by pruning entities which are `undefined` or didn't match specified type conditions after a prior `FetchNode` execution. After pruning, only the remaining entities will be fetched in the dependent `FetchNode` execution. If NO entities remain, the request will not be made. [PR #612](https://github.com/apollographql/federation/pull/612)
8+
89
## v0.25.0
910

1011
- Add support for an upcoming mechanism for fetching a graph's schema and configuration when using managed federation. In this release, the new mechanism is opt-in, and users shouldn't use enable it unless instructed by Apollo to do so. [PR #458](https://github.com/apollographql/federation/pull/458) [PR #585](https://github.com/apollographql/federation/pull/585)

gateway-js/src/__tests__/executeQueryPlan.test.ts

+321
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ describe('executeQueryPlan', () => {
2525
addResolversToSchema(serviceMap[serviceName].schema, resolvers);
2626
}
2727

28+
function spyOnEntitiesResolverInService(serviceName: string) {
29+
const entitiesField = serviceMap[serviceName].schema
30+
.getQueryType()!
31+
.getFields()['_entities'];
32+
return jest.spyOn(entitiesField, 'resolve');
33+
}
34+
2835
let schema: GraphQLSchema;
2936
let queryPlannerPointer: QueryPlannerPointer;
3037

@@ -142,6 +149,320 @@ describe('executeQueryPlan', () => {
142149
expect(response).toHaveProperty('errors.0.extensions.variables', {});
143150
});
144151

152+
it(`should not send request to downstream services when all entities are undefined`, async () => {
153+
const accountsEntitiesResolverSpy = spyOnEntitiesResolverInService(
154+
'accounts',
155+
);
156+
157+
const operationString = `#graphql
158+
query {
159+
# The first 3 products are all Furniture
160+
topProducts(first: 3) {
161+
reviews {
162+
body
163+
}
164+
... on Book {
165+
reviews {
166+
author {
167+
name {
168+
first
169+
last
170+
}
171+
}
172+
}
173+
}
174+
}
175+
}
176+
`;
177+
178+
const operationDocument = gql(operationString);
179+
180+
const operationContext = buildOperationContext({
181+
schema,
182+
operationDocument,
183+
operationString,
184+
queryPlannerPointer,
185+
});
186+
187+
const queryPlan = buildQueryPlan(operationContext);
188+
189+
const response = await executeQueryPlan(
190+
queryPlan,
191+
serviceMap,
192+
buildRequestContext(),
193+
operationContext,
194+
);
195+
196+
expect(accountsEntitiesResolverSpy).not.toHaveBeenCalled();
197+
198+
expect(response).toMatchInlineSnapshot(`
199+
Object {
200+
"data": Object {
201+
"topProducts": Array [
202+
Object {
203+
"reviews": Array [
204+
Object {
205+
"body": "Love it!",
206+
},
207+
Object {
208+
"body": "Prefer something else.",
209+
},
210+
],
211+
},
212+
Object {
213+
"reviews": Array [
214+
Object {
215+
"body": "Too expensive.",
216+
},
217+
],
218+
},
219+
Object {
220+
"reviews": Array [
221+
Object {
222+
"body": "Could be better.",
223+
},
224+
],
225+
},
226+
],
227+
},
228+
}
229+
`);
230+
});
231+
232+
it(`should send a request to downstream services for the remaining entities when some entities are undefined`, async () => {
233+
const accountsEntitiesResolverSpy = spyOnEntitiesResolverInService(
234+
'accounts',
235+
);
236+
237+
const operationString = `#graphql
238+
query {
239+
# The first 3 products are all Furniture, but the next 2 are Books
240+
topProducts(first: 5) {
241+
reviews {
242+
body
243+
}
244+
... on Book {
245+
reviews {
246+
author {
247+
name {
248+
first
249+
last
250+
}
251+
}
252+
}
253+
}
254+
}
255+
}
256+
`;
257+
258+
const operationDocument = gql(operationString);
259+
260+
const operationContext = buildOperationContext({
261+
schema,
262+
operationDocument,
263+
operationString,
264+
queryPlannerPointer,
265+
});
266+
267+
const queryPlan = buildQueryPlan(operationContext);
268+
269+
const response = await executeQueryPlan(
270+
queryPlan,
271+
serviceMap,
272+
buildRequestContext(),
273+
operationContext,
274+
);
275+
276+
expect(accountsEntitiesResolverSpy).toHaveBeenCalledTimes(1);
277+
expect(accountsEntitiesResolverSpy.mock.calls[0][1]).toEqual({
278+
representations: [
279+
{ __typename: 'User', id: '2' },
280+
{ __typename: 'User', id: '2' },
281+
],
282+
});
283+
284+
expect(response).toMatchInlineSnapshot(`
285+
Object {
286+
"data": Object {
287+
"topProducts": Array [
288+
Object {
289+
"reviews": Array [
290+
Object {
291+
"body": "Love it!",
292+
},
293+
Object {
294+
"body": "Prefer something else.",
295+
},
296+
],
297+
},
298+
Object {
299+
"reviews": Array [
300+
Object {
301+
"body": "Too expensive.",
302+
},
303+
],
304+
},
305+
Object {
306+
"reviews": Array [
307+
Object {
308+
"body": "Could be better.",
309+
},
310+
],
311+
},
312+
Object {
313+
"reviews": Array [
314+
Object {
315+
"author": Object {
316+
"name": Object {
317+
"first": "Alan",
318+
"last": "Turing",
319+
},
320+
},
321+
"body": "Wish I had read this before.",
322+
},
323+
],
324+
},
325+
Object {
326+
"reviews": Array [
327+
Object {
328+
"author": Object {
329+
"name": Object {
330+
"first": "Alan",
331+
"last": "Turing",
332+
},
333+
},
334+
"body": "A bit outdated.",
335+
},
336+
],
337+
},
338+
],
339+
},
340+
}
341+
`);
342+
});
343+
344+
it(`should not send request to downstream service when entities don't match type conditions`, async () => {
345+
const reviewsEntitiesResolverSpy = spyOnEntitiesResolverInService(
346+
'reviews',
347+
);
348+
349+
const operationString = `#graphql
350+
query {
351+
# The first 3 products are all Furniture
352+
topProducts(first: 3) {
353+
... on Book {
354+
reviews {
355+
body
356+
}
357+
}
358+
}
359+
}
360+
`;
361+
362+
const operationDocument = gql(operationString);
363+
364+
const operationContext = buildOperationContext({
365+
schema,
366+
operationDocument,
367+
operationString,
368+
queryPlannerPointer,
369+
});
370+
371+
const queryPlan = buildQueryPlan(operationContext);
372+
373+
const response = await executeQueryPlan(
374+
queryPlan,
375+
serviceMap,
376+
buildRequestContext(),
377+
operationContext,
378+
);
379+
380+
expect(reviewsEntitiesResolverSpy).not.toHaveBeenCalled();
381+
382+
expect(response).toMatchInlineSnapshot(`
383+
Object {
384+
"data": Object {
385+
"topProducts": Array [
386+
Object {},
387+
Object {},
388+
Object {},
389+
],
390+
},
391+
}
392+
`);
393+
});
394+
395+
it(`should send a request to downstream services for the remaining entities when some entities don't match type conditions`, async () => {
396+
const reviewsEntitiesResolverSpy = spyOnEntitiesResolverInService(
397+
'reviews',
398+
);
399+
400+
const operationString = `#graphql
401+
query {
402+
# The first 3 products are all Furniture, but the next 2 are Books
403+
topProducts(first: 5) {
404+
... on Book {
405+
reviews {
406+
body
407+
}
408+
}
409+
}
410+
}
411+
`;
412+
413+
const operationDocument = gql(operationString);
414+
415+
const operationContext = buildOperationContext({
416+
schema,
417+
operationDocument,
418+
operationString,
419+
queryPlannerPointer,
420+
});
421+
422+
const queryPlan = buildQueryPlan(operationContext);
423+
424+
const response = await executeQueryPlan(
425+
queryPlan,
426+
serviceMap,
427+
buildRequestContext(),
428+
operationContext,
429+
);
430+
431+
expect(reviewsEntitiesResolverSpy).toHaveBeenCalledTimes(1);
432+
expect(reviewsEntitiesResolverSpy.mock.calls[0][1]).toEqual({
433+
representations: [
434+
{ __typename: 'Book', isbn: '0262510871' },
435+
{ __typename: 'Book', isbn: '0136291554' },
436+
],
437+
});
438+
439+
expect(response).toMatchInlineSnapshot(`
440+
Object {
441+
"data": Object {
442+
"topProducts": Array [
443+
Object {},
444+
Object {},
445+
Object {},
446+
Object {
447+
"reviews": Array [
448+
Object {
449+
"body": "Wish I had read this before.",
450+
},
451+
],
452+
},
453+
Object {
454+
"reviews": Array [
455+
Object {
456+
"body": "A bit outdated.",
457+
},
458+
],
459+
},
460+
],
461+
},
462+
}
463+
`);
464+
});
465+
145466
it(`should still include other root-level results if one root-level field errors out`, async () => {
146467
overrideResolversInService('accounts', {
147468
RootQuery: {

gateway-js/src/executeQueryPlan.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getResponseName
2626
} from '@apollo/query-planner';
2727
import { deepMerge } from './utilities/deepMerge';
28+
import { isNotNullOrUndefined } from './utilities/array';
2829

2930
export type ServiceMap = {
3031
[serviceName: string]: GraphQLDataSource;
@@ -192,7 +193,7 @@ async function executeNode<TContext>(
192193
async function executeFetch<TContext>(
193194
context: ExecutionContext<TContext>,
194195
fetch: FetchNode,
195-
results: ResultMap | ResultMap[],
196+
results: ResultMap | (ResultMap | null | undefined)[],
196197
_path: ResponsePath,
197198
traceNode: Trace.QueryPlanNode.FetchNode | null,
198199
): Promise<void> {
@@ -202,7 +203,14 @@ async function executeFetch<TContext>(
202203
throw new Error(`Couldn't find service with name "${fetch.serviceName}"`);
203204
}
204205

205-
const entities = Array.isArray(results) ? results : [results];
206+
let entities: ResultMap[];
207+
if (Array.isArray(results)) {
208+
// Remove null or undefined entities from the list
209+
entities = results.filter(isNotNullOrUndefined);
210+
} else {
211+
entities = [results];
212+
}
213+
206214
if (entities.length < 1) return;
207215

208216
let variables = Object.create(null);
@@ -242,6 +250,10 @@ async function executeFetch<TContext>(
242250
}
243251
});
244252

253+
// If there are no representations, that means the type conditions in
254+
// the requires don't match any entities.
255+
if (representations.length < 1) return;
256+
245257
if ('representations' in variables) {
246258
throw new Error(`Variables cannot contain key "representations"`);
247259
}

gateway-js/src/utilities/array.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
function isNotNullOrUndefined<T>(
1+
export function isNotNullOrUndefined<T>(
22
value: T | null | undefined,
33
): value is T {
44
return value !== null && typeof value !== 'undefined';

0 commit comments

Comments
 (0)