From 80836fa78af3c6e61c61fe4d3bc52831b2c58931 Mon Sep 17 00:00:00 2001 From: Saihajpreet Singh Date: Tue, 1 Nov 2022 10:13:33 -0400 Subject: [PATCH] get defer/stream from graphql-js (#4796) * get defer/stream from graphql-js * prettier * use agnostic root type getter * use createGraphQLError * fix issue with v15 and v16 cause of obj vs enum * Fix tests * Remove unused subscribe * Fix TS * Fix ESM * ESM fix * move collect fields to utils * chore(dependencies): updated changesets for modified dependencies * run prettier * make default execute incremental * chore(dependencies): updated changesets for modified dependencies * .. * chore(dependencies): updated changesets for modified dependencies * Sync * Use ValueOrPromise * chore(dependencies): updated changesets for modified dependencies * Fix * chore(dependencies): updated changesets for modified dependencies * Go * Go * stop Co-authored-by: Arda TANRIKULU Co-authored-by: github-actions[bot] --- ...raphql-tools_executor-4796-dependencies.md | 6 + .changeset/chatty-worms-pull.md | 5 + .changeset/quick-clocks-beam.md | 5 + .changeset/spotty-maps-approve.md | 5 + .changeset/spotty-suns-bake.md | 5 + .changeset/warm-bags-camp.md | 7 + .../tests/basic.example.test.ts | 8 +- .../tests/withTransforms.test.ts | 3 +- .../batch-execute/tests/batchExecute.test.ts | 4 +- packages/delegate/src/delegateToSchema.ts | 11 +- packages/delegate/src/mergeFields.ts | 12 +- packages/executor/package.json | 4 +- .../expectEqualPromisesOrValues.ts | 22 + .../src/__testUtils__/expectPromise.ts | 22 + .../src/execution/__tests__/defer-test.ts | 700 ++++++ .../__tests__/flattenAsyncIterable-test.ts | 146 ++ .../src/execution/__tests__/invariant-test.ts | 11 + .../src/execution/__tests__/lists-test.ts | 5 +- .../src/execution/__tests__/mutations-test.ts | 142 ++ .../src/execution/__tests__/nonnull-test.ts | 2 +- .../src/execution/__tests__/stream-test.ts | 1873 +++++++++++++++++ .../src/execution/__tests__/subscribe-test.ts | 215 +- .../src/execution/__tests__/sync-test.ts | 18 + packages/executor/src/execution/execute.ts | 1151 ++++++++-- .../src/execution/flattenAsyncIterable.ts | 93 + packages/executor/src/execution/index.ts | 1 + packages/executor/src/execution/invariant.ts | 5 + .../src/execution/normalizedExecutor.ts | 57 + .../src/execution/promiseForObject.ts | 22 + packages/executor/src/execution/subscribe.ts | 224 -- .../loaders/url/tests/graphql-upload.spec.ts | 4 +- packages/loaders/url/tests/url-loader.spec.ts | 4 +- .../tests/resolvers-composition.spec.ts | 5 +- packages/schema/tests/schemaGenerator.test.ts | 4 +- .../stitch/src/getFieldsNotInSubschema.ts | 8 +- packages/stitch/src/stitchingInfo.ts | 12 +- packages/utils/src/AccumulatorMap.ts | 17 + packages/utils/src/Interfaces.ts | 7 +- packages/utils/src/collectFields.ts | 217 +- packages/utils/src/directives.ts | 53 + packages/utils/src/executor.ts | 2 +- packages/utils/src/index.ts | 1 + packages/utils/src/jsutils.ts | 2 +- packages/utils/src/visitResult.ts | 10 +- packages/utils/tests/AccumulatorMap-test.ts | 31 + .../transformFilterInputObjectFields.test.ts | 3 +- .../wrap/tests/transformMapLeafValues.test.ts | 5 +- .../transformRenameInputObjectFields.test.ts | 6 +- ...ransformRenameObjectFieldArguments.test.ts | 5 +- .../transformTransformCompositeFields.test.ts | 5 +- .../transformTransformEnumValues.test.ts | 5 +- yarn.lock | 11 +- 52 files changed, 4675 insertions(+), 526 deletions(-) create mode 100644 .changeset/@graphql-tools_executor-4796-dependencies.md create mode 100644 .changeset/chatty-worms-pull.md create mode 100644 .changeset/quick-clocks-beam.md create mode 100644 .changeset/spotty-maps-approve.md create mode 100644 .changeset/spotty-suns-bake.md create mode 100644 .changeset/warm-bags-camp.md create mode 100644 packages/executor/src/__testUtils__/expectEqualPromisesOrValues.ts create mode 100644 packages/executor/src/__testUtils__/expectPromise.ts create mode 100644 packages/executor/src/execution/__tests__/defer-test.ts create mode 100644 packages/executor/src/execution/__tests__/flattenAsyncIterable-test.ts create mode 100644 packages/executor/src/execution/__tests__/invariant-test.ts create mode 100644 packages/executor/src/execution/__tests__/stream-test.ts create mode 100644 packages/executor/src/execution/flattenAsyncIterable.ts create mode 100644 packages/executor/src/execution/invariant.ts create mode 100644 packages/executor/src/execution/normalizedExecutor.ts create mode 100644 packages/executor/src/execution/promiseForObject.ts delete mode 100644 packages/executor/src/execution/subscribe.ts create mode 100644 packages/utils/src/AccumulatorMap.ts create mode 100644 packages/utils/src/directives.ts create mode 100644 packages/utils/tests/AccumulatorMap-test.ts diff --git a/.changeset/@graphql-tools_executor-4796-dependencies.md b/.changeset/@graphql-tools_executor-4796-dependencies.md new file mode 100644 index 00000000000..8bf5d0c97c8 --- /dev/null +++ b/.changeset/@graphql-tools_executor-4796-dependencies.md @@ -0,0 +1,6 @@ +--- +'@graphql-tools/executor': patch +--- +dependencies updates: + - Added dependency [`@repeaterjs/repeater@3.0.4` ↗︎](https://www.npmjs.com/package/@repeaterjs/repeater/v/3.0.4) (to `dependencies`) + - Added dependency [`value-or-promise@1.0.1` ↗︎](https://www.npmjs.com/package/value-or-promise/v/1.0.1) (to `dependencies`) diff --git a/.changeset/chatty-worms-pull.md b/.changeset/chatty-worms-pull.md new file mode 100644 index 00000000000..5e3fab7d706 --- /dev/null +++ b/.changeset/chatty-worms-pull.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/utils': minor +--- + +add `@defer` directive diff --git a/.changeset/quick-clocks-beam.md b/.changeset/quick-clocks-beam.md new file mode 100644 index 00000000000..aae41f28eef --- /dev/null +++ b/.changeset/quick-clocks-beam.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/executor': patch +--- + +get defer stream from graphql-js diff --git a/.changeset/spotty-maps-approve.md b/.changeset/spotty-maps-approve.md new file mode 100644 index 00000000000..f76804a8ac3 --- /dev/null +++ b/.changeset/spotty-maps-approve.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/utils': minor +--- + +export collect field helpers diff --git a/.changeset/spotty-suns-bake.md b/.changeset/spotty-suns-bake.md new file mode 100644 index 00000000000..d8ed4898b6c --- /dev/null +++ b/.changeset/spotty-suns-bake.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/utils': minor +--- + +add `@stream` directive diff --git a/.changeset/warm-bags-camp.md b/.changeset/warm-bags-camp.md new file mode 100644 index 00000000000..290baa6a261 --- /dev/null +++ b/.changeset/warm-bags-camp.md @@ -0,0 +1,7 @@ +--- +'@graphql-tools/utils': major +'@graphql-tools/delegate': patch +'@graphql-tools/stitch': patch +--- + +update `collectFields` to support collecting deffered values diff --git a/packages/batch-delegate/tests/basic.example.test.ts b/packages/batch-delegate/tests/basic.example.test.ts index 8fe5827636d..fa30e35cf49 100644 --- a/packages/batch-delegate/tests/basic.example.test.ts +++ b/packages/batch-delegate/tests/basic.example.test.ts @@ -1,4 +1,4 @@ -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { OperationTypeNode, parse } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -89,6 +89,9 @@ describe('batch delegation within basic stitching example', () => { const result = await execute({ schema: stitchedSchema, document: parse(query) }); expect(numCalls).toEqual(1); + + if (isIncrementalResult(result)) throw Error('result is incremental'); + expect(result.errors).toBeUndefined(); const chirps: any = result.data!['trendingChirps']; expect(chirps[0].chirpedAtUser.email).not.toBe(null); @@ -182,6 +185,9 @@ describe('batch delegation within basic stitching example', () => { const result = await execute({ schema: stitchedSchema, document: parse(query) }); expect(numCalls).toEqual(1); + + if (isIncrementalResult(result)) throw Error('result is incremental'); + expect(result.data).toEqual({ users: [ { diff --git a/packages/batch-delegate/tests/withTransforms.test.ts b/packages/batch-delegate/tests/withTransforms.test.ts index c8e4122621a..754c5b238f9 100644 --- a/packages/batch-delegate/tests/withTransforms.test.ts +++ b/packages/batch-delegate/tests/withTransforms.test.ts @@ -1,4 +1,4 @@ -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { GraphQLList, GraphQLObjectType, Kind, OperationTypeNode, parse } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -121,6 +121,7 @@ describe('works with complex transforms', () => { `; const result = await execute({ schema: stitchedSchema, document: parse(query) }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeUndefined(); expect(result.data).toEqual({ diff --git a/packages/batch-execute/tests/batchExecute.test.ts b/packages/batch-execute/tests/batchExecute.test.ts index 70eef8032b3..1fa8dfadd38 100644 --- a/packages/batch-execute/tests/batchExecute.test.ts +++ b/packages/batch-execute/tests/batchExecute.test.ts @@ -2,7 +2,7 @@ import { parse, print, OperationDefinitionNode, validate } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { createBatchingExecutor } from '@graphql-tools/batch-execute'; import { ExecutionResult, Executor } from '@graphql-tools/utils'; -import { execute } from '@graphql-tools/executor'; +import { normalizedExecutor } from '@graphql-tools/executor'; describe('batch execution', () => { let executorCalls = 0; @@ -41,7 +41,7 @@ describe('batch execution', () => { if (errors.length > 0) { return { errors }; } - return execute({ + return normalizedExecutor({ schema, document, variableValues: executorVariables, diff --git a/packages/delegate/src/delegateToSchema.ts b/packages/delegate/src/delegateToSchema.ts index a124c3047ff..5e7605f34c3 100644 --- a/packages/delegate/src/delegateToSchema.ts +++ b/packages/delegate/src/delegateToSchema.ts @@ -36,7 +36,7 @@ import { Subschema } from './Subschema.js'; import { createRequest, getDelegatingOperation } from './createRequest.js'; import { Transformer } from './Transformer.js'; import { applySchemaTransforms } from './applySchemaTransforms.js'; -import { ExecutionArgs, execute, subscribe } from '@graphql-tools/executor'; +import { normalizedExecutor } from '@graphql-tools/executor'; export function delegateToSchema< TContext extends Record = Record, @@ -219,18 +219,13 @@ function getExecutor>( export const createDefaultExecutor = memoize1(function createDefaultExecutor(schema: GraphQLSchema): Executor { return function defaultExecutor(request: ExecutionRequest) { - const executionArgs: ExecutionArgs = { + return normalizedExecutor({ schema, document: request.document, rootValue: request.rootValue, contextValue: request.context, variableValues: request.variables, operationName: request.operationName, - }; - const operationType = request.operationType || getOperationASTFromRequest(request).operation; - if (operationType === 'subscription') { - return subscribe(executionArgs); - } - return execute(executionArgs); + }); }; }); diff --git a/packages/delegate/src/mergeFields.ts b/packages/delegate/src/mergeFields.ts index 7735cac7be0..a34bf912e2d 100644 --- a/packages/delegate/src/mergeFields.ts +++ b/packages/delegate/src/mergeFields.ts @@ -92,17 +92,9 @@ async function executeDelegationStage( source = error; } if (source instanceof Error || source == null) { - const fieldNodeResponseKeyMap = collectFields( - schema, - EMPTY_OBJECT, - EMPTY_OBJECT, - type, - selectionSet, - new Map(), - new Set() - ); + const { fields } = collectFields(schema, EMPTY_OBJECT, EMPTY_OBJECT, type, selectionSet); const nullResult = {}; - for (const [responseKey, fieldNodes] of fieldNodeResponseKeyMap) { + for (const [responseKey, fieldNodes] of fields) { const combinedPath = [...path, responseKey]; if (source instanceof GraphQLError) { nullResult[responseKey] = relocatedError(source, combinedPath); diff --git a/packages/executor/package.json b/packages/executor/package.json index 382ccf7bcfa..f882942565d 100644 --- a/packages/executor/package.json +++ b/packages/executor/package.json @@ -52,8 +52,10 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@repeaterjs/repeater": "3.0.4", "@graphql-tools/utils": "8.13.1", - "@graphql-typed-document-node/core": "3.1.1" + "@graphql-typed-document-node/core": "3.1.1", + "value-or-promise": "1.0.1" }, "devDependencies": { "graphql": "^16.6.0", diff --git a/packages/executor/src/__testUtils__/expectEqualPromisesOrValues.ts b/packages/executor/src/__testUtils__/expectEqualPromisesOrValues.ts new file mode 100644 index 00000000000..516c37bbd5e --- /dev/null +++ b/packages/executor/src/__testUtils__/expectEqualPromisesOrValues.ts @@ -0,0 +1,22 @@ +import { isPromise, MaybePromise } from '@graphql-tools/utils'; +import { expectJSON } from './expectJSON.js'; + +export function expectMatchingValues(values: ReadonlyArray): T { + const [firstValue, ...remainingValues] = values; + for (const value of remainingValues) { + expectJSON(value).toDeepEqual(firstValue); + } + return firstValue; +} + +export function expectEqualPromisesOrValues(items: ReadonlyArray>): MaybePromise { + const [firstItem, ...remainingItems] = items; + if (isPromise(firstItem)) { + if (remainingItems.every(isPromise)) { + return Promise.all(items).then(expectMatchingValues); + } + } else if (remainingItems.every(item => !isPromise(item))) { + return expectMatchingValues(items); + } + throw new Error('Cannot compare promises and values'); +} diff --git a/packages/executor/src/__testUtils__/expectPromise.ts b/packages/executor/src/__testUtils__/expectPromise.ts new file mode 100644 index 00000000000..9dfd6970bea --- /dev/null +++ b/packages/executor/src/__testUtils__/expectPromise.ts @@ -0,0 +1,22 @@ +import { isPromise } from '@graphql-tools/utils'; + +export function expectPromise(maybePromise: unknown) { + expect(isPromise(maybePromise)).toBeTruthy(); + + return { + toResolve() { + return maybePromise; + }, + async toRejectWith(message: string) { + let caughtError: Error | undefined; + try { + await maybePromise; + } catch (error) { + caughtError = error as Error; + } + + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError).toHaveProperty('message', message); + }, + }; +} diff --git a/packages/executor/src/execution/__tests__/defer-test.ts b/packages/executor/src/execution/__tests__/defer-test.ts new file mode 100644 index 00000000000..bff9036f2d4 --- /dev/null +++ b/packages/executor/src/execution/__tests__/defer-test.ts @@ -0,0 +1,700 @@ +import { + DocumentNode, + parse, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLID, + GraphQLString, + GraphQLSchema, +} from 'graphql'; +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import type { InitialIncrementalExecutionResult, SubsequentIncrementalExecutionResult } from '../execute.js'; +import { execute } from '../execute.js'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + promiseNonNullErrorField: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => Promise.resolve(null), + }, + }, + name: 'Friend', +}); + +const friends = [ + { name: 'Han', id: 2 }, + { name: 'Leia', id: 3 }, + { name: 'C-3PO', id: 4 }, +]; + +const heroType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + slowField: { + type: GraphQLString, + resolve: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }, + errorField: { + type: GraphQLString, + resolve: () => { + throw new Error('bad'); + }, + }, + nonNullErrorField: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => null, + }, + promiseNonNullErrorField: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => Promise.resolve(null), + }, + friends: { + type: new GraphQLList(friendType), + resolve: () => friends, + }, + asyncFriends: { + type: new GraphQLList(friendType), + async *resolve() { + yield await Promise.resolve(friends[0]); + }, + }, + }, + name: 'Hero', +}); + +const hero = { name: 'Luke', id: 1 }; + +const query = new GraphQLObjectType({ + fields: { + hero: { + type: heroType, + resolve: () => hero, + }, + }, + name: 'Query', +}); + +const schema = new GraphQLSchema({ query }); + +async function complete(document: DocumentNode) { + const result = await execute({ + schema, + document, + rootValue: {}, + }); + + if ('initialResult' in result) { + const results: Array = [ + result.initialResult, + ]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; + } + return result; +} + +describe('Execute: defer directive', () => { + it('Can defer fragments containing scalar types', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + id + name + } + `); + const result = await complete(document); + + expect(result).toEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + id: '1', + name: 'Luke', + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can disable defer using if argument', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(if: false) + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual({ + data: { + hero: { + id: '1', + name: 'Luke', + }, + }, + }); + }); + + it('Does not disable defer with null if argument', async () => { + const document = parse(` + query HeroNameQuery($shouldDefer: Boolean) { + hero { + id + ...NameFragment @defer(if: $shouldDefer) + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: { name: 'Luke' }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can defer fragments on the top level Query field', async () => { + const document = parse(` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + id + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + id: '1', + }, + }, + path: [], + label: 'DeferQuery', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can defer fragments with errors on the top level Query field', async () => { + const document = parse(` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + errorField + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + errorField: null, + }, + }, + errors: [ + { + message: 'bad', + locations: [{ line: 7, column: 11 }], + path: ['hero', 'errorField'], + }, + ], + path: [], + label: 'DeferQuery', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can defer a fragment within an already deferred fragment', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...TopFragment @defer(label: "DeferTop") + } + } + fragment TopFragment on Hero { + name + ...NestedFragment @defer(label: "DeferNested") + } + fragment NestedFragment on Hero { + friends { + name + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + }, + path: ['hero'], + label: 'DeferNested', + }, + { + data: { + name: 'Luke', + }, + path: ['hero'], + label: 'DeferTop', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can defer a fragment that is also not deferred, deferred fragment is first', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...TopFragment @defer(label: "DeferTop") + ...TopFragment + } + } + fragment TopFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + name: 'Luke', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + path: ['hero'], + label: 'DeferTop', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can defer a fragment that is also not deferred, non-deferred fragment is first', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...TopFragment + ...TopFragment @defer(label: "DeferTop") + } + } + fragment TopFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + name: 'Luke', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + path: ['hero'], + label: 'DeferTop', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can defer an inline fragment', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ... on Hero @defer(label: "InlineDeferred") { + name + } + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [{ data: { name: 'Luke' }, path: ['hero'], label: 'InlineDeferred' }], + hasNext: false, + }, + ]); + }); + + it('Handles errors thrown in deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + errorField + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: { errorField: null }, + path: ['hero'], + errors: [ + { + message: 'bad', + locations: [{ line: 9, column: 9 }], + path: ['hero', 'errorField'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles non-nullable errors thrown in deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullErrorField + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: null, + path: ['hero'], + errors: [ + { + message: 'Cannot return null for non-nullable field Hero.nonNullErrorField.', + locations: [{ line: 9, column: 9 }], + path: ['hero', 'nonNullErrorField'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles non-nullable errors thrown outside deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + nonNullErrorField + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + id + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Cannot return null for non-nullable field Hero.nonNullErrorField.', + locations: [ + { + line: 4, + column: 11, + }, + ], + path: ['hero', 'nonNullErrorField'], + }, + ], + data: { + hero: null, + }, + }); + }); + + it('Handles async non-nullable errors thrown in deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + promiseNonNullErrorField + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: null, + path: ['hero'], + errors: [ + { + message: 'Cannot return null for non-nullable field Hero.promiseNonNullErrorField.', + locations: [{ line: 9, column: 9 }], + path: ['hero', 'promiseNonNullErrorField'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Returns payloads in correct order', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + slowField + friends { + ...NestedFragment @defer + } + } + fragment NestedFragment on Friend { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { slowField: 'slow', friends: [{}, {}, {}] }, + path: ['hero'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { data: { name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: { name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: { name: 'C-3PO' }, path: ['hero', 'friends', 2] }, + ], + hasNext: false, + }, + ]); + }); + + it('Returns payloads from synchronous data in correct order', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + friends { + ...NestedFragment @defer + } + } + fragment NestedFragment on Friend { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + friends: [{}, {}, {}], + }, + path: ['hero'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { data: { name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: { name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: { name: 'C-3PO' }, path: ['hero', 'friends', 2] }, + ], + hasNext: false, + }, + ]); + }); + + it('Filters deferred payloads when a list item returned by an async iterable is nulled', async () => { + const document = parse(` + query { + hero { + asyncFriends { + promiseNonNullErrorField + ...NameFragment @defer + } + } + } + fragment NameFragment on Friend { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual({ + data: { + hero: { + asyncFriends: [null], + }, + }, + errors: [ + { + message: 'Cannot return null for non-nullable field Friend.promiseNonNullErrorField.', + locations: [{ line: 5, column: 11 }], + path: ['hero', 'asyncFriends', 0, 'promiseNonNullErrorField'], + }, + ], + }); + }); +}); diff --git a/packages/executor/src/execution/__tests__/flattenAsyncIterable-test.ts b/packages/executor/src/execution/__tests__/flattenAsyncIterable-test.ts new file mode 100644 index 00000000000..1744804d291 --- /dev/null +++ b/packages/executor/src/execution/__tests__/flattenAsyncIterable-test.ts @@ -0,0 +1,146 @@ +import { flattenAsyncIterable } from '../flattenAsyncIterable.js'; + +describe('flattenAsyncIterable', () => { + it('flatten nested async generators', async () => { + async function* source() { + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(1.1); + yield await Promise.resolve(1.2); + })() + ); + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(2.1); + yield await Promise.resolve(2.2); + })() + ); + } + + const doubles = flattenAsyncIterable(source()); + + const result = []; + for await (const x of doubles) { + result.push(x); + } + expect(result).toEqual([1.1, 1.2, 2.1, 2.2]); + }); + + it('allows returning early from a nested async generator', async () => { + async function* source() { + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(1.1); + yield await Promise.resolve(1.2); + })() + ); + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(2.1); /* c8 ignore start */ + // Not reachable, early return + yield await Promise.resolve(2.2); + })() + ); + // Not reachable, early return + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(3.1); + yield await Promise.resolve(3.2); + })() + ); + } + /* c8 ignore stop */ + + const doubles = flattenAsyncIterable(source()); + + expect(await doubles.next()).toEqual({ value: 1.1, done: false }); + expect(await doubles.next()).toEqual({ value: 1.2, done: false }); + expect(await doubles.next()).toEqual({ value: 2.1, done: false }); + + // Early return + expect(await doubles.return()).toEqual({ + value: undefined, + done: true, + }); + + // Subsequent next calls + expect(await doubles.next()).toEqual({ + value: undefined, + done: true, + }); + expect(await doubles.next()).toEqual({ + value: undefined, + done: true, + }); + }); + + it('allows throwing errors from a nested async generator', async () => { + async function* source() { + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(1.1); + yield await Promise.resolve(1.2); + })() + ); + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(2.1); /* c8 ignore start */ + // Not reachable, early return + yield await Promise.resolve(2.2); + })() + ); + // Not reachable, early return + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(3.1); + yield await Promise.resolve(3.2); + })() + ); + } + /* c8 ignore stop */ + + const doubles = flattenAsyncIterable(source()); + + expect(await doubles.next()).toEqual({ value: 1.1, done: false }); + expect(await doubles.next()).toEqual({ value: 1.2, done: false }); + expect(await doubles.next()).toEqual({ value: 2.1, done: false }); + + // Throw error + let caughtError; + try { + await doubles.throw('ouch'); /* c8 ignore start */ + } catch (e) { + caughtError = e; + } + expect(caughtError).toEqual('ouch'); + }); + it('completely yields sub-iterables even when next() called in parallel', async () => { + async function* source() { + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(1.1); + yield await Promise.resolve(1.2); + })() + ); + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(2.1); + yield await Promise.resolve(2.2); + })() + ); + } + + const result = flattenAsyncIterable(source()); + + const promise1 = result.next(); + const promise2 = result.next(); + expect(await promise1).toEqual({ value: 1.1, done: false }); + expect(await promise2).toEqual({ value: 1.2, done: false }); + expect(await result.next()).toEqual({ value: 2.1, done: false }); + expect(await result.next()).toEqual({ value: 2.2, done: false }); + expect(await result.next()).toEqual({ + value: undefined, + done: true, + }); + }); +}); diff --git a/packages/executor/src/execution/__tests__/invariant-test.ts b/packages/executor/src/execution/__tests__/invariant-test.ts new file mode 100644 index 00000000000..6bddc4fab65 --- /dev/null +++ b/packages/executor/src/execution/__tests__/invariant-test.ts @@ -0,0 +1,11 @@ +import { invariant } from '../invariant.js'; + +describe('invariant', () => { + it('throws on false conditions', () => { + expect(() => invariant(false, 'Oops!')).toThrow('Oops!'); + }); + + it('use default error message', () => { + expect(() => invariant(false)).toThrow('Unexpected invariant triggered.'); + }); +}); diff --git a/packages/executor/src/execution/__tests__/lists-test.ts b/packages/executor/src/execution/__tests__/lists-test.ts index 67ca77eaa6b..4d83805786d 100644 --- a/packages/executor/src/execution/__tests__/lists-test.ts +++ b/packages/executor/src/execution/__tests__/lists-test.ts @@ -1,4 +1,3 @@ -import { ExecutionResult, MaybePromise } from '@graphql-tools/utils'; import { parse, buildSchema, @@ -76,9 +75,7 @@ describe('Execute: Accepts async iterables as list value', () => { }); } - function completeObjectList( - resolve: GraphQLFieldResolver<{ index: number }, unknown> - ): MaybePromise { + function completeObjectList(resolve: GraphQLFieldResolver<{ index: number }, unknown>) { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', diff --git a/packages/executor/src/execution/__tests__/mutations-test.ts b/packages/executor/src/execution/__tests__/mutations-test.ts index e8f103a3fb8..2a43216e331 100644 --- a/packages/executor/src/execution/__tests__/mutations-test.ts +++ b/packages/executor/src/execution/__tests__/mutations-test.ts @@ -41,6 +41,13 @@ class Root { const numberHolderType = new GraphQLObjectType({ fields: { theNumber: { type: GraphQLInt }, + promiseToGetTheNumber: { + type: GraphQLInt, + resolve: async root => { + await new Promise(resolve => setTimeout(resolve, 0)); + return root.theNumber; + }, + }, }, name: 'NumberHolder', }); @@ -182,4 +189,139 @@ describe('Execute: Handles mutation execution ordering', () => { ], }); }); + + it('Mutation fields with @defer do not block next mutation', async () => { + const document = parse(` + mutation M { + first: promiseToChangeTheNumber(newNumber: 1) { + ...DeferFragment @defer(label: "defer-label") + }, + second: immediatelyChangeTheNumber(newNumber: 2) { + theNumber + } + } + fragment DeferFragment on NumberHolder { + promiseToGetTheNumber + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ + schema, + document, + rootValue, + }); + const patches = []; + + expect('initialResult' in mutationResult).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in mutationResult then it should work fine + patches.push(mutationResult.initialResult); + // @ts-expect-error once we assert that initialResult is in mutationResult then it should work fine + for await (const patch of mutationResult.subsequentResults) { + patches.push(patch); + } + + expectJSON(patches).toDeepEqual([ + { + data: { + first: {}, + second: { theNumber: 2 }, + }, + hasNext: true, + }, + { + incremental: [ + { + label: 'defer-label', + path: ['first'], + data: { + promiseToGetTheNumber: 2, + }, + }, + ], + hasNext: false, + }, + ]); + }); + + it('Mutation inside of a fragment', async () => { + const document = parse(` + mutation M { + ...MutationFragment + second: immediatelyChangeTheNumber(newNumber: 2) { + theNumber + } + } + fragment MutationFragment on Mutation { + first: promiseToChangeTheNumber(newNumber: 1) { + theNumber + }, + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ schema, document, rootValue }); + + expectJSON(mutationResult).toDeepEqual({ + data: { + first: { theNumber: 1 }, + second: { theNumber: 2 }, + }, + }); + }); + + it('Mutation with @defer is not executed serially', async () => { + const document = parse(` + mutation M { + ...MutationFragment @defer(label: "defer-label") + second: immediatelyChangeTheNumber(newNumber: 2) { + theNumber + } + } + fragment MutationFragment on Mutation { + first: promiseToChangeTheNumber(newNumber: 1) { + theNumber + }, + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ + schema, + document, + rootValue, + }); + const patches = []; + + expect('initialResult' in mutationResult).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in mutationResult then it should work fine + patches.push(mutationResult.initialResult); + // @ts-expect-error once we assert that initialResult is in mutationResult then it should work fine + for await (const patch of mutationResult.subsequentResults) { + patches.push(patch); + } + + expect(patches).toEqual([ + { + data: { + second: { theNumber: 2 }, + }, + hasNext: true, + }, + { + incremental: [ + { + label: 'defer-label', + path: [], + data: { + first: { + theNumber: 1, + }, + }, + }, + ], + hasNext: false, + }, + ]); + }); }); diff --git a/packages/executor/src/execution/__tests__/nonnull-test.ts b/packages/executor/src/execution/__tests__/nonnull-test.ts index d5d05d892ce..f70e71dec04 100644 --- a/packages/executor/src/execution/__tests__/nonnull-test.ts +++ b/packages/executor/src/execution/__tests__/nonnull-test.ts @@ -95,7 +95,7 @@ const schema = buildSchema(` } `); -function executeQuery(query: string, rootValue: unknown): ExecutionResult | Promise { +function executeQuery(query: string, rootValue: unknown) { return execute({ schema, document: parse(query), rootValue }); } diff --git a/packages/executor/src/execution/__tests__/stream-test.ts b/packages/executor/src/execution/__tests__/stream-test.ts new file mode 100644 index 00000000000..d1308d3d486 --- /dev/null +++ b/packages/executor/src/execution/__tests__/stream-test.ts @@ -0,0 +1,1873 @@ +import { MaybePromise } from '@graphql-tools/utils'; +import { + DocumentNode, + parse, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLID, + GraphQLString, + GraphQLSchema, +} from 'graphql'; +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import type { InitialIncrementalExecutionResult, SubsequentIncrementalExecutionResult } from '../execute.js'; +import { execute } from '../execute.js'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: 'Friend', +}); + +const friends = [ + { name: 'Luke', id: 1 }, + { name: 'Han', id: 2 }, + { name: 'Leia', id: 3 }, +]; + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: 'NestedObject', + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: 'DeeperNestedObject', + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: 'Query', +}); + +const schema = new GraphQLSchema({ query }); + +async function complete(document: DocumentNode, rootValue: unknown = {}) { + const result = await execute({ + schema, + document, + rootValue, + }); + + if ('initialResult' in result) { + const results: Array = [ + result.initialResult, + ]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; + } + return result; +} + +async function completeAsync(document: DocumentNode, numCalls: number, rootValue: unknown = {}) { + const result = await execute({ + schema, + document, + rootValue, + }); + + expect('initialResult' in result).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const iterator = result.subsequentResults[Symbol.asyncIterator](); + + const promises: Array< + MaybePromise> + // @ts-expect-error once we assert that initialResult is in result then it should work fine + > = [{ done: false, value: result.initialResult }]; + for (let i = 0; i < numCalls; i++) { + promises.push(iterator.next()); + } + return Promise.all(promises); +} + +function createResolvablePromise(): [Promise, (value?: T) => void] { + let resolveFn; + const promise = new Promise(resolve => { + resolveFn = resolve; + }); + return [promise, resolveFn as unknown as (value?: T) => void]; +} + +describe('Execute: stream directive', () => { + it('Can stream a list field', async () => { + const document = parse('{ scalarList @stream(initialCount: 1) }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expect(result).toEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + incremental: [{ items: ['banana'], path: ['scalarList', 1] }], + hasNext: true, + }, + { + incremental: [{ items: ['coconut'], path: ['scalarList', 2] }], + hasNext: false, + }, + ]); + }); + + it('Can use default value of initialCount', async () => { + const document = parse('{ scalarList @stream }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expect(result).toEqual([ + { + data: { + scalarList: [], + }, + hasNext: true, + }, + { + incremental: [{ items: ['apple'], path: ['scalarList', 0] }], + hasNext: true, + }, + { + incremental: [{ items: ['banana'], path: ['scalarList', 1] }], + hasNext: true, + }, + { + incremental: [{ items: ['coconut'], path: ['scalarList', 2] }], + hasNext: false, + }, + ]); + }); + + it('Negative values of initialCount throw field errors', async () => { + const document = parse('{ scalarList @stream(initialCount: -2) }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'initialCount must be a positive integer', + locations: [ + { + line: 1, + column: 3, + }, + ], + path: ['scalarList'], + }, + ], + data: { + scalarList: null, + }, + }); + }); + + it('Returns label from stream directive', async () => { + const document = parse('{ scalarList @stream(initialCount: 1, label: "scalar-stream") }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + incremental: [ + { + items: ['banana'], + path: ['scalarList', 1], + label: 'scalar-stream', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: ['coconut'], + path: ['scalarList', 2], + label: 'scalar-stream', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can disable @stream using if argument', async () => { + const document = parse('{ scalarList @stream(initialCount: 0, if: false) }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual({ + data: { scalarList: ['apple', 'banana', 'coconut'] }, + }); + }); + + it('Does not disable stream with null if argument', async () => { + const document = parse('query ($shouldStream: Boolean) { scalarList @stream(initialCount: 2, if: $shouldStream) }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { scalarList: ['apple', 'banana'] }, + hasNext: true, + }, + { + incremental: [{ items: ['coconut'], path: ['scalarList', 2] }], + hasNext: false, + }, + ]); + }); + + it('Can stream multi-dimensional lists', async () => { + const document = parse('{ scalarListList @stream(initialCount: 1) }'); + const result = await complete(document, { + scalarListList: () => [ + ['apple', 'apple', 'apple'], + ['banana', 'banana', 'banana'], + ['coconut', 'coconut', 'coconut'], + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarListList: [['apple', 'apple', 'apple']], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [['banana', 'banana', 'banana']], + path: ['scalarListList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [['coconut', 'coconut', 'coconut']], + path: ['scalarListList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can stream a field that returns a list of promises', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => friends.map(f => Promise.resolve(f)), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can stream in correct order with lists of promises', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => friends.map(f => Promise.resolve(f)), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Luke', id: '1' }], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Han', id: '2' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles rejections in a field that returns a list of promises before initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(f); + }), + }); + expectJSON(result).toDeepEqual([ + { + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + data: { + friendList: [{ name: 'Luke', id: '1' }, null], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles rejections in a field that returns a list of promises after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(f); + }), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke', id: '1' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can stream a field that returns an async iterable', async () => { + const document = parse(` + query { + friendList @stream { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Luke', id: '1' }], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Han', id: '2' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Can stream a field that returns an async iterable, using a non-zero initialCount', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [ + { name: 'Luke', id: '1' }, + { name: 'Han', id: '2' }, + ], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Negative values of initialCount throw field errors on a field that returns an async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: -2) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() {}, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'initialCount must be a positive integer', + locations: [{ line: 3, column: 9 }], + path: ['friendList'], + }, + ], + data: { + friendList: null, + }, + }); + }); + + it('Can handle concurrent calls to .next() without waiting', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await completeAsync(document, 3, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + expectJSON(result).toDeepEqual([ + { + done: false, + value: { + data: { + friendList: [ + { name: 'Luke', id: '1' }, + { name: 'Han', id: '2' }, + ], + }, + hasNext: true, + }, + }, + { + done: false, + value: { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + }, + { done: false, value: { hasNext: false } }, + { done: true, value: undefined }, + ]); + }); + + it('Handles error thrown in async iterable before initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error('bad'); + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + data: { + friendList: [{ name: 'Luke', id: '1' }, null], + }, + }); + }); + + it('Handles error thrown in async iterable after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error('bad'); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke', id: '1' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles null returned in non-null list items after initialCount is reached', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [friends[0], null], + }); + + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', + locations: [{ line: 3, column: 9 }], + path: ['nonNullFriendList', 1], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles null returned in non-null async iterable list items after initialCount is reached', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document, { + async *nonNullFriendList() { + try { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(null); /* c8 ignore start */ + // Not reachable, early return + } finally { + /* c8 ignore stop */ + // eslint-disable-next-line no-unsafe-finally + throw new Error('Oops'); + } + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', + locations: [{ line: 3, column: 9 }], + path: ['nonNullFriendList', 1], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + scalarList @stream(initialCount: 1) + } + `); + const result = await complete(document, { + scalarList: () => [friends[0].name, {}], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['Luke'], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['scalarList', 1], + errors: [ + { + message: 'String cannot represent value: {}', + locations: [{ line: 3, column: 9 }], + path: ['scalarList', 1], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles async errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + friendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles async errors thrown by completeValue after initialCount is reached from async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + async *nonNullFriendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }); + yield await Promise.resolve({ + nonNullName: friends[1].name, + }); /* c8 ignore start */ + } /* c8 ignore stop */, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Filters payloads that are nulled', async () => { + const document = parse(` + query { + nestedObject { + nonNullScalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `); + const result = await complete(document, { + nestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', + locations: [{ line: 4, column: 11 }], + path: ['nestedObject', 'nonNullScalarField'], + }, + ], + data: { + nestedObject: null, + }, + }); + }); + + it('Filters payloads that are nulled by a later synchronous error', async () => { + const document = parse(` + query { + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + nonNullScalarField + } + } + `); + const result = await complete(document, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + nonNullScalarField: () => null, + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', + locations: [{ line: 7, column: 11 }], + path: ['nestedObject', 'nonNullScalarField'], + }, + ], + data: { + nestedObject: null, + }, + }); + }); + + it('Does not filter payloads when null error is in a different path', async () => { + const document = parse(` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `); + const result = await complete(document, { + nestedObject: { + scalarField: () => Promise.reject(new Error('Oops')), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { scalarField: null }, + path: ['otherNestedObject'], + errors: [ + { + message: 'Oops', + locations: [{ line: 5, column: 13 }], + path: ['otherNestedObject', 'scalarField'], + }, + ], + }, + { + items: [{ name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Filters stream payloads that are nulled in a deferred payload', async () => { + const document = parse(` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `); + const result = await complete(document, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nestedObject: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + deeperNestedObject: null, + }, + path: ['nestedObject'], + errors: [ + { + message: 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', + locations: [{ line: 6, column: 15 }], + path: ['nestedObject', 'deeperNestedObject', 'nonNullScalarField'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Filters defer payloads that are nulled in a stream response', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + path: ['friendList', 0], + errors: [ + { + message: 'Cannot return null for non-nullable field Friend.nonNullName.', + locations: [{ line: 4, column: 9 }], + path: ['friendList', 0, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Returns iterator and ignores errors when stream payloads are filtered', async () => { + let returned = false; + let requested = false; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (requested) { + /* c8 ignore next 3 */ + // Not reached, iterator should end immediately. + expect('Not reached').toBeFalsy(); + } + requested = true; + const friend = friends[0]; + return Promise.resolve({ + done: false, + value: { + name: friend.name, + nonNullName: null, + }, + }); + }, + return: () => { + returned = true; + return Promise.reject(new Error('Oops')); + }, + }), + }; + + const document = parse(` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `); + + const executeResult = await execute({ + schema, + document, + rootValue: { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + deeperNestedFriendList: iterable, + }, + }, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + nestedObject: {}, + }, + hasNext: true, + }); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: false, + value: { + incremental: [ + { + data: { + deeperNestedObject: null, + }, + path: ['nestedObject'], + errors: [ + { + message: 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', + locations: [{ line: 6, column: 15 }], + path: ['nestedObject', 'deeperNestedObject', 'nonNullScalarField'], + }, + ], + }, + ], + hasNext: false, + }, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ done: true, value: undefined }); + + expect(returned).toBeTruthy(); + }); + + it('Handles promises returned by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ id: '1', name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2', name: 'Han' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3', name: 'Leia' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Returns payloads in correct order when parent deferred fragment resolves slower than stream', async () => { + const [slowFieldPromise, resolveSlowField] = createResolvablePromise(); + const document = parse(` + query { + nestedObject { + ... DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `); + const executeResult = await execute({ + schema, + document, + rootValue: { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + nestedObject: {}, + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + resolveSlowField('slow'); + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { scalarField: 'slow', nestedFriendList: [] }, + path: ['nestedObject'], + }, + ], + hasNext: true, + }, + done: false, + }); + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + items: [{ name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + ], + hasNext: true, + }, + done: false, + }); + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: { + incremental: [ + { + items: [{ name: 'Han' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + ], + hasNext: true, + }, + done: false, + }); + const result5 = await iterator.next(); + expectJSON(result5).toDeepEqual({ + value: { hasNext: false }, + done: false, + }); + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ + value: undefined, + done: true, + }); + }); + + it('Can @defer fields that are resolved after async iterable is complete', async () => { + const [slowFieldPromise, resolveSlowField] = createResolvablePromise(); + const [iterableCompletionPromise, resolveIterableCompletion] = createResolvablePromise(); + + const document = parse(` + query { + friendList @stream(initialCount: 1, label:"stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `); + + const executeResult = await execute({ + schema, + document, + rootValue: { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [{ id: '1' }], + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + resolveIterableCompletion(); + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Luke' }, + path: ['friendList', 0], + label: 'DeferName', + }, + { + items: [{ id: '2' }], + path: ['friendList', 1], + label: 'stream-label', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result3Promise = iterator.next(); + resolveSlowField('Han'); + const result3 = await result3Promise; + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Han' }, + path: ['friendList', 1], + label: 'DeferName', + }, + ], + hasNext: false, + }, + done: false, + }); + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: undefined, + done: true, + }); + }); + + it('Can @defer fields that are resolved before async iterable is complete', async () => { + const [slowFieldPromise, resolveSlowField] = createResolvablePromise(); + const [iterableCompletionPromise, resolveIterableCompletion] = createResolvablePromise(); + + const document = parse(` + query { + friendList @stream(initialCount: 1, label:"stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `); + + const executeResult = await execute({ + schema, + document, + rootValue: { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in executeResult then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + // @ts-ignore once we assert that initialResult is in executeResult then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [{ id: '1' }], + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + resolveSlowField('Han'); + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Luke' }, + path: ['friendList', 0], + label: 'DeferName', + }, + { + items: [{ id: '2' }], + path: ['friendList', 1], + label: 'stream-label', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Han' }, + path: ['friendList', 1], + label: 'DeferName', + }, + ], + hasNext: true, + }, + done: false, + }); + const result4Promise = iterator.next(); + resolveIterableCompletion(); + const result4 = await result4Promise; + expectJSON(result4).toDeepEqual({ + value: { hasNext: false }, + done: false, + }); + + const result5 = await iterator.next(); + expectJSON(result5).toDeepEqual({ + value: undefined, + done: true, + }); + }); + + it('Returns underlying async iterables when returned generator is returned', async () => { + let returned = false; + let index = 0; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + const friend = friends[index++]; + if (!friend) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + return: () => { + returned = true; + }, + }), + }; + + const document = parse(` + query { + friendList @stream(initialCount: 1) { + id + ... @defer { + name + } + } + } + `); + + const executeResult = await execute({ + schema, + document, + rootValue: { + friendList: iterable, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [ + { + id: '1', + }, + ], + }, + hasNext: true, + }); + const returnPromise = iterator.return(); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: true, + value: undefined, + }); + await returnPromise; + expect(returned).toBeTruthy(); + }); + + it('Can return async iterable when underlying iterable does not have a return method', async () => { + let index = 0; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + const friend = friends[index++]; + if (!friend) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + }), + }; + + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + + const executeResult = await execute({ + schema, + document, + rootValue: { + friendList: iterable, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }); + + const returnPromise = iterator.return(); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: true, + value: undefined, + }); + await returnPromise; + }); + + it('Returns underlying async iterables when returned generator is thrown', async () => { + let index = 0; + let returned = false; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + const friend = friends[index++]; + if (!friend) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + return: () => { + returned = true; + }, + }), + }; + const document = parse(` + query { + friendList @stream(initialCount: 1) { + ... @defer { + name + } + id + } + } + `); + + const executeResult = await execute({ + schema, + document, + rootValue: { + friendList: iterable, + }, + }); + expect('initialResult' in executeResult).toBeTruthy(); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + // @ts-expect-error once we assert that initialResult is in result then it should work fine + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [ + { + id: '1', + }, + ], + }, + hasNext: true, + }); + + const throwPromise = iterator.throw(new Error('bad')); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: true, + value: undefined, + }); + try { + await throwPromise; /* c8 ignore start */ + // Not reachable, always throws + /* c8 ignore stop */ + } catch (e) { + // ignore error + } + expect(returned).toBeTruthy(); + }); +}); diff --git a/packages/executor/src/execution/__tests__/subscribe-test.ts b/packages/executor/src/execution/__tests__/subscribe-test.ts index 1e98576b68f..5551c2f619d 100644 --- a/packages/executor/src/execution/__tests__/subscribe-test.ts +++ b/packages/executor/src/execution/__tests__/subscribe-test.ts @@ -11,8 +11,7 @@ import { GraphQLSchema, } from 'graphql'; -import type { ExecutionArgs } from '../execute.js'; -import { createSourceEventStream, subscribe } from '../execute.js'; +import { ExecutionArgs, createSourceEventStream, subscribe } from '../execute.js'; import { SimplePubSub } from './simplePubSub.js'; import { ExecutionResult, isAsyncIterable, isPromise, MaybePromise } from '@graphql-tools/utils'; @@ -29,6 +28,10 @@ const EmailType = new GraphQLObjectType({ fields: { from: { type: GraphQLString }, subject: { type: GraphQLString }, + asyncSubject: { + type: GraphQLString, + resolve: email => Promise.resolve(email.subject), + }, message: { type: GraphQLString }, unread: { type: GraphQLBoolean }, }, @@ -79,17 +82,22 @@ const emailSchema = new GraphQLSchema({ }), }); -function createSubscription(pubsub: SimplePubSub) { +function createSubscription(pubsub: SimplePubSub, variableValues?: { readonly [variable: string]: unknown }) { const document = parse(` - subscription ($priority: Int = 0) { + subscription ($priority: Int = 0, $shouldDefer: Boolean = false, $asyncResolver: Boolean = false) { importantEmail(priority: $priority) { email { from subject + ... @include(if: $asyncResolver) { + asyncSubject + } } - inbox { - unread - total + ... @defer(if: $shouldDefer) { + inbox { + unread + total + } } } } @@ -119,7 +127,12 @@ function createSubscription(pubsub: SimplePubSub) { }), }; - return subscribe({ schema: emailSchema, document, rootValue: data }); + return subscribe({ + schema: emailSchema, + document, + rootValue: data, + variableValues, + }); } // TODO: consider adding this method to testUtils (with tests) @@ -577,6 +590,47 @@ describe('Subscription Publish Phase', () => { expect(await payload2).toEqual(expectedPayload); }); + it('produces a payload when queried fields are async', async () => { + const pubsub = new SimplePubSub(); + + const subscription = createSubscription(pubsub, { asyncResolver: true }); + expect(isAsyncIterable(subscription)).toBeTruthy(); + + expect( + pubsub.emit({ + from: 'yuzhi@graphql.org', + subject: 'Alright', + message: 'Tests are good', + unread: true, + }) + ).toBeTruthy(); + + // @ts-expect-error we have asserted it is an AsyncIterable + expectJSON(await subscription.next()).toDeepEqual({ + done: false, + value: { + data: { + importantEmail: { + email: { + from: 'yuzhi@graphql.org', + subject: 'Alright', + asyncSubject: 'Alright', + }, + inbox: { + unread: 1, + total: 2, + }, + }, + }, + }, + }); + // @ts-expect-error we have asserted it is an AsyncIterable + expectJSON(await subscription.return()).toDeepEqual({ + done: true, + value: undefined, + }); + }); + it('produces a payload per subscription event', async () => { const pubsub = new SimplePubSub(); const subscription = createSubscription(pubsub); @@ -670,6 +724,151 @@ describe('Subscription Publish Phase', () => { }); }); + it('produces additional payloads for subscriptions with @defer', async () => { + const pubsub = new SimplePubSub(); + const subscription = await createSubscription(pubsub, { + shouldDefer: true, + }); + expect(isAsyncIterable(subscription)).toBeTruthy(); + // Wait for the next subscription payload. + // @ts-expect-error we have asserted it is an async iterable + const payload = subscription.next(); + + // A new email arrives! + expect( + pubsub.emit({ + from: 'yuzhi@graphql.org', + subject: 'Alright', + message: 'Tests are good', + unread: true, + }) + ).toBeTruthy(); + + // The previously waited on payload now has a value. + expectJSON(await payload).toDeepEqual({ + done: false, + value: { + data: { + importantEmail: { + email: { + from: 'yuzhi@graphql.org', + subject: 'Alright', + }, + }, + }, + hasNext: true, + }, + }); + + // Wait for the next payload from @defer + // @ts-expect-error we have asserted it is an async iterable + expectJSON(await subscription.next()).toDeepEqual({ + done: false, + value: { + incremental: [ + { + data: { + inbox: { + unread: 1, + total: 2, + }, + }, + path: ['importantEmail'], + }, + ], + hasNext: false, + }, + }); + + // Another new email arrives, after all incrementally delivered payloads are received. + expect( + pubsub.emit({ + from: 'hyo@graphql.org', + subject: 'Tools', + message: 'I <3 making things', + unread: true, + }) + ).toBeTruthy(); + + // The next waited on payload will have a value. + // @ts-expect-error we have asserted it is an async iterable + expectJSON(await subscription.next()).toDeepEqual({ + done: false, + value: { + data: { + importantEmail: { + email: { + from: 'hyo@graphql.org', + subject: 'Tools', + }, + }, + }, + hasNext: true, + }, + }); + + // Another new email arrives, before the incrementally delivered payloads from the last email was received. + expect( + pubsub.emit({ + from: 'adam@graphql.org', + subject: 'Important', + message: 'Read me please', + unread: true, + }) + ).toBeTruthy(); + + // Deferred payload from previous event is received. + // @ts-expect-error we have asserted it is an async iterable + expectJSON(await subscription.next()).toDeepEqual({ + done: false, + value: { + incremental: [ + { + data: { + inbox: { + unread: 2, + total: 3, + }, + }, + path: ['importantEmail'], + }, + ], + hasNext: false, + }, + }); + + // Next payload from last event + // @ts-expect-error we have asserted it is an async iterable + expectJSON(await subscription.next()).toDeepEqual({ + done: false, + value: { + data: { + importantEmail: { + email: { + from: 'adam@graphql.org', + subject: 'Important', + }, + }, + }, + hasNext: true, + }, + }); + + // The client disconnects before the deferred payload is consumed. + // @ts-expect-error we have asserted it is an async iterable + expectJSON(await subscription.return()).toDeepEqual({ + done: true, + value: undefined, + }); + + // Awaiting a subscription after closing it results in completed results. + // @ts-expect-error we have asserted it is an async iterable + expectJSON(await subscription.next()).toDeepEqual({ + done: true, + value: undefined, + }); + }); + it('produces a payload when there are multiple events', async () => { const pubsub = new SimplePubSub(); const subscription = createSubscription(pubsub); diff --git a/packages/executor/src/execution/__tests__/sync-test.ts b/packages/executor/src/execution/__tests__/sync-test.ts index ae8509461db..dd62818722e 100644 --- a/packages/executor/src/execution/__tests__/sync-test.ts +++ b/packages/executor/src/execution/__tests__/sync-test.ts @@ -100,6 +100,24 @@ describe('Execute: synchronously when possible', () => { }); }).toThrow('GraphQL execution failed to complete synchronously.'); }); + + it('throws if encountering async iterable execution', () => { + const doc = ` + query Example { + ...deferFrag @defer(label: "deferLabel") + } + fragment deferFrag on Query { + syncField + } + `; + expect(() => { + executeSync({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + }).toThrow('GraphQL execution failed to complete synchronously.'); + }); }); describe('graphqlSync', () => { diff --git a/packages/executor/src/execution/execute.ts b/packages/executor/src/execution/execute.ts index 6d8a6112393..01fa06bf371 100644 --- a/packages/executor/src/execution/execute.ts +++ b/packages/executor/src/execution/execute.ts @@ -1,7 +1,6 @@ import { GraphQLFormattedError, locatedError, - GraphQLError, FieldNode, FragmentDefinitionNode, OperationDefinitionNode, @@ -22,32 +21,55 @@ import { isObjectType, assertValidSchema, GraphQLSchema, + getDirectiveValues, SchemaMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, } from 'graphql'; +import type { GraphQLError } from 'graphql'; import { - collectFields, - collectSubFields, createGraphQLError, inspect, isAsyncIterable, isIterableObject, isObjectLike, isPromise, - mapAsyncIterator, - Maybe, Path, pathToArray, - MaybePromise, addPath, getArgumentValues, promiseReduce, + Maybe, + memoize3, getDefinedRootType, - ExecutionResult, + MaybePromise, + mapAsyncIterator, + GraphQLStreamDirective, + collectFields, + collectSubFields as _collectSubfields, } from '@graphql-tools/utils'; import { getVariableValues } from './values.js'; +import { promiseForObject } from './promiseForObject.js'; +import { flattenAsyncIterable } from './flattenAsyncIterable.js'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { invariant } from './invariant.js'; +import { ValueOrPromise } from 'value-or-promise'; + +export interface SingularExecutionResult { + errors?: ReadonlyArray; + data?: TData | null; + extensions?: TExtensions; +} + +/** + * A memoized collection of relevant subfields with regard to the return + * type. Memoizing ensures the subfields are not repeatedly calculated, which + * saves overhead when resolving lists of values. + */ +const collectSubfields = memoize3( + (exeContext: ExecutionContext, returnType: GraphQLObjectType, fieldNodes: Array) => + _collectSubfields(exeContext.schema, exeContext.fragments, exeContext.variableValues, returnType, fieldNodes) +); // This file contains a lot of such errors but we plan to refactor it anyway // so just disable it for entire file. @@ -78,25 +100,103 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core'; * Namely, schema of the type system that is currently executing, * and the fragments defined in the query document */ -export interface ExecutionContext { +export interface ExecutionContext { schema: GraphQLSchema; fragments: Record; rootValue: unknown; contextValue: TContext; operation: OperationDefinitionNode; - variableValues: { [variable: string]: unknown }; + variableValues: TVariables; fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; errors: Array; + subsequentPayloads: Set; } -export interface FormattedExecutionResult { +export interface FormattedExecutionResult, TExtensions = Record> { errors?: ReadonlyArray; data?: TData | null; extensions?: TExtensions; } +export interface IncrementalExecutionResults, TExtensions = Record> { + initialResult: InitialIncrementalExecutionResult; + subsequentResults: AsyncGenerator, void, void>; +} + +export interface InitialIncrementalExecutionResult< + TData = Record, + TExtensions = Record +> extends SingularExecutionResult { + hasNext: boolean; + incremental?: ReadonlyArray>; + extensions?: TExtensions; +} + +export interface FormattedInitialIncrementalExecutionResult< + TData = Record, + TExtensions = Record +> extends FormattedExecutionResult { + hasNext: boolean; + incremental?: ReadonlyArray>; + extensions?: TExtensions; +} + +export interface SubsequentIncrementalExecutionResult< + TData = Record, + TExtensions = Record +> { + hasNext: boolean; + incremental?: ReadonlyArray>; + extensions?: TExtensions; +} + +export interface FormattedSubsequentIncrementalExecutionResult< + TData = Record, + TExtensions = Record +> { + hasNext: boolean; + incremental?: ReadonlyArray>; + extensions?: TExtensions; +} + +export interface IncrementalDeferResult, TExtensions = Record> + extends SingularExecutionResult { + path?: ReadonlyArray; + label?: string; +} + +export interface FormattedIncrementalDeferResult, TExtensions = Record> + extends FormattedExecutionResult { + path?: ReadonlyArray; + label?: string; +} + +export interface IncrementalStreamResult, TExtensions = Record> { + errors?: ReadonlyArray; + items?: TData | null; + path?: ReadonlyArray; + label?: string; + extensions?: TExtensions; +} + +export interface FormattedIncrementalStreamResult, TExtensions = Record> { + errors?: ReadonlyArray; + items?: TData | null; + path?: ReadonlyArray; + label?: string; + extensions?: TExtensions; +} + +export type IncrementalResult, TExtensions = Record> = + | IncrementalDeferResult + | IncrementalStreamResult; + +export type FormattedIncrementalResult, TExtensions = Record> = + | FormattedIncrementalDeferResult + | FormattedIncrementalStreamResult; + export interface ExecutionArgs { schema: GraphQLSchema; document: TypedDocumentNode; @@ -110,18 +210,20 @@ export interface ExecutionArgs { } /** - * Implements the "Executing requests" section of the GraphQL specification. + * Implements the "Executing requests" section of the GraphQL specification, + * including `@defer` and `@stream` as proposed in + * https://github.com/graphql/graphql-spec/pull/742 * - * Returns either a synchronous ExecutionResult (if all encountered resolvers - * are synchronous), or a Promise of an ExecutionResult that will eventually be - * resolved and never rejected. + * This function returns a Promise of an IncrementalExecutionResults + * object. This object either consists of a single ExecutionResult, or an + * object containing an `initialResult` and a stream of `subsequentResults`. * * If the arguments to this function do not result in a legal execution context, * a GraphQLError will be thrown immediately explaining the invalid input. */ -export function execute( +export function execute( args: ExecutionArgs -): MaybePromise> { +): MaybePromise | IncrementalExecutionResults> { // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. const exeContext = buildExecutionContext(args); @@ -134,7 +236,9 @@ export function execute { +function executeImpl( + exeContext: ExecutionContext +): MaybePromise | IncrementalExecutionResults> { // Return a Promise that will eventually resolve to the data described by // The "Response" section of the GraphQL specification. // @@ -146,22 +250,27 @@ function executeImpl(exeContext: ExecutionContext): MaybePromise buildResponse(data, exeContext.errors), - error => { - exeContext.errors.push(error); - return buildResponse(null, exeContext.errors); + return new ValueOrPromise(() => executeOperation(exeContext)) + .then( + data => { + const initialResult = buildResponse(data, exeContext.errors); + if (exeContext.subsequentPayloads.size > 0) { + return { + initialResult: { + ...initialResult, + hasNext: true, + }, + subsequentResults: yieldSubsequentPayloads(exeContext), + }; } - ); - } - return buildResponse(result, exeContext.errors); - } catch (error) { - exeContext.errors.push(error as GraphQLError); - return buildResponse(null, exeContext.errors); - } + return initialResult; + }, + (error: any) => { + exeContext.errors.push(error); + return buildResponse(null, exeContext.errors); + } + ) + .resolve()!; } /** @@ -169,11 +278,11 @@ function executeImpl(exeContext: ExecutionContext): MaybePromise | null, errors: ReadonlyArray): ExecutionResult { +function buildResponse(data: TData | null, errors: ReadonlyArray): SingularExecutionResult { return errors.length === 0 ? { data } : { errors, data }; } @@ -220,11 +329,9 @@ export function assertValidExecutionArguments( * TODO: consider no longer exporting this function * @internal */ -export function buildExecutionContext< - TData = { [key: string]: any }, - TVariables = { [key: string]: any }, - TContext = any ->(args: ExecutionArgs): ReadonlyArray | ExecutionContext { +export function buildExecutionContext( + args: ExecutionArgs +): ReadonlyArray | ExecutionContext { const { schema, document, @@ -291,6 +398,7 @@ export function buildExecutionContext< fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, + subsequentPayloads: new Set(), errors: [], }; } @@ -299,6 +407,7 @@ function buildPerEventExecutionContext(exeContext: ExecutionContext, payload: un return { ...exeContext, rootValue: payload, + subsequentPayloads: new Set(), errors: [], }; } @@ -306,53 +415,66 @@ function buildPerEventExecutionContext(exeContext: ExecutionContext, payload: un /** * Implements the "Executing operations" section of the spec. */ -function executeOperation(exeContext: ExecutionContext): MaybePromise> { +function executeOperation( + exeContext: ExecutionContext +): MaybePromise { const { operation, schema, fragments, variableValues, rootValue } = exeContext; const rootType = getDefinedRootType(schema, operation.operation, [operation]); + if (rootType == null) { + createGraphQLError(`Schema is not configured to execute ${operation.operation} operation.`, { + nodes: operation, + }); + } - const rootFields = collectFields(schema, fragments, variableValues, rootType, operation.selectionSet); + const { fields: rootFields, patches } = collectFields( + schema, + fragments, + variableValues, + rootType, + operation.selectionSet + ); const path = undefined; + let result: MaybePromise; - switch (operation.operation) { - case 'query': - return executeFields(exeContext, rootType, rootValue, path, rootFields); - case 'mutation': - return executeFieldsSerially(exeContext, rootType, rootValue, path, rootFields); - case 'subscription': - // TODO: deprecate `subscribe` and move all logic here - // Temporary solution until we finish merging execute and subscribe together - return executeFields(exeContext, rootType, rootValue, path, rootFields); - } - throw new Error(`Can only execute queries, mutations and subscriptions, got "${operation.operation}".`); + if (operation.operation === 'mutation') { + result = executeFieldsSerially(exeContext, rootType, rootValue, path, rootFields); + } else { + result = executeFields(exeContext, rootType, rootValue, path, rootFields) as TData; + } + + for (const patch of patches) { + const { label, fields: patchFields } = patch; + executeDeferredFragment(exeContext, rootType, rootValue, patchFields, label, path); + } + + return result; } /** * Implements the "Executing selection sets" section of the spec * for fields that must be executed serially. */ -function executeFieldsSerially( +function executeFieldsSerially( exeContext: ExecutionContext, parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - fields: Map> -): MaybePromise> { + fields: Map> +): MaybePromise { return promiseReduce( - fields.entries(), + fields, (results, [responseName, fieldNodes]) => { const fieldPath = addPath(path, responseName, parentType.name); - const result = executeField(exeContext, parentType, sourceValue, fieldNodes, fieldPath); - if (result === undefined) { - return results; - } - if (isPromise(result)) { - return result.then(resolvedResult => { - results[responseName] = resolvedResult; + return new ValueOrPromise(() => executeField(exeContext, parentType, sourceValue, fieldNodes, fieldPath)) + .then(result => { + if (result === undefined) { + return results; + } + + results[responseName] = result; return results; - }); - } - results[responseName] = result; - return results; + }) + .resolve(); }, Object.create(null) ); @@ -367,21 +489,32 @@ function executeFields( parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - fields: Map> + fields: Map>, + asyncPayloadRecord?: AsyncPayloadRecord ): MaybePromise> { const results = Object.create(null); let containsPromise = false; - for (const [responseName, fieldNodes] of fields.entries()) { - const fieldPath = addPath(path, responseName, parentType.name); - const result = executeField(exeContext, parentType, sourceValue, fieldNodes, fieldPath); + try { + for (const [responseName, fieldNodes] of fields) { + const fieldPath = addPath(path, responseName, parentType.name); + const result = executeField(exeContext, parentType, sourceValue, fieldNodes, fieldPath, asyncPayloadRecord); - if (result !== undefined) { - results[responseName] = result; - if (isPromise(result)) { - containsPromise = true; + if (result !== undefined) { + results[responseName] = result; + if (isPromise(result)) { + containsPromise = true; + } } } + } catch (error) { + if (containsPromise) { + // Ensure that any promises returned by other fields are handled, as they may also reject. + return promiseForObject(results).finally(() => { + throw error; + }); + } + throw error; } // If there are no promises, we can just return the object @@ -392,15 +525,7 @@ function executeFields( // Otherwise, results is a map from field name to the result of resolving that // field, which is possibly a promise. Return a promise that will return this // same map, but with any promises replaced with the values they resolved to. - return Promise.all(Object.values(results)).then(resolvedValues => { - const resolvedObject = Object.create(null); - - for (const [i, key] of Object.keys(results).entries()) { - resolvedObject[key] = resolvedValues[i]; - } - - return resolvedObject; - }); + return promiseForObject(results); } /** @@ -413,9 +538,11 @@ function executeField( exeContext: ExecutionContext, parentType: GraphQLObjectType, source: unknown, - fieldNodes: ReadonlyArray, - path: Path + fieldNodes: Array, + path: Path, + asyncPayloadRecord?: AsyncPayloadRecord ): MaybePromise { + const errors = asyncPayloadRecord?.errors ?? exeContext.errors; const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]); if (!fieldDef) { return; @@ -442,9 +569,11 @@ function executeField( let completed; if (isPromise(result)) { - completed = result.then(resolved => completeValue(exeContext, returnType, fieldNodes, info, path, resolved)); + completed = result.then(resolved => + completeValue(exeContext, returnType, fieldNodes, info, path, resolved, asyncPayloadRecord) + ); } else { - completed = completeValue(exeContext, returnType, fieldNodes, info, path, result); + completed = completeValue(exeContext, returnType, fieldNodes, info, path, result, asyncPayloadRecord); } if (isPromise(completed)) { @@ -452,13 +581,17 @@ function executeField( // to take a second callback for the error case. return completed.then(undefined, rawError => { const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return handleFieldError(error, returnType, exeContext); + const handledError = handleFieldError(error, returnType, errors); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return handledError; }); } return completed; } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return handleFieldError(error, returnType, exeContext); + const handledError = handleFieldError(error, returnType, errors); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return handledError; } } @@ -469,7 +602,7 @@ function executeField( export function buildResolveInfo( exeContext: ExecutionContext, fieldDef: GraphQLField, - fieldNodes: ReadonlyArray, + fieldNodes: Array, parentType: GraphQLObjectType, path: Path ): GraphQLResolveInfo { @@ -489,7 +622,7 @@ export function buildResolveInfo( }; } -function handleFieldError(error: GraphQLError, returnType: GraphQLOutputType, exeContext: ExecutionContext): null { +function handleFieldError(error: GraphQLError, returnType: GraphQLOutputType, errors: Array): null { // If the field type is non-nullable, then it is resolved without any // protection from errors, however it still properly locates the error. if (isNonNullType(returnType)) { @@ -498,7 +631,7 @@ function handleFieldError(error: GraphQLError, returnType: GraphQLOutputType, ex // Otherwise, error protection is applied, logging the error and resolving // a null value for this field if one is encountered. - exeContext.errors.push(error); + errors.push(error); return null; } @@ -526,10 +659,11 @@ function handleFieldError(error: GraphQLError, returnType: GraphQLOutputType, ex function completeValue( exeContext: ExecutionContext, returnType: GraphQLOutputType, - fieldNodes: ReadonlyArray, + fieldNodes: Array, info: GraphQLResolveInfo, path: Path, - result: unknown + result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord ): MaybePromise { // If result is an Error, throw a located error. if (result instanceof Error) { @@ -539,7 +673,7 @@ function completeValue( // If field type is NonNull, complete for inner type, and throw field error // if result is null. if (isNonNullType(returnType)) { - const completed = completeValue(exeContext, returnType.ofType, fieldNodes, info, path, result); + const completed = completeValue(exeContext, returnType.ofType, fieldNodes, info, path, result, asyncPayloadRecord); if (completed === null) { throw new Error(`Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`); } @@ -553,7 +687,7 @@ function completeValue( // If field type is List, complete each item in the list with the inner type if (isListType(returnType)) { - return completeListValue(exeContext, returnType, fieldNodes, info, path, result); + return completeListValue(exeContext, returnType, fieldNodes, info, path, result, asyncPayloadRecord); } // If field type is a leaf type, Scalar or Enum, serialize to a valid value, @@ -565,18 +699,63 @@ function completeValue( // If field type is an abstract type, Interface or Union, determine the // runtime Object type and complete for that type. if (isAbstractType(returnType)) { - return completeAbstractValue(exeContext, returnType, fieldNodes, info, path, result); + return completeAbstractValue(exeContext, returnType, fieldNodes, info, path, result, asyncPayloadRecord); } // If field type is Object, execute and complete all sub-selections. if (isObjectType(returnType)) { - return completeObjectValue(exeContext, returnType, fieldNodes, info, path, result); + return completeObjectValue(exeContext, returnType, fieldNodes, info, path, result, asyncPayloadRecord); } /* c8 ignore next 6 */ // Not reachable, all possible output types have been considered. console.assert(false, 'Cannot complete value of unexpected output type: ' + inspect(returnType)); } +/** + * Returns an object containing the `@stream` arguments if a field should be + * streamed based on the experimental flag, stream directive present and + * not disabled by the "if" argument. + */ +function getStreamValues( + exeContext: ExecutionContext, + fieldNodes: Array, + path: Path +): + | undefined + | { + initialCount: number | undefined; + label: string | undefined; + } { + // do not stream inner lists of multi-dimensional lists + if (typeof path.key === 'number') { + return; + } + + // validation only allows equivalent streams on multiple fields, so it is + // safe to only check the first fieldNode for the stream directive + const stream = getDirectiveValues(GraphQLStreamDirective, fieldNodes[0], exeContext.variableValues) as { + initialCount: number; + label: string; + }; + + if (!stream) { + return; + } + + if (stream['if'] === false) { + return; + } + + invariant(typeof stream['initialCount'] === 'number', 'initialCount must be a number'); + + invariant(stream['initialCount'] >= 0, 'initialCount must be a positive integer'); + + return { + initialCount: stream['initialCount'], + label: typeof stream['label'] === 'string' ? stream['label'] : undefined, + }; +} + /** * Complete a async iterator value by completing the result and calling * recursively until all the results are completed. @@ -584,41 +763,62 @@ function completeValue( async function completeAsyncIteratorValue( exeContext: ExecutionContext, itemType: GraphQLOutputType, - fieldNodes: ReadonlyArray, + fieldNodes: Array, info: GraphQLResolveInfo, path: Path, - iterator: AsyncIterator + iterator: AsyncIterator, + asyncPayloadRecord?: AsyncPayloadRecord ): Promise> { + const errors = asyncPayloadRecord?.errors ?? exeContext.errors; + const stream = getStreamValues(exeContext, fieldNodes, path); let containsPromise = false; - const completedResults = []; + const completedResults: Array = []; let index = 0; while (true) { - const fieldPath = addPath(path, index, undefined); + if (stream && typeof stream.initialCount === 'number' && index >= stream.initialCount) { + executeStreamIterator( + index, + iterator, + exeContext, + fieldNodes, + info, + itemType, + path, + stream.label, + asyncPayloadRecord + ); + break; + } + + const itemPath = addPath(path, index, undefined); + let iteration; try { - const { value, done } = await iterator.next(); - if (done) { + iteration = await iterator.next(); + if (iteration.done) { break; } - - try { - // TODO can the error checking logic be consolidated with completeListValue? - const completedItem = completeValue(exeContext, itemType, fieldNodes, info, fieldPath, value); - if (isPromise(completedItem)) { - containsPromise = true; - } - completedResults.push(completedItem); - } catch (rawError) { - completedResults.push(null); - const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath)); - handleFieldError(error, itemType, exeContext); - } } catch (rawError) { - completedResults.push(null); - const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath)); - handleFieldError(error, itemType, exeContext); + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + completedResults.push(handleFieldError(error, itemType, errors)); break; } + + if ( + completeListItemValue( + iteration.value, + completedResults, + errors, + exeContext, + itemType, + fieldNodes, + info, + itemPath, + asyncPayloadRecord + ) + ) { + containsPromise = true; + } index += 1; } return containsPromise ? Promise.all(completedResults) : completedResults; @@ -631,17 +831,19 @@ async function completeAsyncIteratorValue( function completeListValue( exeContext: ExecutionContext, returnType: GraphQLList, - fieldNodes: ReadonlyArray, + fieldNodes: Array, info: GraphQLResolveInfo, path: Path, - result: unknown + result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord ): MaybePromise> { const itemType = returnType.ofType; + const errors = asyncPayloadRecord?.errors ?? exeContext.errors; if (isAsyncIterable(result)) { const iterator = result[Symbol.asyncIterator](); - return completeAsyncIteratorValue(exeContext, itemType, fieldNodes, info, path, iterator); + return completeAsyncIteratorValue(exeContext, itemType, fieldNodes, info, path, iterator, asyncPayloadRecord); } if (!isIterableObject(result)) { @@ -650,42 +852,109 @@ function completeListValue( ); } + const stream = getStreamValues(exeContext, fieldNodes, path); + // This is specified as a simple map, however we're optimizing the path // where the list contains no Promises by avoiding creating another Promise. let containsPromise = false; - const completedResults = Array.from(result, (item, index) => { + let previousAsyncPayloadRecord = asyncPayloadRecord; + const completedResults: Array = []; + let index = 0; + for (const item of result) { // No need to modify the info object containing the path, // since from here on it is not ever accessed by resolver functions. const itemPath = addPath(path, index, undefined); - try { - let completedItem; - if (isPromise(item)) { - completedItem = item.then(resolved => - completeValue(exeContext, itemType, fieldNodes, info, itemPath, resolved) - ); - } else { - completedItem = completeValue(exeContext, itemType, fieldNodes, info, itemPath, item); - } - if (isPromise(completedItem)) { - containsPromise = true; - // Note: we don't rely on a `catch` method, but we do expect "thenable" - // to take a second callback for the error case. - return completedItem.then(undefined, rawError => { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); - return handleFieldError(error, itemType, exeContext); - }); - } - return completedItem; - } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); - return handleFieldError(error, itemType, exeContext); + if (stream && typeof stream.initialCount === 'number' && index >= stream.initialCount) { + previousAsyncPayloadRecord = executeStreamField( + path, + itemPath, + item, + exeContext, + fieldNodes, + info, + itemType, + stream.label, + previousAsyncPayloadRecord + ); + index++; + continue; } - }); + + if ( + completeListItemValue( + item, + completedResults, + errors, + exeContext, + itemType, + fieldNodes, + info, + itemPath, + asyncPayloadRecord + ) + ) { + containsPromise = true; + } + + index++; + } return containsPromise ? Promise.all(completedResults) : completedResults; } +/** + * Complete a list item value by adding it to the completed results. + * + * Returns true if the value is a Promise. + */ +function completeListItemValue( + item: unknown, + completedResults: Array, + errors: Array, + exeContext: ExecutionContext, + itemType: GraphQLOutputType, + fieldNodes: Array, + info: GraphQLResolveInfo, + itemPath: Path, + asyncPayloadRecord?: AsyncPayloadRecord +): boolean { + try { + let completedItem; + if (isPromise(item)) { + completedItem = item.then(resolved => + completeValue(exeContext, itemType, fieldNodes, info, itemPath, resolved, asyncPayloadRecord) + ); + } else { + completedItem = completeValue(exeContext, itemType, fieldNodes, info, itemPath, item, asyncPayloadRecord); + } + + if (isPromise(completedItem)) { + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + completedResults.push( + completedItem.then(undefined, rawError => { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError(error, itemType, errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return handledError; + }) + ); + + return true; + } + + completedResults.push(completedItem); + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError(error, itemType, errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + completedResults.push(handledError); + } + + return false; +} + /** * Complete a Scalar or Enum by serializing to a valid value, returning * null if serialization is not possible. @@ -708,10 +977,11 @@ function completeLeafValue(returnType: GraphQLLeafType, result: unknown): unknow function completeAbstractValue( exeContext: ExecutionContext, returnType: GraphQLAbstractType, - fieldNodes: ReadonlyArray, + fieldNodes: Array, info: GraphQLResolveInfo, path: Path, - result: unknown + result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord ): MaybePromise> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -725,7 +995,8 @@ function completeAbstractValue( fieldNodes, info, path, - result + result, + asyncPayloadRecord ) ); } @@ -736,7 +1007,8 @@ function completeAbstractValue( fieldNodes, info, path, - result + result, + asyncPayloadRecord ); } @@ -744,7 +1016,7 @@ function ensureValidRuntimeType( runtimeTypeName: unknown, exeContext: ExecutionContext, returnType: GraphQLAbstractType, - fieldNodes: ReadonlyArray, + fieldNodes: Array, info: GraphQLResolveInfo, result: unknown ): GraphQLObjectType { @@ -801,20 +1073,12 @@ function ensureValidRuntimeType( function completeObjectValue( exeContext: ExecutionContext, returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, + fieldNodes: Array, info: GraphQLResolveInfo, path: Path, - result: unknown + result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord ): MaybePromise> { - // Collect sub-fields to execute to complete this value. - const subFieldNodes = collectSubFields( - exeContext.schema, - exeContext.fragments, - exeContext.variableValues, - returnType, - fieldNodes as FieldNode[] - ); - // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather // than continuing execution. @@ -826,7 +1090,7 @@ function completeObjectValue( if (!resolvedIsTypeOf) { throw invalidReturnTypeError(returnType, result, fieldNodes); } - return executeFields(exeContext, returnType, result, path, subFieldNodes); + return collectAndExecuteSubfields(exeContext, returnType, fieldNodes, path, result, asyncPayloadRecord); }); } @@ -835,19 +1099,40 @@ function completeObjectValue( } } - return executeFields(exeContext, returnType, result, path, subFieldNodes); + return collectAndExecuteSubfields(exeContext, returnType, fieldNodes, path, result, asyncPayloadRecord); } function invalidReturnTypeError( returnType: GraphQLObjectType, result: unknown, - fieldNodes: ReadonlyArray + fieldNodes: Array ): GraphQLError { return createGraphQLError(`Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, { nodes: fieldNodes, }); } +function collectAndExecuteSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: Array, + path: Path, + result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord +): MaybePromise> { + // Collect sub-fields to execute to complete this value. + const { fields: subFieldNodes, patches: subPatches } = collectSubfields(exeContext, returnType, fieldNodes); + + const subFields = executeFields(exeContext, returnType, result, path, subFieldNodes, asyncPayloadRecord); + + for (const subPatch of subPatches) { + const { label, fields: subPatchFieldNodes } = subPatch; + executeDeferredFragment(exeContext, returnType, result, subPatchFieldNodes, label, path, asyncPayloadRecord); + } + + return subFields; +} + /** * If a resolveType function is not given, then a default resolve behavior is * used which attempts two strategies: @@ -921,7 +1206,9 @@ export const defaultFieldResolver: GraphQLFieldResolver = func }; /** - * Implements the "Subscribe" algorithm described in the GraphQL specification. + * Implements the "Subscribe" algorithm described in the GraphQL specification, + * including `@defer` and `@stream` as proposed in + * https://github.com/graphql/graphql-spec/pull/742 * * Returns a Promise which resolves to either an AsyncIterator (if successful) * or an ExecutionResult (error). The promise will be rejected if the schema or @@ -929,19 +1216,40 @@ export const defaultFieldResolver: GraphQLFieldResolver = func * is not an async iterable. * * If the client-provided arguments to this function do not result in a - * compliant subscription, a GraphQL Response (ExecutionResult) with - * descriptive errors and no data will be returned. + * compliant subscription, a GraphQL Response (ExecutionResult) with descriptive + * errors and no data will be returned. * - * If the source stream could not be created due to faulty subscription - * resolver logic or underlying systems, the promise will resolve to a single + * If the source stream could not be created due to faulty subscription resolver + * logic or underlying systems, the promise will resolve to a single * ExecutionResult containing `errors` and no `data`. * * If the operation succeeded, the promise resolves to an AsyncIterator, which - * yields a stream of ExecutionResults representing the response stream. + * yields a stream of result representing the response stream. + * + * Each result may be an ExecutionResult with no `hasNext` (if executing the + * event did not use `@defer` or `@stream`), or an + * `InitialIncrementalExecutionResult` or `SubsequentIncrementalExecutionResult` + * (if executing the event used `@defer` or `@stream`). In the case of + * incremental execution results, each event produces a single + * `InitialIncrementalExecutionResult` followed by one or more + * `SubsequentIncrementalExecutionResult`s; all but the last have `hasNext: true`, + * and the last has `hasNext: false`. There is no interleaving between results + * generated from the same original event. * - * Accepts either an object with named arguments, or individual arguments. + * Accepts an object with named arguments. */ -export function subscribe(args: ExecutionArgs): MaybePromise | ExecutionResult> { +export function subscribe( + args: ExecutionArgs +): MaybePromise< + | AsyncGenerator< + | SingularExecutionResult + | InitialIncrementalExecutionResult + | SubsequentIncrementalExecutionResult, + void, + void + > + | SingularExecutionResult +> { // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. const exeContext = buildExecutionContext(args); @@ -960,10 +1268,32 @@ export function subscribe(args: ExecutionArgs): MaybePromise { + if ('initialResult' in someExecutionResult) { + yield someExecutionResult.initialResult; + yield* someExecutionResult.subsequentResults; + } else { + yield someExecutionResult; + } +} + function mapSourceToResponse( exeContext: ExecutionContext, - resultOrStream: ExecutionResult | AsyncIterable -): MaybePromise | ExecutionResult> { + resultOrStream: SingularExecutionResult | AsyncIterable +): MaybePromise< + | AsyncGenerator< + SingularExecutionResult | InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult, + void, + void + > + | SingularExecutionResult +> { if (!isAsyncIterable(resultOrStream)) { return resultOrStream; } @@ -974,8 +1304,10 @@ function mapSourceToResponse( // the GraphQL specification. The `execute` function provides the // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the // "ExecuteQuery" algorithm, for which `execute` is also used. - return mapAsyncIterator(resultOrStream[Symbol.asyncIterator](), (payload: unknown) => - executeImpl(buildPerEventExecutionContext(exeContext, payload)) + return flattenAsyncIterable( + mapAsyncIterator(resultOrStream[Symbol.asyncIterator](), async (payload: unknown) => + ensureAsyncIterable(await executeImpl(buildPerEventExecutionContext(exeContext, payload))) + ) ); } @@ -1007,7 +1339,9 @@ function mapSourceToResponse( * or otherwise separating these two steps. For more on this, see the * "Supporting Subscriptions at Scale" information in the GraphQL specification. */ -export function createSourceEventStream(args: ExecutionArgs): MaybePromise | ExecutionResult> { +export function createSourceEventStream( + args: ExecutionArgs +): MaybePromise | SingularExecutionResult> { // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. const exeContext = buildExecutionContext(args); @@ -1022,7 +1356,7 @@ export function createSourceEventStream(args: ExecutionArgs): MaybePromise | ExecutionResult> { +): MaybePromise | SingularExecutionResult> { try { const eventStream = executeSubscription(exeContext); if (isPromise(eventStream)) { @@ -1043,7 +1377,7 @@ function executeSubscription(exeContext: ExecutionContext): MaybePromise { return result; } +function executeDeferredFragment( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: unknown, + fields: Map>, + label?: string, + path?: Path, + parentContext?: AsyncPayloadRecord +): void { + const asyncPayloadRecord = new DeferredFragmentRecord({ + label, + path, + parentContext, + exeContext, + }); + let promiseOrData; + try { + promiseOrData = executeFields(exeContext, parentType, sourceValue, path, fields, asyncPayloadRecord); + + if (isPromise(promiseOrData)) { + promiseOrData = promiseOrData.then(null, e => { + asyncPayloadRecord.errors.push(e); + return null; + }); + } + } catch (e) { + asyncPayloadRecord.errors.push(e as GraphQLError); + promiseOrData = null; + } + asyncPayloadRecord.addData(promiseOrData); +} + +function executeStreamField( + path: Path, + itemPath: Path, + item: MaybePromise, + exeContext: ExecutionContext, + fieldNodes: Array, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + label?: string, + parentContext?: AsyncPayloadRecord +): AsyncPayloadRecord { + const asyncPayloadRecord = new StreamRecord({ + label, + path: itemPath, + parentContext, + exeContext, + }); + let completedItem: MaybePromise; + try { + try { + if (isPromise(item)) { + completedItem = item.then(resolved => + completeValue(exeContext, itemType, fieldNodes, info, itemPath, resolved, asyncPayloadRecord) + ); + } else { + completedItem = completeValue(exeContext, itemType, fieldNodes, info, itemPath, item, asyncPayloadRecord); + } + + if (isPromise(completedItem)) { + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + completedItem = completedItem.then(undefined, rawError => { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError(error, itemType, asyncPayloadRecord.errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return handledError; + }); + } + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + completedItem = handleFieldError(error, itemType, asyncPayloadRecord.errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + } + } catch (error) { + asyncPayloadRecord.errors.push(error as GraphQLError); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + asyncPayloadRecord.addItems(null); + return asyncPayloadRecord; + } + + let completedItems: MaybePromise | null>; + if (isPromise(completedItem)) { + completedItems = completedItem.then( + value => [value], + error => { + asyncPayloadRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return null; + } + ); + } else { + completedItems = [completedItem]; + } + + asyncPayloadRecord.addItems(completedItems); + return asyncPayloadRecord; +} + +async function executeStreamIteratorItem( + iterator: AsyncIterator, + exeContext: ExecutionContext, + fieldNodes: Array, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + asyncPayloadRecord: StreamRecord, + itemPath: Path +): Promise> { + let item; + try { + const { value, done } = await iterator.next(); + if (done) { + asyncPayloadRecord.setIsCompletedIterator(); + return { done, value: undefined }; + } + item = value; + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); + // don't continue if iterator throws + return { done: true, value }; + } + let completedItem; + try { + completedItem = completeValue(exeContext, itemType, fieldNodes, info, itemPath, item, asyncPayloadRecord); + + if (isPromise(completedItem)) { + completedItem = completedItem.then(undefined, rawError => { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const handledError = handleFieldError(error, itemType, asyncPayloadRecord.errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return handledError; + }); + } + return { done: false, value: completedItem }; + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); + filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + return { done: false, value }; + } +} + +async function executeStreamIterator( + initialIndex: number, + iterator: AsyncIterator, + exeContext: ExecutionContext, + fieldNodes: Array, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + path: Path, + label?: string, + parentContext?: AsyncPayloadRecord +): Promise { + let index = initialIndex; + let previousAsyncPayloadRecord = parentContext ?? undefined; + while (true) { + const itemPath = addPath(path, index, undefined); + const asyncPayloadRecord = new StreamRecord({ + label, + path: itemPath, + parentContext: previousAsyncPayloadRecord, + iterator, + exeContext, + }); + + let iteration; + try { + iteration = await executeStreamIteratorItem( + iterator, + exeContext, + fieldNodes, + info, + itemType, + asyncPayloadRecord, + itemPath + ); + } catch (error) { + asyncPayloadRecord.errors.push(error as GraphQLError); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + asyncPayloadRecord.addItems(null); + // entire stream has errored and bubbled upwards + if (iterator?.return) { + iterator.return().catch(() => { + // ignore errors + }); + } + return; + } + + const { done, value: completedItem } = iteration; + + let completedItems: MaybePromise | null>; + if (isPromise(completedItem)) { + completedItems = completedItem.then( + value => [value], + error => { + asyncPayloadRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + return null; + } + ); + } else { + completedItems = [completedItem]; + } + + asyncPayloadRecord.addItems(completedItems); + + if (done) { + break; + } + previousAsyncPayloadRecord = asyncPayloadRecord; + index++; + } +} + +function filterSubsequentPayloads( + exeContext: ExecutionContext, + nullPath: Path, + currentAsyncRecord: AsyncPayloadRecord | undefined +): void { + const nullPathArray = pathToArray(nullPath); + exeContext.subsequentPayloads.forEach(asyncRecord => { + if (asyncRecord === currentAsyncRecord) { + // don't remove payload from where error originates + return; + } + for (let i = 0; i < nullPathArray.length; i++) { + if (asyncRecord.path[i] !== nullPathArray[i]) { + // asyncRecord points to a path unaffected by this payload + return; + } + } + // asyncRecord path points to nulled error field + if (isStreamPayload(asyncRecord) && asyncRecord.iterator?.return) { + asyncRecord.iterator.return().catch(() => { + // ignore error + }); + } + exeContext.subsequentPayloads.delete(asyncRecord); + }); +} + +function getCompletedIncrementalResults(exeContext: ExecutionContext): Array { + const incrementalResults: Array = []; + for (const asyncPayloadRecord of exeContext.subsequentPayloads) { + const incrementalResult: IncrementalResult = {}; + if (!asyncPayloadRecord.isCompleted) { + continue; + } + exeContext.subsequentPayloads.delete(asyncPayloadRecord); + if (isStreamPayload(asyncPayloadRecord)) { + const items = asyncPayloadRecord.items; + if (asyncPayloadRecord.isCompletedIterator) { + // async iterable resolver just finished but there may be pending payloads + continue; + } + (incrementalResult as IncrementalStreamResult).items = items; + } else { + const data = asyncPayloadRecord.data; + (incrementalResult as IncrementalDeferResult).data = data ?? null; + } + + incrementalResult.path = asyncPayloadRecord.path; + if (asyncPayloadRecord.label) { + incrementalResult.label = asyncPayloadRecord.label; + } + if (asyncPayloadRecord.errors.length > 0) { + incrementalResult.errors = asyncPayloadRecord.errors; + } + incrementalResults.push(incrementalResult); + } + return incrementalResults; +} + +function yieldSubsequentPayloads( + exeContext: ExecutionContext +): AsyncGenerator { + let isDone = false; + + async function next(): Promise> { + if (isDone) { + return { value: undefined, done: true }; + } + + await Promise.race(Array.from(exeContext.subsequentPayloads).map(p => p.promise)); + + if (isDone) { + // a different call to next has exhausted all payloads + return { value: undefined, done: true }; + } + + const incremental = getCompletedIncrementalResults(exeContext); + const hasNext = exeContext.subsequentPayloads.size > 0; + + if (!incremental.length && hasNext) { + return next(); + } + + if (!hasNext) { + isDone = true; + } + + return { + value: incremental.length ? { incremental, hasNext } : { hasNext }, + done: false, + }; + } + + function returnStreamIterators() { + const promises: Array>> = []; + exeContext.subsequentPayloads.forEach(asyncPayloadRecord => { + if (isStreamPayload(asyncPayloadRecord) && asyncPayloadRecord.iterator?.return) { + promises.push(asyncPayloadRecord.iterator.return()); + } + }); + return Promise.all(promises); + } + + return { + [Symbol.asyncIterator]() { + return this; + }, + next, + async return(): Promise> { + await returnStreamIterators(); + isDone = true; + return { value: undefined, done: true }; + }, + async throw(error?: unknown): Promise> { + await returnStreamIterators(); + isDone = true; + return Promise.reject(error); + }, + }; +} + +class DeferredFragmentRecord { + type: 'defer'; + errors: Array; + label: string | undefined; + path: Array; + promise: Promise; + data: Record | null; + parentContext: AsyncPayloadRecord | undefined; + isCompleted: boolean; + _exeContext: ExecutionContext; + _resolve?: (arg: MaybePromise | null>) => void; + constructor(opts: { + label: string | undefined; + path: Path | undefined; + parentContext: AsyncPayloadRecord | undefined; + exeContext: ExecutionContext; + }) { + this.type = 'defer'; + this.label = opts.label; + this.path = pathToArray(opts.path); + this.parentContext = opts.parentContext; + this.errors = []; + this._exeContext = opts.exeContext; + this._exeContext.subsequentPayloads.add(this); + this.isCompleted = false; + this.data = null; + this.promise = new Promise | null>(resolve => { + this._resolve = MaybePromise => { + resolve(MaybePromise); + }; + }).then(data => { + this.data = data; + this.isCompleted = true; + }); + } + + addData(data: MaybePromise | null>) { + const parentData = this.parentContext?.promise; + if (parentData) { + this._resolve?.(parentData.then(() => data)); + return; + } + this._resolve?.(data); + } +} + +class StreamRecord { + type: 'stream'; + errors: Array; + label: string | undefined; + path: Array; + items: Array | null; + promise: Promise; + parentContext: AsyncPayloadRecord | undefined; + iterator: AsyncIterator | undefined; + isCompletedIterator?: boolean; + isCompleted: boolean; + _exeContext: ExecutionContext; + _resolve?: (arg: MaybePromise | null>) => void; + constructor(opts: { + label: string | undefined; + path: Path | undefined; + iterator?: AsyncIterator; + parentContext: AsyncPayloadRecord | undefined; + exeContext: ExecutionContext; + }) { + this.type = 'stream'; + this.items = null; + this.label = opts.label; + this.path = pathToArray(opts.path); + this.parentContext = opts.parentContext; + this.iterator = opts.iterator; + this.errors = []; + this._exeContext = opts.exeContext; + this._exeContext.subsequentPayloads.add(this); + this.isCompleted = false; + this.items = null; + this.promise = new Promise | null>(resolve => { + this._resolve = MaybePromise => { + resolve(MaybePromise); + }; + }).then(items => { + this.items = items; + this.isCompleted = true; + }); + } + + addItems(items: MaybePromise | null>) { + const parentData = this.parentContext?.promise; + if (parentData) { + this._resolve?.(parentData.then(() => items)); + return; + } + this._resolve?.(items); + } + + setIsCompletedIterator() { + this.isCompletedIterator = true; + } +} + +type AsyncPayloadRecord = DeferredFragmentRecord | StreamRecord; + +function isStreamPayload(asyncPayload: AsyncPayloadRecord): asyncPayload is StreamRecord { + return asyncPayload.type === 'stream'; +} + /** * This method looks up the field on the given type definition. * It has special casing for the three introspection fields, @@ -1125,3 +1904,9 @@ export function getFieldDef( } return parentType.getFields()[fieldName]; } + +export function isIncrementalResult( + result: SingularExecutionResult | IncrementalExecutionResults +): result is IncrementalExecutionResults { + return 'incremental' in result; +} diff --git a/packages/executor/src/execution/flattenAsyncIterable.ts b/packages/executor/src/execution/flattenAsyncIterable.ts new file mode 100644 index 00000000000..1f194a1fdd9 --- /dev/null +++ b/packages/executor/src/execution/flattenAsyncIterable.ts @@ -0,0 +1,93 @@ +type AsyncIterableOrGenerator = AsyncGenerator | AsyncIterable; + +/** + * Given an AsyncIterable of AsyncIterables, flatten all yielded results into a + * single AsyncIterable. + */ +export function flattenAsyncIterable( + iterable: AsyncIterableOrGenerator> +): AsyncGenerator { + // You might think this whole function could be replaced with + // + // async function* flattenAsyncIterable(iterable) { + // for await (const subIterator of iterable) { + // yield* subIterator; + // } + // } + // + // but calling `.return()` on the iterator it returns won't interrupt the `for await`. + + const topIterator = iterable[Symbol.asyncIterator](); + let currentNestedIterator: AsyncIterator | undefined; + let waitForCurrentNestedIterator: Promise | undefined; + let done = false; + + async function next(): Promise> { + if (done) { + return { value: undefined, done: true }; + } + + try { + if (!currentNestedIterator) { + // Somebody else is getting it already. + if (waitForCurrentNestedIterator) { + await waitForCurrentNestedIterator; + return await next(); + } + // Nobody else is getting it. We should! + let resolve: () => void; + waitForCurrentNestedIterator = new Promise(r => { + resolve = r; + }); + const topIteratorResult = await topIterator.next(); + if (topIteratorResult.done) { + // Given that done only ever transitions from false to true, + // require-atomic-updates is being unnecessarily cautious. + done = true; + return await next(); + } + // eslint is making a reasonable point here, but we've explicitly protected + // ourself from the race condition by ensuring that only the single call + // that assigns to waitForCurrentNestedIterator is allowed to assign to + // currentNestedIterator or waitForCurrentNestedIterator. + currentNestedIterator = topIteratorResult.value[Symbol.asyncIterator](); + waitForCurrentNestedIterator = undefined; + resolve!(); + return await next(); + } + + const rememberCurrentNestedIterator = currentNestedIterator; + const nestedIteratorResult = await currentNestedIterator.next(); + if (!nestedIteratorResult.done) { + return nestedIteratorResult; + } + + // The nested iterator is done. If it's still the current one, make it not + // current. (If it's not the current one, somebody else has made us move on.) + if (currentNestedIterator === rememberCurrentNestedIterator) { + currentNestedIterator = undefined; + } + return await next(); + } catch (err) { + done = true; + throw err; + } + } + return { + next, + async return() { + done = true; + await Promise.all([currentNestedIterator?.return?.(), topIterator.return?.()]); + return { value: undefined, done: true }; + }, + async throw(error?: unknown): Promise> { + done = true; + await Promise.all([currentNestedIterator?.throw?.(error), topIterator.throw?.(error)]); + /* c8 ignore next */ + throw error; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/packages/executor/src/execution/index.ts b/packages/executor/src/execution/index.ts index d4361e12029..2a2ac2f2bae 100644 --- a/packages/executor/src/execution/index.ts +++ b/packages/executor/src/execution/index.ts @@ -1,2 +1,3 @@ export * from './execute.js'; export * from './values.js'; +export * from './normalizedExecutor.js'; diff --git a/packages/executor/src/execution/invariant.ts b/packages/executor/src/execution/invariant.ts new file mode 100644 index 00000000000..0b2f891a2cb --- /dev/null +++ b/packages/executor/src/execution/invariant.ts @@ -0,0 +1,5 @@ +export function invariant(condition: boolean, message?: string): asserts condition { + if (!condition) { + throw new Error(message != null ? message : 'Unexpected invariant triggered.'); + } +} diff --git a/packages/executor/src/execution/normalizedExecutor.ts b/packages/executor/src/execution/normalizedExecutor.ts new file mode 100644 index 00000000000..d179cd65d89 --- /dev/null +++ b/packages/executor/src/execution/normalizedExecutor.ts @@ -0,0 +1,57 @@ +import { MaybeAsyncIterable, ExecutionResult, isAsyncIterable, MaybePromise } from '@graphql-tools/utils'; +import { getOperationAST } from 'graphql'; +import { execute, ExecutionArgs, subscribe } from './execute.js'; +import { Repeater } from '@repeaterjs/repeater'; +import { ValueOrPromise } from 'value-or-promise'; + +export function normalizedExecutor( + args: ExecutionArgs +): MaybePromise>> { + const operationAST = getOperationAST(args.document, args.operationName); + if (operationAST == null) { + throw new Error('Must provide an operation.'); + } + if (operationAST.operation === 'subscription') { + return new ValueOrPromise(() => subscribe(args)) + .then((result): MaybeAsyncIterable> => { + if (isAsyncIterable(result)) { + return new Repeater(async (push, stop) => { + let stopped = false; + stop.then(() => { + stopped = true; + }); + for await (const value of result) { + if (stopped) { + break; + } + push(value); + } + stop(); + }); + } + return result; + }) + .resolve()!; + } + return new ValueOrPromise(() => execute(args)) + .then((result): MaybeAsyncIterable> => { + if ('initialResult' in result) { + return new Repeater(async (push, stop) => { + let stopped = false; + stop.then(() => { + stopped = true; + }); + push(result.initialResult); + for await (const value of result.subsequentResults) { + if (stopped) { + break; + } + push(value); + } + stop(); + }); + } + return result; + }) + .resolve()!; +} diff --git a/packages/executor/src/execution/promiseForObject.ts b/packages/executor/src/execution/promiseForObject.ts new file mode 100644 index 00000000000..1b3a036e460 --- /dev/null +++ b/packages/executor/src/execution/promiseForObject.ts @@ -0,0 +1,22 @@ +type ResolvedObject = { + [TKey in keyof TData]: TData[TKey] extends Promise ? TValue : TData[TKey]; +}; + +/** + * This function transforms a JS object `Record>` into + * a `Promise>` + * + * This is akin to bluebird's `Promise.props`, but implemented only using + * `Promise.all` so it will work with any implementation of ES6 promises. + */ +export async function promiseForObject(object: TData): Promise> { + const keys = Object.keys(object as any); + const values = Object.values(object as any); + + const resolvedValues = await Promise.all(values); + const resolvedObject = Object.create(null); + for (let i = 0; i < keys.length; ++i) { + resolvedObject[keys[i]] = resolvedValues[i]; + } + return resolvedObject; +} diff --git a/packages/executor/src/execution/subscribe.ts b/packages/executor/src/execution/subscribe.ts deleted file mode 100644 index 973d9188b69..00000000000 --- a/packages/executor/src/execution/subscribe.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { GraphQLError, locatedError, GraphQLFieldResolver, GraphQLSchema } from 'graphql'; -import { - collectFields, - mapAsyncIterator, - inspect, - isAsyncIterable, - createGraphQLError, - Maybe, - addPath, - pathToArray, - getArgumentValues, - ExecutionResult, -} from '@graphql-tools/utils'; -import type { ExecutionArgs, ExecutionContext } from './execute.js'; -import { - assertValidExecutionArguments, - buildExecutionContext, - buildResolveInfo, - execute, - getFieldDef, -} from './execute.js'; -import { TypedDocumentNode } from '@graphql-typed-document-node/core'; - -/** - * Implements the "Subscribe" algorithm described in the GraphQL specification. - * - * Returns a Promise which resolves to either an AsyncIterator (if successful) - * or an ExecutionResult (error). The promise will be rejected if the schema or - * other arguments to this function are invalid, or if the resolved event stream - * is not an async iterable. - * - * If the client-provided arguments to this function do not result in a - * compliant subscription, a GraphQL Response (ExecutionResult) with - * descriptive errors and no data will be returned. - * - * If the source stream could not be created due to faulty subscription - * resolver logic or underlying systems, the promise will resolve to a single - * ExecutionResult containing `errors` and no `data`. - * - * If the operation succeeded, the promise resolves to an AsyncIterator, which - * yields a stream of ExecutionResults representing the response stream. - * - * Accepts either an object with named arguments, or individual arguments. - */ -export async function subscribe(args: ExecutionArgs): Promise | ExecutionResult> { - // Temporary for v15 to v16 migration. Remove in v17 - console.assert( - arguments.length < 2, - 'graphql@16 dropped long-deprecated support for positional arguments, please pass an object instead.' - ); - - const { - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - subscribeFieldResolver, - } = args; - - const resultOrStream = await createSourceEventStream( - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - subscribeFieldResolver - ); - - if (!isAsyncIterable(resultOrStream)) { - return resultOrStream; - } - - // For each payload yielded from a subscription, map it over the normal - // GraphQL `execute` function, with `payload` as the rootValue. - // This implements the "MapSourceToResponseEvent" algorithm described in - // the GraphQL specification. The `execute` function provides the - // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the - // "ExecuteQuery" algorithm, for which `execute` is also used. - const mapSourceToResponse = (payload: unknown) => - execute({ - schema, - document, - rootValue: payload, - contextValue, - variableValues, - operationName, - fieldResolver, - }); - - // Map every source value to a ExecutionResult value as described above. - return mapAsyncIterator(resultOrStream[Symbol.asyncIterator](), mapSourceToResponse); -} - -/** - * Implements the "CreateSourceEventStream" algorithm described in the - * GraphQL specification, resolving the subscription source event stream. - * - * Returns a Promise which resolves to either an AsyncIterable (if successful) - * or an ExecutionResult (error). The promise will be rejected if the schema or - * other arguments to this function are invalid, or if the resolved event stream - * is not an async iterable. - * - * If the client-provided arguments to this function do not result in a - * compliant subscription, a GraphQL Response (ExecutionResult) with - * descriptive errors and no data will be returned. - * - * If the the source stream could not be created due to faulty subscription - * resolver logic or underlying systems, the promise will resolve to a single - * ExecutionResult containing `errors` and no `data`. - * - * If the operation succeeded, the promise resolves to the AsyncIterable for the - * event stream returned by the resolver. - * - * A Source Event Stream represents a sequence of events, each of which triggers - * a GraphQL execution for that event. - * - * This may be useful when hosting the stateful subscription service in a - * different process or machine than the stateless GraphQL execution engine, - * or otherwise separating these two steps. For more on this, see the - * "Supporting Subscriptions at Scale" information in the GraphQL specification. - */ -export async function createSourceEventStream< - TData = { [key: string]: any }, - TVariables = { [key: string]: any }, - TContext = any ->( - schema: GraphQLSchema, - document: TypedDocumentNode, - rootValue?: unknown, - contextValue?: TContext, - variableValues?: TVariables, - operationName?: Maybe, - subscribeFieldResolver?: Maybe> -): Promise | ExecutionResult> { - // If arguments are missing or incorrectly typed, this is an internal - // developer mistake which should throw an early error. - assertValidExecutionArguments(schema, document, variableValues); - - // If a valid execution context cannot be created due to incorrect arguments, - // a "Response" with only errors is returned. - const exeContext = buildExecutionContext({ - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - subscribeFieldResolver, - }); - - // Return early errors if execution context failed. - if (!('schema' in exeContext)) { - return { errors: exeContext }; - } - - try { - const eventStream = await executeSubscription(exeContext); - - // Assert field returned an event stream, otherwise yield an error. - if (!isAsyncIterable(eventStream)) { - throw new Error('Subscription field must return Async Iterable. ' + `Received: ${inspect(eventStream)}.`); - } - - return eventStream; - } catch (error) { - // If it GraphQLError, report it as an ExecutionResult, containing only errors and no data. - // Otherwise treat the error as a system-class error and re-throw it. - if (error instanceof GraphQLError) { - return { errors: [error] }; - } - throw error; - } -} - -async function executeSubscription(exeContext: ExecutionContext): Promise { - const { schema, fragments, operation, variableValues, rootValue } = exeContext; - - const rootType = schema.getSubscriptionType(); - if (rootType == null) { - throw createGraphQLError('Schema is not configured to execute subscription operation.', { nodes: operation }); - } - - const rootFields = collectFields(schema, fragments, variableValues, rootType, operation.selectionSet); - const [responseName, fieldNodes] = [...rootFields.entries()][0]; - const fieldDef = getFieldDef(schema, rootType, fieldNodes[0]); - - if (!fieldDef) { - const fieldName = fieldNodes[0].name.value; - throw createGraphQLError(`The subscription field "${fieldName}" is not defined.`, { nodes: fieldNodes }); - } - - const path = addPath(undefined, responseName, rootType.name); - const info = buildResolveInfo(exeContext, fieldDef, fieldNodes, rootType, path); - - try { - // Implements the "ResolveFieldEventStream" algorithm from GraphQL specification. - // It differs from "ResolveFieldValue" due to providing a different `resolveFn`. - - // Build a JS object of arguments from the field.arguments AST, using the - // variables scope to fulfill any variable references. - const args = getArgumentValues(fieldDef, fieldNodes[0], variableValues); - - // The resolve function's optional third argument is a context value that - // is provided to every resolve function within an execution. It is commonly - // used to represent an authenticated user, or request-specific caches. - const contextValue = exeContext.contextValue; - - // Call the `subscribe()` resolver or the default resolver to produce an - // AsyncIterable yielding raw payloads. - const resolveFn = fieldDef.subscribe ?? exeContext.subscribeFieldResolver; - const eventStream = await resolveFn(rootValue, args, contextValue, info); - - if (eventStream instanceof Error) { - throw eventStream; - } - return eventStream; - } catch (error) { - throw locatedError(error, fieldNodes, pathToArray(path)); - } -} diff --git a/packages/loaders/url/tests/graphql-upload.spec.ts b/packages/loaders/url/tests/graphql-upload.spec.ts index cf0e968cf7c..ee1fae644f2 100644 --- a/packages/loaders/url/tests/graphql-upload.spec.ts +++ b/packages/loaders/url/tests/graphql-upload.spec.ts @@ -1,6 +1,6 @@ import { File } from '@whatwg-node/fetch'; import { readFileSync } from 'fs'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { GraphQLSchema, parse } from 'graphql'; import { join } from 'path'; import { assertNonMaybe, testSchema } from './test-utils'; @@ -85,7 +85,7 @@ describe('GraphQL Upload compatibility', () => { nonObjectVar: 'somefilename.txt', }, }); - + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeFalsy(); assertNonMaybe(result.data); const uploadFileData: any = result.data?.['uploadFile']; diff --git a/packages/loaders/url/tests/url-loader.spec.ts b/packages/loaders/url/tests/url-loader.spec.ts index ab974adeb7d..830220d9006 100644 --- a/packages/loaders/url/tests/url-loader.spec.ts +++ b/packages/loaders/url/tests/url-loader.spec.ts @@ -11,7 +11,7 @@ import { AsyncFetchFn, defaultAsyncFetch } from '../src/defaultAsyncFetch.js'; import { Response, Headers } from '@whatwg-node/fetch'; import { loadSchema } from '@graphql-tools/load'; import { testUrl, testSchema, testTypeDefs, assertNonMaybe } from './test-utils'; -import { execute, subscribe } from '@graphql-tools/executor'; +import { execute, isIncrementalResult, subscribe } from '@graphql-tools/executor'; describe('Schema URL Loader', () => { const loader = new UrlLoader(); @@ -180,6 +180,8 @@ describe('Schema URL Loader', () => { }, }); + if (isIncrementalResult(result)) throw Error('result is incremental'); + expect(result?.errors).toBeFalsy(); expect(result?.data?.['a']).toBe(testVariableValue); diff --git a/packages/resolvers-composition/tests/resolvers-composition.spec.ts b/packages/resolvers-composition/tests/resolvers-composition.spec.ts index cf26590d2dc..fb765c16082 100644 --- a/packages/resolvers-composition/tests/resolvers-composition.spec.ts +++ b/packages/resolvers-composition/tests/resolvers-composition.spec.ts @@ -1,7 +1,7 @@ import gql from 'graphql-tag'; import { composeResolvers, ResolversComposerMapping } from '../src/index.js'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { GraphQLScalarType, Kind } from 'graphql'; import { inspect } from '@graphql-tools/utils'; @@ -55,6 +55,7 @@ describe('Resolvers composition', () => { } `, }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeFalsy(); expect(result.data!['foo']).toBe('FOOFOO'); }); @@ -95,6 +96,7 @@ describe('Resolvers composition', () => { } `, }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeFalsy(); expect(result.data!['foo']).toBe('FOOFOO'); }); @@ -174,6 +176,7 @@ describe('Resolvers composition', () => { } `, }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeFalsy(); expect(result.data!['foo']).toBe('FOOFOO'); }); diff --git a/packages/schema/tests/schemaGenerator.test.ts b/packages/schema/tests/schemaGenerator.test.ts index 40b970de0d4..7ba53d29e8a 100644 --- a/packages/schema/tests/schemaGenerator.test.ts +++ b/packages/schema/tests/schemaGenerator.test.ts @@ -23,7 +23,7 @@ import { makeExecutableSchema, addResolversToSchema, chainResolvers } from '@gra import { IResolverValidationOptions, IResolvers, ExecutionResult, TypeSource } from '@graphql-tools/utils'; import TypeA from './fixtures/circularSchemaA.js'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; interface Bird { name: string; @@ -2045,6 +2045,7 @@ describe('can specify lexical parser options', () => { const hoistedQuery = hoist(parsedQuery); const result = await execute({ schema: jsSchema, document: hoistedQuery }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.data).toEqual({ hello: 'hello world' }); const result2 = await execute({ @@ -2054,6 +2055,7 @@ describe('can specify lexical parser options', () => { phrase: 'world again!', }, }); + if (isIncrementalResult(result2)) throw Error('result is incremental'); expect(result2.data).toEqual({ hello: 'hello world again!' }); }); }); diff --git a/packages/stitch/src/getFieldsNotInSubschema.ts b/packages/stitch/src/getFieldsNotInSubschema.ts index e7668cf80db..3fdb2b32d4e 100644 --- a/packages/stitch/src/getFieldsNotInSubschema.ts +++ b/packages/stitch/src/getFieldsNotInSubschema.ts @@ -12,7 +12,13 @@ export function getFieldsNotInSubschema( fragments: Record, variableValues: Record ): Array { - const subFieldNodesByResponseKey = collectSubFields(schema, fragments, variableValues, gatewayType, fieldNodes); + const { fields: subFieldNodesByResponseKey } = collectSubFields( + schema, + fragments, + variableValues, + gatewayType, + fieldNodes + ); // TODO: Verify whether it is safe that extensions always exists. const fieldNodesByField = stitchingInfo?.fieldNodesByField; diff --git a/packages/stitch/src/stitchingInfo.ts b/packages/stitch/src/stitchingInfo.ts index 4b63145b957..cceb2ef839a 100644 --- a/packages/stitch/src/stitchingInfo.ts +++ b/packages/stitch/src/stitchingInfo.ts @@ -269,17 +269,9 @@ export function completeStitchingInfo>( const type = schema.getType(typeName) as GraphQLObjectType; for (const fieldName in selectionSetsByField[typeName]) { for (const selectionSet of selectionSetsByField[typeName][fieldName]) { - const fieldNodesByResponseKey = collectFields( - schema, - fragments, - variableValues, - type, - selectionSet, - new Map(), - new Set() - ); + const { fields } = collectFields(schema, fragments, variableValues, type, selectionSet); - for (const [, fieldNodes] of fieldNodesByResponseKey) { + for (const [, fieldNodes] of fields) { for (const fieldNode of fieldNodes) { const key = print(fieldNode); if (fieldNodeMap[key] == null) { diff --git a/packages/utils/src/AccumulatorMap.ts b/packages/utils/src/AccumulatorMap.ts new file mode 100644 index 00000000000..156fe71c207 --- /dev/null +++ b/packages/utils/src/AccumulatorMap.ts @@ -0,0 +1,17 @@ +/** + * ES6 Map with additional `add` method to accumulate items. + */ +export class AccumulatorMap extends Map> { + get [Symbol.toStringTag]() { + return 'AccumulatorMap'; + } + + add(key: K, item: T): void { + const group = this.get(key); + if (group === undefined) { + this.set(key, [item]); + } else { + group.push(item); + } + } +} diff --git a/packages/utils/src/Interfaces.ts b/packages/utils/src/Interfaces.ts index 1ea4d8e9212..c2bbd4cbdfe 100644 --- a/packages/utils/src/Interfaces.ts +++ b/packages/utils/src/Interfaces.ts @@ -53,12 +53,17 @@ import { * * - `errors` is included when any errors occurred as a non-empty array. * - `data` is the result of a successful execution of the query. + * - `hasNext` is true if a future payload is expected. * - `extensions` is reserved for adding non-standard properties. */ export interface ExecutionResult { - errors?: ReadonlyArray; + incremental?: ReadonlyArray>; data?: TData | null; + errors?: ReadonlyArray; + hasNext?: boolean; extensions?: TExtensions; + label?: string; + path?: ReadonlyArray; } export interface ExecutionRequest< diff --git a/packages/utils/src/collectFields.ts b/packages/utils/src/collectFields.ts index 3b963c60482..476e39adc1b 100644 --- a/packages/utils/src/collectFields.ts +++ b/packages/utils/src/collectFields.ts @@ -14,30 +14,36 @@ import { isAbstractType, typeFromAST, } from 'graphql'; +import { GraphQLDeferDirective } from './directives.js'; +import { AccumulatorMap } from './AccumulatorMap.js'; -// Taken from GraphQL-JS v16 for backwards compat -export function collectFields( +export interface PatchFields { + label: string | undefined; + fields: Map>; +} + +export interface FieldsAndPatches { + fields: Map>; + patches: Array; +} + +function collectFieldsImpl( schema: GraphQLSchema, fragments: Record, - variableValues: { [variable: string]: unknown }, + variableValues: TVariables, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - fields: Map> = new Map(), - visitedFragmentNames: Set = new Set() -): Map> { + fields: AccumulatorMap, + patches: Array, + visitedFragmentNames: Set +): void { for (const selection of selectionSet.selections) { switch (selection.kind) { case Kind.FIELD: { if (!shouldIncludeNode(variableValues, selection)) { continue; } - const name = getFieldEntryKey(selection); - const fieldList = fields.get(name); - if (fieldList !== undefined) { - fieldList.push(selection); - } else { - fields.set(name, [selection]); - } + fields.add(getFieldEntryKey(selection), selection); break; } case Kind.INLINE_FRAGMENT: { @@ -47,49 +53,121 @@ export function collectFields( ) { continue; } - collectFields( - schema, - fragments, - variableValues, - runtimeType, - selection.selectionSet, - fields, - visitedFragmentNames - ); + + const defer = getDeferValues(variableValues, selection); + + if (defer) { + const patchFields = new AccumulatorMap(); + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + selection.selectionSet, + patchFields, + patches, + visitedFragmentNames + ); + patches.push({ + label: defer.label, + fields: patchFields, + }); + } else { + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + selection.selectionSet, + fields, + patches, + visitedFragmentNames + ); + } break; } case Kind.FRAGMENT_SPREAD: { const fragName = selection.name.value; - if (visitedFragmentNames.has(fragName) || !shouldIncludeNode(variableValues, selection)) { + + if (!shouldIncludeNode(variableValues, selection)) { + continue; + } + + const defer = getDeferValues(variableValues, selection); + if (visitedFragmentNames.has(fragName) && !defer) { continue; } - visitedFragmentNames.add(fragName); + const fragment = fragments[fragName]; if (!fragment || !doesFragmentConditionMatch(schema, fragment, runtimeType)) { continue; } - collectFields( - schema, - fragments, - variableValues, - runtimeType, - fragment.selectionSet, - fields, - visitedFragmentNames - ); + + if (!defer) { + visitedFragmentNames.add(fragName); + } + + if (defer) { + const patchFields = new AccumulatorMap(); + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + fragment.selectionSet, + patchFields, + patches, + visitedFragmentNames + ); + patches.push({ + label: defer.label, + fields: patchFields, + }); + } else { + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + fragment.selectionSet, + fields, + patches, + visitedFragmentNames + ); + } break; } } } - return fields; +} + +/** + * Given a selectionSet, collects all of the fields and returns them. + * + * CollectFields requires the "runtime type" of an object. For a field that + * returns an Interface or Union type, the "runtime type" will be the actual + * object type returned by that field. + * + */ +export function collectFields( + schema: GraphQLSchema, + fragments: Record, + variableValues: TVariables, + runtimeType: GraphQLObjectType, + selectionSet: SelectionSetNode +): FieldsAndPatches { + const fields = new AccumulatorMap(); + const patches: Array = []; + collectFieldsImpl(schema, fragments, variableValues, runtimeType, selectionSet, fields, patches, new Set()); + return { fields, patches }; } /** * Determines if a field should be included based on the `@include` and `@skip` * directives, where `@skip` has higher precedence than `@include`. */ -function shouldIncludeNode( - variableValues: { [variable: string]: unknown }, +export function shouldIncludeNode( + variableValues: any, node: FragmentSpreadNode | FieldNode | InlineFragmentNode ): boolean { const skip = getDirectiveValues(GraphQLSkipDirective, node, variableValues); @@ -107,7 +185,7 @@ function shouldIncludeNode( /** * Determines if a fragment is applicable to the given type. */ -function doesFragmentConditionMatch( +export function doesFragmentConditionMatch( schema: GraphQLSchema, fragment: FragmentDefinitionNode | InlineFragmentNode, type: GraphQLObjectType @@ -130,33 +208,72 @@ function doesFragmentConditionMatch( /** * Implements the logic to compute the key of a given field's entry */ -function getFieldEntryKey(node: FieldNode): string { +export function getFieldEntryKey(node: FieldNode): string { return node.alias ? node.alias.value : node.name.value; } -export const collectSubFields = memoize5(function collectSubFields( +/** + * Returns an object containing the `@defer` arguments if a field should be + * deferred based on the experimental flag, defer directive present and + * not disabled by the "if" argument. + */ +export function getDeferValues( + variableValues: any, + node: FragmentSpreadNode | InlineFragmentNode +): undefined | { label: string | undefined } { + const defer = getDirectiveValues(GraphQLDeferDirective, node, variableValues); + + if (!defer) { + return; + } + + if (defer['if'] === false) { + return; + } + + return { + label: typeof defer['label'] === 'string' ? defer['label'] : undefined, + }; +} + +/** + * Given an array of field nodes, collects all of the subfields of the passed + * in fields, and returns them at the end. + * + * CollectSubFields requires the "return type" of an object. For a field that + * returns an Interface or Union type, the "return type" will be the actual + * object type returned by that field. + * + */ +export const collectSubFields = memoize5(function collectSubfields( schema: GraphQLSchema, fragments: Record, - variableValues: Record, - type: GraphQLObjectType, + variableValues: { [variable: string]: unknown }, + returnType: GraphQLObjectType, fieldNodes: Array -): Map> { - const subFieldNodes = new Map>(); +): FieldsAndPatches { + const subFieldNodes = new AccumulatorMap(); const visitedFragmentNames = new Set(); - for (const fieldNode of fieldNodes) { - if (fieldNode.selectionSet) { - collectFields( + const subPatches: Array = []; + const subFieldsAndPatches = { + fields: subFieldNodes, + patches: subPatches, + }; + + for (const node of fieldNodes) { + if (node.selectionSet) { + collectFieldsImpl( schema, fragments, variableValues, - type, - fieldNode.selectionSet, + returnType, + node.selectionSet, subFieldNodes, + subPatches, visitedFragmentNames ); } } - - return subFieldNodes; + return subFieldsAndPatches; }); diff --git a/packages/utils/src/directives.ts b/packages/utils/src/directives.ts new file mode 100644 index 00000000000..071cae63965 --- /dev/null +++ b/packages/utils/src/directives.ts @@ -0,0 +1,53 @@ +import { + DirectiveLocation, + GraphQLBoolean, + GraphQLDirective, + GraphQLInt, + GraphQLNonNull, + GraphQLString, +} from 'graphql'; + +/** + * Used to conditionally defer fragments. + */ +export const GraphQLDeferDirective = new GraphQLDirective({ + name: 'defer', + description: 'Directs the executor to defer this fragment when the `if` argument is true or undefined.', + locations: [DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], + args: { + if: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Deferred when true or undefined.', + defaultValue: true, + }, + label: { + type: GraphQLString, + description: 'Unique name', + }, + }, +}); + +/** + * Used to conditionally stream list fields. + */ +export const GraphQLStreamDirective = new GraphQLDirective({ + name: 'stream', + description: 'Directs the executor to stream plural fields when the `if` argument is true or undefined.', + locations: [DirectiveLocation.FIELD], + args: { + if: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Stream when true or undefined.', + defaultValue: true, + }, + label: { + type: GraphQLString, + description: 'Unique name', + }, + initialCount: { + defaultValue: 0, + type: GraphQLInt, + description: 'Number of items to return immediately', + }, + }, +}); diff --git a/packages/utils/src/executor.ts b/packages/utils/src/executor.ts index a2c14ac2a6d..00c28f20ee0 100644 --- a/packages/utils/src/executor.ts +++ b/packages/utils/src/executor.ts @@ -1,6 +1,6 @@ import { ExecutionResult, ExecutionRequest } from './Interfaces.js'; -export type MaybePromise = Promise | T; +export type MaybePromise = PromiseLike | T; export type MaybeAsyncIterable = AsyncIterable | T; export type AsyncExecutor, TBaseExtensions = Record> = < diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1cbb8997ecf..ce56d28cfdd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -52,3 +52,4 @@ export * from './getOperationASTFromRequest.js'; export * from './extractExtensionsFromSchema.js'; export * from './Path.js'; export * from './jsutils.js'; +export * from './directives.js'; diff --git a/packages/utils/src/jsutils.ts b/packages/utils/src/jsutils.ts index 3006bfa0952..ce2934fc293 100644 --- a/packages/utils/src/jsutils.ts +++ b/packages/utils/src/jsutils.ts @@ -8,7 +8,7 @@ export function isObjectLike(value: unknown): value is { [key: string]: unknown return typeof value === 'object' && value !== null; } -export function isPromise(value: unknown): value is Promise { +export function isPromise(value: unknown): value is PromiseLike { return isObjectLike(value) && typeof value['then'] === 'function'; } diff --git a/packages/utils/src/visitResult.ts b/packages/utils/src/visitResult.ts index 9c94e506e27..cbeb8f18dc5 100644 --- a/packages/utils/src/visitResult.ts +++ b/packages/utils/src/visitResult.ts @@ -179,14 +179,12 @@ function visitRoot( errorInfo: ErrorInfo ): any { const operationRootType = getOperationRootType(schema, operation)!; - const collectedFields = collectFields( + const { fields: collectedFields } = collectFields( schema, fragments, variableValues, operationRootType, - operation.selectionSet, - new Map(), - new Set() + operation.selectionSet ); return visitObjectValue( @@ -378,7 +376,7 @@ function visitFieldValue( ); } else if (isAbstractType(nullableType)) { const finalType = schema.getType(value.__typename) as GraphQLObjectType; - const collectedFields = collectSubFields(schema, fragments, variableValues, finalType, fieldNodes); + const { fields: collectedFields } = collectSubFields(schema, fragments, variableValues, finalType, fieldNodes); return visitObjectValue( value, finalType, @@ -392,7 +390,7 @@ function visitFieldValue( errorInfo ); } else if (isObjectType(nullableType)) { - const collectedFields = collectSubFields(schema, fragments, variableValues, nullableType, fieldNodes); + const { fields: collectedFields } = collectSubFields(schema, fragments, variableValues, nullableType, fieldNodes); return visitObjectValue( value, nullableType, diff --git a/packages/utils/tests/AccumulatorMap-test.ts b/packages/utils/tests/AccumulatorMap-test.ts new file mode 100644 index 00000000000..69141eec1b9 --- /dev/null +++ b/packages/utils/tests/AccumulatorMap-test.ts @@ -0,0 +1,31 @@ +import { AccumulatorMap } from '../src/AccumulatorMap'; + +function expectMap(map: Map) { + return expect(Object.fromEntries(map)); +} + +describe('AccumulatorMap', () => { + it('can be Object.toStringified', () => { + const accumulatorMap = new AccumulatorMap(); + + expect(Object.prototype.toString.call(accumulatorMap)).toEqual('[object AccumulatorMap]'); + }); + + it('accumulate items', () => { + const accumulatorMap = new AccumulatorMap(); + + expectMap(accumulatorMap).toEqual({}); + + accumulatorMap.add('a', 1); + accumulatorMap.add('b', 2); + accumulatorMap.add('c', 3); + accumulatorMap.add('b', 4); + accumulatorMap.add('c', 5); + accumulatorMap.add('c', 6); + expectMap(accumulatorMap).toEqual({ + a: [1], + b: [2, 4], + c: [3, 5, 6], + }); + }); +}); diff --git a/packages/wrap/tests/transformFilterInputObjectFields.test.ts b/packages/wrap/tests/transformFilterInputObjectFields.test.ts index fde82e46f68..733cbeb135a 100644 --- a/packages/wrap/tests/transformFilterInputObjectFields.test.ts +++ b/packages/wrap/tests/transformFilterInputObjectFields.test.ts @@ -2,7 +2,7 @@ import { wrapSchema, FilterInputObjectFields } from '@graphql-tools/wrap'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { graphql, astFromValue, Kind, GraphQLString, parse } from 'graphql'; import { assertSome } from '@graphql-tools/utils'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; describe('FilterInputObjectFields', () => { const schema = makeExecutableSchema({ @@ -73,6 +73,7 @@ describe('FilterInputObjectFields', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); assertSome(result.data); expect(result.errors).toBeUndefined(); const dataTest: any = result.data['test']; diff --git a/packages/wrap/tests/transformMapLeafValues.test.ts b/packages/wrap/tests/transformMapLeafValues.test.ts index f8e3de67ffb..3e6d9f61648 100644 --- a/packages/wrap/tests/transformMapLeafValues.test.ts +++ b/packages/wrap/tests/transformMapLeafValues.test.ts @@ -1,6 +1,6 @@ import { wrapSchema, MapLeafValues } from '@graphql-tools/wrap'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { GraphQLSchema, parse } from 'graphql'; import { assertSome } from '@graphql-tools/utils'; @@ -55,6 +55,8 @@ describe('MapLeafValues', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); + assertSome(result.data); expect(result.data['testEnum']).toBe('THREE'); expect(result.data['testScalar']).toBe(15); @@ -74,6 +76,7 @@ describe('MapLeafValues', () => { argument: 5, }, }); + if (isIncrementalResult(result)) throw Error('result is incremental'); assertSome(result.data); expect(result.data['testEnum']).toBe('THREE'); expect(result.data['testScalar']).toBe(15); diff --git a/packages/wrap/tests/transformRenameInputObjectFields.test.ts b/packages/wrap/tests/transformRenameInputObjectFields.test.ts index b2756bbd491..b883227a4ed 100644 --- a/packages/wrap/tests/transformRenameInputObjectFields.test.ts +++ b/packages/wrap/tests/transformRenameInputObjectFields.test.ts @@ -1,6 +1,6 @@ import { wrapSchema, RenameInputObjectFields } from '@graphql-tools/wrap'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { parse } from 'graphql'; import { assertSome } from '@graphql-tools/utils'; @@ -55,6 +55,7 @@ describe('RenameInputObjectFields', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); assertSome(result.data); const testData: any = result.data['test']; expect(testData.field1).toBe('field1'); @@ -111,6 +112,8 @@ describe('RenameInputObjectFields', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); + assertSome(result.data); const testData: any = result.data['test']; expect(testData.field1).toBe('field1'); @@ -176,6 +179,7 @@ describe('RenameInputObjectFields', () => { }, }; const result = await execute({ schema: transformedSchema, document: parse(query), variableValues: variables }); + if (isIncrementalResult(result)) throw Error('result is incremental'); assertSome(result.data); const testData: any = result.data['test']; expect(testData.field1).toBe('field1'); diff --git a/packages/wrap/tests/transformRenameObjectFieldArguments.test.ts b/packages/wrap/tests/transformRenameObjectFieldArguments.test.ts index 48c28674236..8122e883d02 100644 --- a/packages/wrap/tests/transformRenameObjectFieldArguments.test.ts +++ b/packages/wrap/tests/transformRenameObjectFieldArguments.test.ts @@ -1,6 +1,6 @@ import { wrapSchema } from '@graphql-tools/wrap'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; import { parse } from 'graphql'; import { assertSome } from '@graphql-tools/utils'; @@ -58,6 +58,8 @@ describe('RenameObjectFieldArguments', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); + assertSome(result.data); const testData: any = result.data['test']; expect(testData.field1).toBe('field1'); @@ -124,6 +126,7 @@ describe('RenameObjectFieldArguments', () => { }, }; const result = await execute({ schema: transformedSchema, document: parse(query), variableValues: variables }); + if (isIncrementalResult(result)) throw Error('result is incremental'); assertSome(result.data); const testData: any = result.data['test']; expect(testData.field1).toBe('field1'); diff --git a/packages/wrap/tests/transformTransformCompositeFields.test.ts b/packages/wrap/tests/transformTransformCompositeFields.test.ts index 63424f2f5c6..69a70e8131b 100644 --- a/packages/wrap/tests/transformTransformCompositeFields.test.ts +++ b/packages/wrap/tests/transformTransformCompositeFields.test.ts @@ -1,7 +1,7 @@ import { wrapSchema, TransformCompositeFields } from '@graphql-tools/wrap'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { parse } from 'graphql'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; const baseSchema = makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -35,6 +35,7 @@ describe('TransformCompositeFields', () => { schema: transformedSchema, document: parse('{ product { id, theId: id } }'), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.data).toEqual({ product: { id: 'r2d2c3p0', theId: 'r2d2c3p0' }, }); @@ -65,6 +66,7 @@ describe('TransformCompositeFields', () => { schema: transformedSchema, document: parse('{ product { theId: id } }'), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.data).toEqual({ product: { theId: 'R2D2C3P0' }, }); @@ -91,6 +93,7 @@ describe('TransformCompositeFields', () => { schema: transformedSchema, document: parse('{ product { _id } }'), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(dataObjects).toEqual(['Query', 'Product']); expect(result.data).toEqual({ product: { _id: 'R2D2C3P0' }, diff --git a/packages/wrap/tests/transformTransformEnumValues.test.ts b/packages/wrap/tests/transformTransformEnumValues.test.ts index 62b272fd4a7..9d4776354e7 100644 --- a/packages/wrap/tests/transformTransformEnumValues.test.ts +++ b/packages/wrap/tests/transformTransformEnumValues.test.ts @@ -1,7 +1,7 @@ import { wrapSchema, TransformEnumValues } from '@graphql-tools/wrap'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { GraphQLEnumType, parse } from 'graphql'; -import { execute } from '@graphql-tools/executor'; +import { execute, isIncrementalResult } from '@graphql-tools/executor'; function assertGraphQLEnumType(input: unknown): asserts input is GraphQLEnumType { if (input instanceof GraphQLEnumType) { @@ -44,6 +44,7 @@ describe('TransformEnumValues', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeUndefined(); }); @@ -88,6 +89,7 @@ describe('TransformEnumValues', () => { schema: transformedSchema, document: parse(query), }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeUndefined(); const TestEnum = transformedSchema.getType('TestEnum'); assertGraphQLEnumType(TestEnum); @@ -130,6 +132,7 @@ describe('TransformEnumValues', () => { test: 'UNO', }, }); + if (isIncrementalResult(result)) throw Error('result is incremental'); expect(result.errors).toBeUndefined(); }); }); diff --git a/yarn.lock b/yarn.lock index 851a927464e..c4b7cc45d79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1416,7 +1416,7 @@ "@changesets/types" "^5.2.0" dotenv "^8.1.0" -"@changesets/cli@2.25.2": +"@changesets/cli@2.25.2", "@changesets/cli@^2.16.0": version "2.25.2" resolved "https://registry.yarnpkg.com/@changesets/cli/-/cli-2.25.2.tgz#fc5e894aa6f85c60749a035352dec3dcbd275c71" integrity sha512-ACScBJXI3kRyMd2R8n8SzfttDHi4tmKSwVwXBazJOylQItSRSF4cGmej2E4FVf/eNfGy6THkL9GzAahU9ErZrA== @@ -2480,7 +2480,7 @@ tiny-warning "^1.0.3" tslib "^2.3.0" -"@repeaterjs/repeater@^3.0.4": +"@repeaterjs/repeater@3.0.4", "@repeaterjs/repeater@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.4.tgz#a04d63f4d1bf5540a41b01a921c9a7fddc3bd1ca" integrity sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA== @@ -12036,6 +12036,13 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +value-or-promise@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.1.tgz#7021919262c7a13605da701bcbd3c9ae8219bf68" + integrity sha512-luIWMQACiZgNXrrCVX0B1Lm5bTT+osgLG/uiBMVvxYa52oqHGoF9YGpW+azBThx84N6bAm5MyaodRvsWaYmVbQ== + dependencies: + "@changesets/cli" "^2.16.0" + value-or-promise@1.0.11, value-or-promise@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.11.tgz#3e90299af31dd014fe843fe309cefa7c1d94b140"