diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts new file mode 100644 index 0000000000..2d1cef55bf --- /dev/null +++ b/src/execution/__tests__/defer-test.ts @@ -0,0 +1,566 @@ +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + +import type { DocumentNode } from '../../language/ast'; +import { parse } from '../../language/parser'; + +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition'; +import { GraphQLID, GraphQLString } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; + +import { execute } from '../execute'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + 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, + }, + }, + name: 'Hero', +}); + +const hero = { name: 'Luke', id: 1 }; + +const query = new GraphQLObjectType({ + fields: { + hero: { + type: heroType, + resolve: () => hero, + }, + }, + name: 'Query', +}); + +async function complete(document: DocumentNode) { + const schema = new GraphQLSchema({ query }); + + const result = await execute({ + schema, + document, + rootValue: {}, + }); + + if (isAsyncIterable(result)) { + const results = []; + for await (const patch of result) { + 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); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + 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('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, + }, + { + 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, + }, + { + 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, + }, + { + data: { + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + }, + path: ['hero'], + label: 'DeferNested', + hasNext: true, + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + { + 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 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, + }, + { + 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, + }, + { + data: { + slowField: 'slow', + friends: [{}, {}, {}], + }, + path: ['hero'], + hasNext: true, + }, + { + data: { name: 'Han' }, + path: ['hero', 'friends', 0], + hasNext: true, + }, + { + data: { name: 'Leia' }, + path: ['hero', 'friends', 1], + hasNext: true, + }, + { + 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, + }, + { + data: { + name: 'Luke', + friends: [{}, {}, {}], + }, + path: ['hero'], + hasNext: true, + }, + { + data: { name: 'Han' }, + path: ['hero', 'friends', 0], + hasNext: true, + }, + { + data: { name: 'Leia' }, + path: ['hero', 'friends', 1], + hasNext: true, + }, + { + data: { name: 'C-3PO' }, + path: ['hero', 'friends', 2], + hasNext: false, + }, + ]); + }); +}); diff --git a/src/execution/__tests__/flattenAsyncIterator-test.ts b/src/execution/__tests__/flattenAsyncIterator-test.ts new file mode 100644 index 0000000000..c2d3dcb4b3 --- /dev/null +++ b/src/execution/__tests__/flattenAsyncIterator-test.ts @@ -0,0 +1,145 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { flattenAsyncIterator } from '../flattenAsyncIterator'; + +describe('flattenAsyncIterator', () => { + it('does not modify an already flat async generator', async () => { + async function* source() { + yield await Promise.resolve(1); + yield await Promise.resolve(2); + yield await Promise.resolve(3); + } + + const result = flattenAsyncIterator(source()); + + expect(await result.next()).to.deep.equal({ value: 1, done: false }); + expect(await result.next()).to.deep.equal({ value: 2, done: false }); + expect(await result.next()).to.deep.equal({ value: 3, done: false }); + expect(await result.next()).to.deep.equal({ + value: undefined, + done: true, + }); + }); + + it('does not modify an already flat async iterator', async () => { + const items = [1, 2, 3]; + + const iterator: any = { + [Symbol.asyncIterator]() { + return this; + }, + next() { + return Promise.resolve({ + done: items.length === 0, + value: items.shift(), + }); + }, + }; + + const result = flattenAsyncIterator(iterator); + + expect(await result.next()).to.deep.equal({ value: 1, done: false }); + expect(await result.next()).to.deep.equal({ value: 2, done: false }); + expect(await result.next()).to.deep.equal({ value: 3, done: false }); + expect(await result.next()).to.deep.equal({ + value: undefined, + done: true, + }); + }); + + it('flatten nested async generators', async () => { + async function* source() { + yield await Promise.resolve(1); + yield await Promise.resolve(2); + yield await Promise.resolve( + (async function* nested(): AsyncGenerator { + yield await Promise.resolve(2.1); + yield await Promise.resolve(2.2); + })(), + ); + yield await Promise.resolve(3); + } + + const doubles = flattenAsyncIterator(source()); + + const result = []; + for await (const x of doubles) { + result.push(x); + } + expect(result).to.deep.equal([1, 2, 2.1, 2.2, 3]); + }); + + it('allows returning early from a nested async generator', async () => { + async function* source() { + yield await Promise.resolve(1); + yield await Promise.resolve(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(3); + } + /* c8 ignore stop */ + + const doubles = flattenAsyncIterator(source()); + + expect(await doubles.next()).to.deep.equal({ value: 1, done: false }); + expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); + expect(await doubles.next()).to.deep.equal({ value: 2.1, done: false }); + + // Early return + expect(await doubles.return()).to.deep.equal({ + value: undefined, + done: true, + }); + + // Subsequent next calls + expect(await doubles.next()).to.deep.equal({ + value: undefined, + done: true, + }); + expect(await doubles.next()).to.deep.equal({ + value: undefined, + done: true, + }); + }); + + it('allows throwing errors from a nested async generator', async () => { + async function* source() { + yield await Promise.resolve(1); + yield await Promise.resolve(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(3); + } + /* c8 ignore stop */ + + const doubles = flattenAsyncIterator(source()); + + expect(await doubles.next()).to.deep.equal({ value: 1, done: false }); + expect(await doubles.next()).to.deep.equal({ value: 2, done: false }); + expect(await doubles.next()).to.deep.equal({ value: 2.1, done: false }); + + // Throw error + let caughtError; + try { + await doubles.throw('ouch'); /* c8 ignore start */ + // Not reachable, always throws + /* c8 ignore stop */ + } catch (e) { + caughtError = e; + } + expect(caughtError).to.equal('ouch'); + }); +}); diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index 3fdd77ab56..52addf04ac 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -14,7 +14,7 @@ import { GraphQLSchema } from '../../type/schema'; import { buildSchema } from '../../utilities/buildASTSchema'; -import type { ExecutionResult } from '../execute'; +import type { AsyncExecutionResult, ExecutionResult } from '../execute'; import { execute, executeSync } from '../execute'; describe('Execute: Accepts any iterable as list value', () => { @@ -85,7 +85,9 @@ describe('Execute: Accepts async iterables as list value', () => { function completeObjectList( resolve: GraphQLFieldResolver<{ index: number }, unknown>, - ): PromiseOrValue { + ): PromiseOrValue< + ExecutionResult | AsyncGenerator + > { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', diff --git a/src/execution/__tests__/mutations-test.ts b/src/execution/__tests__/mutations-test.ts index 0f0ad1cbf8..563864c591 100644 --- a/src/execution/__tests__/mutations-test.ts +++ b/src/execution/__tests__/mutations-test.ts @@ -1,9 +1,11 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + import { parse } from '../../language/parser'; import { GraphQLObjectType } from '../../type/definition'; @@ -50,6 +52,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', }); @@ -191,4 +200,122 @@ 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 = []; + + assert(isAsyncIterable(mutationResult)); + for await (const patch of mutationResult) { + patches.push(patch); + } + + expect(patches).to.deep.equal([ + { + data: { + first: {}, + second: { theNumber: 2 }, + }, + hasNext: true, + }, + { + 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 }); + + expect(mutationResult).to.deep.equal({ + 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 = []; + + assert(isAsyncIterable(mutationResult)); + for await (const patch of mutationResult) { + patches.push(patch); + } + + expect(patches).to.deep.equal([ + { + data: { + second: { theNumber: 2 }, + }, + hasNext: true, + }, + { + label: 'defer-label', + path: [], + data: { + first: { + theNumber: 1, + }, + }, + hasNext: false, + }, + ]); + }); }); diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 427f2a64d6..85a1aff018 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -3,6 +3,8 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue'; + import { parse } from '../../language/parser'; import { GraphQLNonNull, GraphQLObjectType } from '../../type/definition'; @@ -11,7 +13,7 @@ import { GraphQLSchema } from '../../type/schema'; import { buildSchema } from '../../utilities/buildASTSchema'; -import type { ExecutionResult } from '../execute'; +import type { AsyncExecutionResult, ExecutionResult } from '../execute'; import { execute, executeSync } from '../execute'; const syncError = new Error('sync'); @@ -109,7 +111,9 @@ const schema = buildSchema(` function executeQuery( query: string, rootValue: unknown, -): ExecutionResult | Promise { +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { return execute({ schema, document: parse(query), rootValue }); } diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts new file mode 100644 index 0000000000..1e2d73b02e --- /dev/null +++ b/src/execution/__tests__/stream-test.ts @@ -0,0 +1,1471 @@ +import { assert } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + +import type { DocumentNode } from '../../language/ast'; +import { parse } from '../../language/parser'; + +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition'; +import { GraphQLID, GraphQLString } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; + +import { execute } from '../execute'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + asyncName: { + type: GraphQLString, + async resolve(rootValue) { + // wait for parent stream to close + await new Promise((r) => setTimeout(r, 0)); + return rootValue.name; + }, + }, + }, + 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), + resolve: () => ['apple', 'banana', 'coconut'], + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + resolve: () => [ + ['apple', 'apple', 'apple'], + ['banana', 'banana', 'banana'], + ['coconut', 'coconut', 'coconut'], + ], + }, + asyncList: { + type: new GraphQLList(friendType), + resolve: () => friends.map((f) => Promise.resolve(f)), + }, + asyncSlowList: { + type: new GraphQLList(friendType), + resolve: () => + friends.map(async (f, i) => { + if (i === 0) { + await resolveOnNextTick(); + } + return f; + }), + }, + nonNullError: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + resolve: () => [friends[0], null], + }, + nonNullAsyncError: { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLObjectType({ + name: 'nonNullAsyncErrorListItem', + fields: { + string: { + type: new GraphQLNonNull(GraphQLString), + resolve: (root) => { + if (root.string === 'error') { + return Promise.reject(new Error('Oops')); + } + return Promise.resolve(root.string); + }, + }, + }, + }), + ), + ), + resolve() { + return [ + { string: friends[0].name }, + { string: 'error' }, + { string: friends[1].name }, + ]; + }, + }, + asyncListError: { + type: new GraphQLList(friendType), + resolve: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(f); + }), + }, + asyncIterableList: { + type: new GraphQLList(friendType), + async *resolve() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }, + asyncIterableError: { + type: new GraphQLList(friendType), + async *resolve() { + yield await Promise.resolve(friends[0]); + throw new Error('bad'); + }, + }, + asyncIterableNonNullError: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + async *resolve() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(null); + yield await Promise.resolve(friends[1]); + }, + }, + asyncIterableInvalid: { + type: new GraphQLList(GraphQLString), + async *resolve() { + yield await Promise.resolve(friends[0].name); + yield await Promise.resolve({}); + }, + }, + asyncIterableAsyncInvalid: { + type: new GraphQLList( + new GraphQLObjectType({ + name: 'asyncIterableAsyncInvalidListItem', + fields: { + string: { + type: new GraphQLNonNull(GraphQLString), + resolve: (root) => { + if (root.string === 'error') { + return Promise.reject(new Error('Oops')); + } + return Promise.resolve(root.string); + }, + }, + }, + }), + ), + async *resolve() { + yield await Promise.resolve({ string: friends[0].name }); + yield await Promise.resolve({ string: 'error' }); + yield await Promise.resolve({ string: friends[1].name }); + }, + }, + asyncIterableListDelayed: { + type: new GraphQLList(friendType), + async *resolve() { + for (const friend of friends) { + // pause an additional ms before yielding to allow time + // for tests to return or throw before next value is processed. + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + yield friend; /* c8 ignore start */ + // Not reachable, early return + } + } /* c8 ignore stop */, + }, + asyncIterableListNoReturn: { + type: new GraphQLList(friendType), + resolve() { + let i = 0; + return { + [Symbol.asyncIterator]: () => ({ + async next() { + const friend = friends[i++]; + if (friend) { + await resolveOnNextTick(); + return { value: friend, done: false }; + } + return { value: undefined, done: true }; + }, + }), + }; + }, + }, + asyncIterableListDelayedClose: { + type: new GraphQLList(friendType), + async *resolve() { + for (const friend of friends) { + yield friend; + } + await new Promise((r) => setTimeout(r, 0)); + }, + }, + nestedObject: { + type: new GraphQLObjectType({ + name: 'NestedObject', + fields: { + slowField: { + type: GraphQLString, + resolve: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }, + asyncIterableList: { + type: new GraphQLList(friendType), + async *resolve() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }, + }, + }), + resolve: () => ({}), + }, + }, + name: 'Query', +}); + +async function complete(document: DocumentNode, rootValue: unknown = {}) { + const schema = new GraphQLSchema({ query }); + const result = await execute({ schema, document, rootValue }); + + if (isAsyncIterable(result)) { + const results = []; + for await (const patch of result) { + results.push(patch); + } + return results; + } + return result; +} + +async function completeAsync(document: DocumentNode, numCalls: number) { + const schema = new GraphQLSchema({ query }); + + const result = await execute({ schema, document, rootValue: {} }); + + assert(isAsyncIterable(result)); + + const iterator = result[Symbol.asyncIterator](); + + const promises = []; + for (let i = 0; i < numCalls; i++) { + promises.push(iterator.next()); + } + return Promise.all(promises); +} + +describe('Execute: stream directive', () => { + it('Can stream a list field', async () => { + const document = parse('{ scalarList @stream(initialCount: 1) }'); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + data: ['banana'], + path: ['scalarList', 1], + hasNext: true, + }, + { + data: ['coconut'], + path: ['scalarList', 2], + hasNext: false, + }, + ]); + }); + it('Can use default value of initialCount', async () => { + const document = parse('{ scalarList @stream }'); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: [], + }, + hasNext: true, + }, + { + data: ['apple'], + path: ['scalarList', 0], + hasNext: true, + }, + { + data: ['banana'], + path: ['scalarList', 1], + hasNext: true, + }, + { + data: ['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); + 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); + + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + data: ['banana'], + path: ['scalarList', 1], + label: 'scalar-stream', + hasNext: true, + }, + { + data: ['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); + + expectJSON(result).toDeepEqual({ + data: { scalarList: ['apple', 'banana', 'coconut'] }, + }); + }); + it('Can stream multi-dimensional lists', async () => { + const document = parse('{ scalarListList @stream(initialCount: 1) }'); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + scalarListList: [['apple', 'apple', 'apple']], + }, + hasNext: true, + }, + { + data: [['banana', 'banana', 'banana']], + path: ['scalarListList', 1], + hasNext: true, + }, + { + data: [['coconut', 'coconut', 'coconut']], + path: ['scalarListList', 2], + hasNext: false, + }, + ]); + }); + + it('Can stream a field that returns a list of promises', async () => { + const document = parse(` + query { + asyncList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncList', 2], + hasNext: false, + }, + ]); + }); + it('Can stream in correct order with lists of promises', async () => { + const document = parse(` + query { + asyncSlowList @stream(initialCount: 0) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncSlowList: [], + }, + hasNext: true, + }, + { + data: [ + { + name: 'Luke', + id: '1', + }, + ], + path: ['asyncSlowList', 0], + hasNext: true, + }, + { + data: [ + { + name: 'Han', + id: '2', + }, + ], + path: ['asyncSlowList', 1], + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncSlowList', 2], + hasNext: false, + }, + ]); + }); + it('Handles rejections in a field that returns a list of promises before initialCount is reached', async () => { + const document = parse(` + query { + asyncListError @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + errors: [ + { + message: 'bad', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncListError', 1], + }, + ], + data: { + asyncListError: [ + { + name: 'Luke', + id: '1', + }, + null, + ], + }, + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncListError', 2], + hasNext: false, + }, + ]); + }); + it('Handles rejections in a field that returns a list of promises after initialCount is reached', async () => { + const document = parse(` + query { + asyncListError @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncListError: [ + { + name: 'Luke', + id: '1', + }, + ], + }, + hasNext: true, + }, + { + data: [null], + path: ['asyncListError', 1], + errors: [ + { + message: 'bad', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncListError', 1], + }, + ], + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncListError', 2], + hasNext: false, + }, + ]); + }); + it('Can stream a field that returns an async iterable', async () => { + const document = parse(` + query { + asyncIterableList @stream { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableList: [], + }, + hasNext: true, + }, + { + data: [ + { + name: 'Luke', + id: '1', + }, + ], + path: ['asyncIterableList', 0], + hasNext: true, + }, + { + data: [ + { + name: 'Han', + id: '2', + }, + ], + path: ['asyncIterableList', 1], + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncIterableList', 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 { + asyncIterableList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncIterableList', 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 { + asyncIterableList @stream(initialCount: -2) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'initialCount must be a positive integer', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncIterableList'], + }, + ], + data: { + asyncIterableList: null, + }, + }); + }); + it('Can handle concurrent calls to .next() without waiting', async () => { + const document = parse(` + query { + asyncIterableList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await completeAsync(document, 4); + expectJSON(result).toDeepEqual([ + { + done: false, + value: { + data: { + asyncIterableList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + }, + { + done: false, + value: { + data: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['asyncIterableList', 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 { + asyncIterableError @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'bad', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncIterableError', 1], + }, + ], + data: { + asyncIterableError: [ + { + name: 'Luke', + id: '1', + }, + null, + ], + }, + }); + }); + it('Handles error thrown in async iterable after initialCount is reached', async () => { + const document = parse(` + query { + asyncIterableError @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableError: [ + { + name: 'Luke', + id: '1', + }, + ], + }, + hasNext: true, + }, + { + data: [null], + path: ['asyncIterableError', 1], + errors: [ + { + message: 'bad', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncIterableError', 1], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles null returned in non-null list items after initialCount is reached', async () => { + const document = parse(` + query { + nonNullError @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + nonNullError: [ + { + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + { + data: null, + path: ['nonNullError', 1], + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.nonNullError.', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['nonNullError', 1], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles null returned in non-null async iterable list items after initialCount is reached', async () => { + const document = parse(` + query { + asyncIterableNonNullError @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableNonNullError: [ + { + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + { + data: null, + path: ['asyncIterableNonNullError', 1], + hasNext: true, + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.asyncIterableNonNullError.', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncIterableNonNullError', 1], + }, + ], + }, + { + data: [ + { + name: 'Han', + }, + ], + path: ['asyncIterableNonNullError', 2], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Handles errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + asyncIterableInvalid @stream(initialCount: 1) + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableInvalid: ['Luke'], + }, + hasNext: true, + }, + { + data: [null], + path: ['asyncIterableInvalid', 1], + errors: [ + { + message: 'String cannot represent value: {}', + locations: [ + { + line: 3, + column: 9, + }, + ], + path: ['asyncIterableInvalid', 1], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Handles async errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + nonNullAsyncError @stream(initialCount: 1) { + string + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullAsyncError: [{ string: 'Luke' }], + }, + hasNext: true, + }, + { + data: null, + path: ['nonNullAsyncError', 1], + hasNext: true, + errors: [ + { + message: 'Oops', + locations: [ + { + line: 4, + column: 11, + }, + ], + path: ['nonNullAsyncError', 1, 'string'], + }, + ], + }, + { + data: [ + { + string: 'Han', + }, + ], + path: ['nonNullAsyncError', 2], + hasNext: false, + }, + ]); + }); + + it('Handles async errors thrown by completeValue after initialCount is reached from async iterable', async () => { + const document = parse(` + query { + asyncIterableAsyncInvalid @stream(initialCount: 1) { + string + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableAsyncInvalid: [{ string: 'Luke' }], + }, + hasNext: true, + }, + { + data: [null], + path: ['asyncIterableAsyncInvalid', 1], + hasNext: true, + errors: [ + { + message: 'Oops', + locations: [ + { + line: 4, + column: 11, + }, + ], + path: ['asyncIterableAsyncInvalid', 1, 'string'], + }, + ], + }, + { + data: [ + { + string: 'Han', + }, + ], + path: ['asyncIterableAsyncInvalid', 2], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Handles promises returned by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + asyncIterableList @stream(initialCount: 1) { + name + asyncName + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableList: [ + { + name: 'Luke', + asyncName: 'Luke', + }, + ], + }, + hasNext: true, + }, + { + data: [ + { + name: 'Han', + asyncName: 'Han', + }, + ], + path: ['asyncIterableList', 1], + hasNext: true, + }, + { + data: [ + { + name: 'Leia', + asyncName: 'Leia', + }, + ], + path: ['asyncIterableList', 2], + hasNext: false, + }, + ]); + }); + it('Returns payloads in correct order when parent deferred fragment resolves slower than stream', async () => { + const document = parse(` + query { + nestedObject { + ... DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + slowField + asyncIterableList @stream(initialCount: 0) { + name + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + nestedObject: {}, + }, + hasNext: true, + }, + { + data: { + slowField: 'slow', + asyncIterableList: [], + }, + path: ['nestedObject'], + hasNext: true, + }, + { + data: [{ name: 'Luke' }], + path: ['nestedObject', 'asyncIterableList', 0], + hasNext: true, + }, + { + data: [{ name: 'Han' }], + path: ['nestedObject', 'asyncIterableList', 1], + hasNext: true, + }, + { + data: [{ name: 'Leia' }], + path: ['nestedObject', 'asyncIterableList', 2], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Can @defer fields that are resolved after async iterable is complete', async () => { + const document = parse(` + query { + asyncIterableList @stream(initialCount: 1, label:"stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + asyncName + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableList: [ + { + id: '1', + }, + ], + }, + hasNext: true, + }, + { + data: [ + { + id: '2', + }, + ], + path: ['asyncIterableList', 1], + label: 'stream-label', + hasNext: true, + }, + { + data: [ + { + id: '3', + }, + ], + path: ['asyncIterableList', 2], + label: 'stream-label', + hasNext: true, + }, + { + data: { + asyncName: 'Luke', + }, + path: ['asyncIterableList', 0], + label: 'DeferName', + hasNext: true, + }, + { + data: { + asyncName: 'Han', + }, + path: ['asyncIterableList', 1], + label: 'DeferName', + hasNext: true, + }, + { + data: { + asyncName: 'Leia', + }, + path: ['asyncIterableList', 2], + label: 'DeferName', + hasNext: false, + }, + ]); + }); + it('Can @defer fields that are resolved before async iterable is complete', async () => { + const document = parse(` + query { + asyncIterableListDelayedClose @stream(initialCount: 1, label:"stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + asyncIterableListDelayedClose: [ + { + id: '1', + }, + ], + }, + hasNext: true, + }, + { + data: { + name: 'Luke', + }, + path: ['asyncIterableListDelayedClose', 0], + label: 'DeferName', + hasNext: true, + }, + { + data: [ + { + id: '2', + }, + ], + path: ['asyncIterableListDelayedClose', 1], + label: 'stream-label', + hasNext: true, + }, + { + data: [ + { + id: '3', + }, + ], + path: ['asyncIterableListDelayedClose', 2], + label: 'stream-label', + hasNext: true, + }, + { + data: { + name: 'Han', + }, + path: ['asyncIterableListDelayedClose', 1], + label: 'DeferName', + hasNext: true, + }, + { + data: { + name: 'Leia', + }, + path: ['asyncIterableListDelayedClose', 2], + label: 'DeferName', + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Returns underlying async iterables when dispatcher is returned', async () => { + const document = parse(` + query { + asyncIterableListDelayed @stream(initialCount: 1) { + name + id + } + } + `); + const schema = new GraphQLSchema({ query }); + + const executeResult = await execute({ schema, document, rootValue: {} }); + assert(isAsyncIterable(executeResult)); + const iterator = executeResult[Symbol.asyncIterator](); + + const result1 = await iterator.next(); + expectJSON(result1).toDeepEqual({ + done: false, + value: { + data: { + asyncIterableListDelayed: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + }); + + const returnPromise = iterator.return(); + + // this result had started processing before return was called + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: false, + value: { + data: [ + { + id: '2', + name: 'Han', + }, + ], + hasNext: true, + path: ['asyncIterableListDelayed', 1], + }, + }); + + // third result is not returned because async iterator has returned + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + done: true, + value: undefined, + }); + await returnPromise; + }); + it('Can return async iterable when underlying iterable does not have a return method', async () => { + const document = parse(` + query { + asyncIterableListNoReturn @stream(initialCount: 1) { + name + id + } + } + `); + const schema = new GraphQLSchema({ query }); + + const executeResult = await execute({ schema, document, rootValue: {} }); + assert(isAsyncIterable(executeResult)); + const iterator = executeResult[Symbol.asyncIterator](); + + const result1 = await iterator.next(); + expectJSON(result1).toDeepEqual({ + done: false, + value: { + data: { + asyncIterableListNoReturn: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + }); + + const returnPromise = iterator.return(); + + // this result had started processing before return was called + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: false, + value: { + data: [ + { + id: '2', + name: 'Han', + }, + ], + hasNext: true, + path: ['asyncIterableListNoReturn', 1], + }, + }); + + // third result is not returned because async iterator has returned + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + done: true, + value: undefined, + }); + await returnPromise; + }); + it('Returns underlying async iterables when dispatcher is thrown', async () => { + const document = parse(` + query { + asyncIterableListDelayed @stream(initialCount: 1) { + name + id + } + } + `); + const schema = new GraphQLSchema({ query }); + + const executeResult = await execute({ schema, document, rootValue: {} }); + assert(isAsyncIterable(executeResult)); + const iterator = executeResult[Symbol.asyncIterator](); + + const result1 = await iterator.next(); + expectJSON(result1).toDeepEqual({ + done: false, + value: { + data: { + asyncIterableListDelayed: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + }); + + const throwPromise = iterator.throw(new Error('bad')); + + // this result had started processing before return was called + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: false, + value: { + data: [ + { + id: '2', + name: 'Han', + }, + ], + hasNext: true, + path: ['asyncIterableListDelayed', 1], + }, + }); + + // third result is not returned because async iterator has returned + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + done: true, + value: undefined, + }); + try { + await throwPromise; /* c8 ignore start */ + // Not reachable, always throws + /* c8 ignore stop */ + } catch (e) { + // ignore error + } + }); +}); diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index 5f256ca868..0ec2679d23 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -82,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) { importantEmail(priority: $priority) { email { from subject } - inbox { - unread - total + ... @defer(if: $shouldDefer) { + inbox { + unread + total + } } } } @@ -122,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) @@ -679,6 +689,136 @@ describe('Subscription Publish Phase', () => { }); }); + it('produces additional payloads for subscriptions with @defer', async () => { + const pubsub = new SimplePubSub(); + const subscription = await createSubscription(pubsub, { + shouldDefer: true, + }); + assert(isAsyncIterable(subscription)); + // Wait for the next subscription payload. + const payload = subscription.next(); + + // A new email arrives! + expect( + pubsub.emit({ + from: 'yuzhi@graphql.org', + subject: 'Alright', + message: 'Tests are good', + unread: true, + }), + ).to.equal(true); + + // The previously waited on payload now has a value. + expect(await payload).to.deep.equal({ + done: false, + value: { + data: { + importantEmail: { + email: { + from: 'yuzhi@graphql.org', + subject: 'Alright', + }, + }, + }, + hasNext: true, + }, + }); + + // Wait for the next payload from @defer + expect(await subscription.next()).to.deep.equal({ + done: false, + value: { + 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, + }), + ).to.equal(true); + + // The next waited on payload will have a value. + expect(await subscription.next()).to.deep.equal({ + 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, + }), + ).to.equal(true); + + // Deferred payload from previous event is received. + expect(await subscription.next()).to.deep.equal({ + done: false, + value: { + data: { + inbox: { + unread: 2, + total: 3, + }, + }, + path: ['importantEmail'], + hasNext: false, + }, + }); + + // Next payload from last event + expect(await subscription.next()).to.deep.equal({ + done: false, + value: { + data: { + importantEmail: { + email: { + from: 'adam@graphql.org', + subject: 'Important', + }, + }, + }, + hasNext: true, + }, + }); + + // The client disconnects before the deferred payload is consumed. + expect(await subscription.return()).to.deep.equal({ + done: true, + value: undefined, + }); + + // Awaiting a subscription after closing it results in completed results. + expect(await subscription.next()).to.deep.equal({ + 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/src/execution/__tests__/sync-test.ts b/src/execution/__tests__/sync-test.ts index 021f09fa3c..2ea2ce8bd5 100644 --- a/src/execution/__tests__/sync-test.ts +++ b/src/execution/__tests__/sync-test.ts @@ -113,6 +113,24 @@ describe('Execute: synchronously when possible', () => { }); }).to.throw('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', + }); + }).to.throw('GraphQL execution failed to complete synchronously.'); + }); }); describe('graphqlSync', () => { diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index bd85f73dcc..7b3695083a 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -13,6 +13,7 @@ import { Kind } from '../language/kinds'; import type { GraphQLObjectType } from '../type/definition'; import { isAbstractType } from '../type/definition'; import { + GraphQLDeferDirective, GraphQLIncludeDirective, GraphQLSkipDirective, } from '../type/directives'; @@ -22,6 +23,16 @@ import { typeFromAST } from '../utilities/typeFromAST'; import { getDirectiveValues } from './values'; +export interface PatchFields { + label?: string; + fields: Map>; +} + +export interface FieldsAndPatches { + fields: Map>; + patches: Array; +} + /** * Given a selectionSet, collects all of the fields and returns them. * @@ -37,8 +48,9 @@ export function collectFields( variableValues: { [variable: string]: unknown }, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, -): Map> { +): FieldsAndPatches { const fields = new AccumulatorMap(); + const patches: Array = []; collectFieldsImpl( schema, fragments, @@ -46,9 +58,10 @@ export function collectFields( runtimeType, selectionSet, fields, + patches, new Set(), ); - return fields; + return { fields, patches }; } /** @@ -67,9 +80,16 @@ export function collectSubfields( variableValues: { [variable: string]: unknown }, returnType: GraphQLObjectType, fieldNodes: ReadonlyArray, -): Map> { +): FieldsAndPatches { const subFieldNodes = new AccumulatorMap(); const visitedFragmentNames = new Set(); + + const subPatches: Array = []; + const subFieldsAndPatches = { + fields: subFieldNodes, + patches: subPatches, + }; + for (const node of fieldNodes) { if (node.selectionSet) { collectFieldsImpl( @@ -79,11 +99,12 @@ export function collectSubfields( returnType, node.selectionSet, subFieldNodes, + subPatches, visitedFragmentNames, ); } } - return subFieldNodes; + return subFieldsAndPatches; } // eslint-disable-next-line max-params @@ -94,6 +115,7 @@ function collectFieldsImpl( runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, fields: AccumulatorMap, + patches: Array, visitedFragmentNames: Set, ): void { for (const selection of selectionSet.selections) { @@ -112,26 +134,51 @@ function collectFieldsImpl( ) { continue; } - collectFieldsImpl( - 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 || @@ -139,21 +186,69 @@ function collectFieldsImpl( ) { continue; } - collectFieldsImpl( - 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; } } } } +/** + * 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. + */ +function getDeferValues( + variableValues: { [variable: string]: unknown }, + node: FragmentSpreadNode | InlineFragmentNode, +): undefined | { label?: string } { + const defer = getDirectiveValues(GraphQLDeferDirective, node, variableValues); + + if (!defer) { + return; + } + + if (defer.if === false) { + return; + } + + return { + label: typeof defer.label === 'string' ? defer.label : undefined, + }; +} + /** * Determines if a field should be included based on the `@include` and `@skip` * directives, where `@skip` has higher precedence than `@include`. diff --git a/src/execution/execute.ts b/src/execution/execute.ts index cc3cbb9cbf..6c4a188e77 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -44,6 +44,7 @@ import { isNonNullType, isObjectType, } from '../type/definition'; +import { GraphQLStreamDirective } from '../type/directives'; import type { GraphQLSchema } from '../type/schema'; import { assertValidSchema } from '../type/validate'; @@ -51,8 +52,13 @@ import { collectFields, collectSubfields as _collectSubfields, } from './collectFields'; +import { flattenAsyncIterator } from './flattenAsyncIterator'; import { mapAsyncIterator } from './mapAsyncIterator'; -import { getArgumentValues, getVariableValues } from './values'; +import { + getArgumentValues, + getDirectiveValues, + getVariableValues, +} from './values'; /* eslint-disable max-params */ // This file contains a lot of such errors but we plan to refactor it anyway @@ -115,6 +121,7 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; errors: Array; + subsequentPayloads: Array; } /** @@ -122,14 +129,16 @@ export interface ExecutionContext { * * - `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< - TData = ObjMap, + TData = unknown, TExtensions = ObjMap, > { errors?: ReadonlyArray; data?: TData | null; + hasNext?: boolean; extensions?: TExtensions; } @@ -139,9 +148,45 @@ export interface FormattedExecutionResult< > { errors?: ReadonlyArray; data?: TData | null; + hasNext?: boolean; + extensions?: TExtensions; +} + +/** + * The result of an asynchronous GraphQL patch. + * + * - `errors` is included when any errors occurred as a non-empty array. + * - `data` is the result of the additional asynchronous data. + * - `path` is the location of data. + * - `label` is the label provided to `@defer` or `@stream`. + * - `hasNext` is true if a future payload is expected. + * - `extensions` is reserved for adding non-standard properties. + */ +export interface ExecutionPatchResult< + TData = unknown, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data?: TData | null; + path?: ReadonlyArray; + label?: string; + hasNext: boolean; + extensions?: TExtensions; +} + +export interface FormattedExecutionPatchResult< + TData = unknown, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data?: TData | null; + path?: ReadonlyArray; + label?: string; + hasNext: boolean; extensions?: TExtensions; } +export type AsyncExecutionResult = ExecutionResult | ExecutionPatchResult; export interface ExecutionArgs { schema: GraphQLSchema; document: DocumentNode; @@ -164,7 +209,11 @@ export interface ExecutionArgs { * 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(args: ExecutionArgs): PromiseOrValue { +export function execute( + args: ExecutionArgs, +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. const exeContext = buildExecutionContext(args); @@ -179,7 +228,9 @@ export function execute(args: ExecutionArgs): PromiseOrValue { function executeImpl( exeContext: ExecutionContext, -): PromiseOrValue { +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { // Return a Promise that will eventually resolve to the data described by // The "Response" section of the GraphQL specification. // @@ -195,14 +246,24 @@ function executeImpl( const result = executeOperation(exeContext); if (isPromise(result)) { return result.then( - (data) => buildResponse(data, exeContext.errors), + (data) => { + const initialResult = buildResponse(data, exeContext.errors); + if (exeContext.subsequentPayloads.length > 0) { + return yieldSubsequentPayloads(exeContext, initialResult); + } + return initialResult; + }, (error) => { exeContext.errors.push(error); return buildResponse(null, exeContext.errors); }, ); } - return buildResponse(result, exeContext.errors); + const initialResult = buildResponse(result, exeContext.errors); + if (exeContext.subsequentPayloads.length > 0) { + return yieldSubsequentPayloads(exeContext, initialResult); + } + return initialResult; } catch (error) { exeContext.errors.push(error); return buildResponse(null, exeContext.errors); @@ -218,7 +279,7 @@ export function executeSync(args: ExecutionArgs): ExecutionResult { const result = execute(args); // Assert that the execution was synchronous. - if (isPromise(result)) { + if (isPromise(result) || isAsyncIterable(result)) { throw new Error('GraphQL execution failed to complete synchronously.'); } @@ -321,6 +382,7 @@ export function buildExecutionContext( fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, + subsequentPayloads: [], errors: [], }; } @@ -352,7 +414,7 @@ function executeOperation( ); } - const rootFields = collectFields( + const { fields: rootFields, patches } = collectFields( schema, fragments, variableValues, @@ -360,23 +422,40 @@ function executeOperation( operation.selectionSet, ); const path = undefined; + let result; switch (operation.operation) { case OperationTypeNode.QUERY: - return executeFields(exeContext, rootType, rootValue, path, rootFields); + result = executeFields(exeContext, rootType, rootValue, path, rootFields); + break; case OperationTypeNode.MUTATION: - return executeFieldsSerially( + result = executeFieldsSerially( exeContext, rootType, rootValue, path, rootFields, ); + break; case OperationTypeNode.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); + result = executeFields(exeContext, rootType, rootValue, path, rootFields); } + + for (const patch of patches) { + const { label, fields: patchFields } = patch; + executeDeferredFragment( + exeContext, + rootType, + rootValue, + patchFields, + label, + path, + ); + } + + return result; } /** @@ -427,6 +506,7 @@ function executeFields( sourceValue: unknown, path: Path | undefined, fields: Map>, + asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; @@ -439,6 +519,7 @@ function executeFields( sourceValue, fieldNodes, fieldPath, + asyncPayloadRecord, ); if (result !== undefined) { @@ -472,7 +553,9 @@ function executeField( source: unknown, fieldNodes: ReadonlyArray, path: Path, + asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue { + const errors = asyncPayloadRecord?.errors ?? exeContext.errors; const fieldName = fieldNodes[0].name.value; const fieldDef = exeContext.schema.getField(parentType, fieldName); if (!fieldDef) { @@ -511,7 +594,15 @@ function executeField( let completed; if (isPromise(result)) { completed = result.then((resolved) => - completeValue(exeContext, returnType, fieldNodes, info, path, resolved), + completeValue( + exeContext, + returnType, + fieldNodes, + info, + path, + resolved, + asyncPayloadRecord, + ), ); } else { completed = completeValue( @@ -521,6 +612,7 @@ function executeField( info, path, result, + asyncPayloadRecord, ); } @@ -529,13 +621,13 @@ 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); + return handleFieldError(error, returnType, errors); }); } return completed; } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return handleFieldError(error, returnType, exeContext); + return handleFieldError(error, returnType, errors); } } @@ -569,7 +661,7 @@ export function buildResolveInfo( function handleFieldError( error: GraphQLError, returnType: GraphQLOutputType, - exeContext: ExecutionContext, + 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. @@ -579,7 +671,7 @@ function handleFieldError( // 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; } @@ -611,6 +703,7 @@ function completeValue( info: GraphQLResolveInfo, path: Path, result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue { // If result is an Error, throw a located error. if (result instanceof Error) { @@ -627,6 +720,7 @@ function completeValue( info, path, result, + asyncPayloadRecord, ); if (completed === null) { throw new Error( @@ -650,6 +744,7 @@ function completeValue( info, path, result, + asyncPayloadRecord, ); } @@ -669,6 +764,7 @@ function completeValue( info, path, result, + asyncPayloadRecord, ); } @@ -681,6 +777,7 @@ function completeValue( info, path, result, + asyncPayloadRecord, ); } /* c8 ignore next 6 */ @@ -691,6 +788,58 @@ function completeValue( ); } +/** + * 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: ReadonlyArray, + path: Path, +): + | undefined + | { + initialCount?: number; + label?: string; + } { + // 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, + ); + + 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. @@ -702,12 +851,35 @@ async function completeAsyncIteratorValue( info: GraphQLResolveInfo, path: Path, iterator: AsyncIterator, + asyncPayloadRecord?: AsyncPayloadRecord, ): Promise> { + const errors = asyncPayloadRecord?.errors ?? exeContext.errors; + const stream = getStreamValues(exeContext, fieldNodes, path); let containsPromise = false; const completedResults = []; let index = 0; // eslint-disable-next-line no-constant-condition while (true) { + if ( + stream && + typeof stream.initialCount === 'number' && + index >= stream.initialCount + ) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + executeStreamIterator( + index, + iterator, + exeContext, + fieldNodes, + info, + itemType, + path, + stream.label, + asyncPayloadRecord, + ); + break; + } + const fieldPath = addPath(path, index, undefined); try { // eslint-disable-next-line no-await-in-loop @@ -725,6 +897,7 @@ async function completeAsyncIteratorValue( info, fieldPath, value, + asyncPayloadRecord, ); if (isPromise(completedItem)) { containsPromise = true; @@ -737,12 +910,12 @@ async function completeAsyncIteratorValue( fieldNodes, pathToArray(fieldPath), ); - handleFieldError(error, itemType, exeContext); + handleFieldError(error, itemType, errors); } } catch (rawError) { completedResults.push(null); const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath)); - handleFieldError(error, itemType, exeContext); + handleFieldError(error, itemType, errors); break; } index += 1; @@ -761,8 +934,10 @@ function completeListValue( info: GraphQLResolveInfo, path: Path, result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue> { const itemType = returnType.ofType; + const errors = asyncPayloadRecord?.errors ?? exeContext.errors; if (isAsyncIterable(result)) { const iterator = result[Symbol.asyncIterator](); @@ -774,6 +949,7 @@ function completeListValue( info, path, iterator, + asyncPayloadRecord, ); } @@ -783,15 +959,39 @@ 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 = []; + 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 ( + stream && + typeof stream.initialCount === 'number' && + index >= stream.initialCount + ) { + previousAsyncPayloadRecord = executeStreamField( + itemPath, + item, + exeContext, + fieldNodes, + info, + itemType, + stream.label, + previousAsyncPayloadRecord, + ); + index++; + continue; + } if (isPromise(item)) { completedItem = item.then((resolved) => completeValue( @@ -801,6 +1001,7 @@ function completeListValue( info, itemPath, resolved, + asyncPayloadRecord, ), ); } else { @@ -811,6 +1012,7 @@ function completeListValue( info, itemPath, item, + asyncPayloadRecord, ); } @@ -818,21 +1020,25 @@ function completeListValue( 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); - }); + completedResults.push( + completedItem.then(undefined, (rawError) => { + const error = locatedError( + rawError, + fieldNodes, + pathToArray(itemPath), + ); + return handleFieldError(error, itemType, errors); + }), + ); + } else { + completedResults.push(completedItem); } - return completedItem; } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); - return handleFieldError(error, itemType, exeContext); + completedResults.push(handleFieldError(error, itemType, errors)); } - }); + index++; + } return containsPromise ? Promise.all(completedResults) : completedResults; } @@ -866,6 +1072,7 @@ function completeAbstractValue( info: GraphQLResolveInfo, path: Path, result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -887,6 +1094,7 @@ function completeAbstractValue( info, path, result, + asyncPayloadRecord, ), ); } @@ -905,6 +1113,7 @@ function completeAbstractValue( info, path, result, + asyncPayloadRecord, ); } @@ -973,10 +1182,8 @@ function completeObjectValue( info: GraphQLResolveInfo, path: Path, result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue> { - // Collect sub-fields to execute to complete this value. - const subFieldNodes = collectSubfields(exeContext, returnType, fieldNodes); - // 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. @@ -988,12 +1195,13 @@ function completeObjectValue( if (!resolvedIsTypeOf) { throw invalidReturnTypeError(returnType, result, fieldNodes); } - return executeFields( + return collectAndExecuteSubfields( exeContext, returnType, - result, + fieldNodes, path, - subFieldNodes, + result, + asyncPayloadRecord, ); }); } @@ -1003,7 +1211,14 @@ function completeObjectValue( } } - return executeFields(exeContext, returnType, result, path, subFieldNodes); + return collectAndExecuteSubfields( + exeContext, + returnType, + fieldNodes, + path, + result, + asyncPayloadRecord, + ); } function invalidReturnTypeError( @@ -1017,6 +1232,46 @@ function invalidReturnTypeError( ); } +function collectAndExecuteSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + path: Path, + result: unknown, + asyncPayloadRecord?: AsyncPayloadRecord, +): PromiseOrValue> { + // 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: @@ -1143,8 +1398,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, (payload: unknown) => - executeImpl(buildPerEventExecutionContext(exeContext, payload)), + return flattenAsyncIterator( + mapAsyncIterator(resultOrStream, (payload: unknown) => + executeImpl(buildPerEventExecutionContext(exeContext, payload)), + ), ); } @@ -1220,7 +1477,7 @@ function executeSubscription( ); } - const rootFields = collectFields( + const { fields: rootFields } = collectFields( schema, fragments, variableValues, @@ -1292,3 +1549,388 @@ function assertEventStream(result: unknown): AsyncIterable { return result; } + +function executeDeferredFragment( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: unknown, + fields: Map>, + label?: string, + path?: Path, + parentContext?: AsyncPayloadRecord, +): void { + const asyncPayloadRecord = new AsyncPayloadRecord({ + label, + path, + parentContext, + }); + 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); + promiseOrData = null; + } + asyncPayloadRecord.addData(promiseOrData); + exeContext.subsequentPayloads.push(asyncPayloadRecord); +} + +function executeStreamField( + path: Path, + item: PromiseOrValue, + exeContext: ExecutionContext, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + label?: string, + parentContext?: AsyncPayloadRecord, +): AsyncPayloadRecord { + const asyncPayloadRecord = new AsyncPayloadRecord({ + label, + path, + parentContext, + }); + let completedItem: PromiseOrValue; + try { + try { + if (isPromise(item)) { + completedItem = item.then((resolved) => + completeValue( + exeContext, + itemType, + fieldNodes, + info, + path, + resolved, + asyncPayloadRecord, + ), + ); + } else { + completedItem = completeValue( + exeContext, + itemType, + fieldNodes, + info, + path, + 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(path)); + return handleFieldError(error, itemType, asyncPayloadRecord.errors); + }); + } + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(path)); + completedItem = handleFieldError( + error, + itemType, + asyncPayloadRecord.errors, + ); + } + } catch (error) { + asyncPayloadRecord.errors.push(error); + asyncPayloadRecord.addData(null); + exeContext.subsequentPayloads.push(asyncPayloadRecord); + return asyncPayloadRecord; + } + + if (isPromise(completedItem)) { + completedItem = completedItem.then( + (value) => [value], + (error) => { + asyncPayloadRecord.errors.push(error); + return null; + }, + ); + } else { + completedItem = [completedItem]; + } + + asyncPayloadRecord.addData(completedItem); + exeContext.subsequentPayloads.push(asyncPayloadRecord); + return asyncPayloadRecord; +} + +async function executeStreamIteratorItem( + iterator: AsyncIterator, + exeContext: ExecutionContext, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + asyncPayloadRecord: AsyncPayloadRecord, + fieldPath: 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(fieldPath)); + 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, + fieldPath, + item, + asyncPayloadRecord, + ); + + if (isPromise(completedItem)) { + completedItem = completedItem.then(undefined, (rawError) => { + const error = locatedError( + rawError, + fieldNodes, + pathToArray(fieldPath), + ); + return handleFieldError(error, itemType, asyncPayloadRecord.errors); + }); + } + return { done: false, value: completedItem }; + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath)); + const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); + return { done: false, value }; + } +} + +async function executeStreamIterator( + initialIndex: number, + iterator: AsyncIterator, + exeContext: ExecutionContext, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + path?: Path, + label?: string, + parentContext?: AsyncPayloadRecord, +): Promise { + const subsequentPayloads = exeContext.subsequentPayloads; + let index = initialIndex; + // eslint-disable-next-line no-constant-condition + while (true) { + const fieldPath = addPath(path, index, undefined); + const asyncPayloadRecord = new AsyncPayloadRecord({ + label, + path: fieldPath, + parentContext, + iterator, + }); + + const dataPromise = executeStreamIteratorItem( + iterator, + exeContext, + fieldNodes, + info, + itemType, + asyncPayloadRecord, + fieldPath, + ); + + asyncPayloadRecord.addData( + dataPromise + .then(({ value }) => value) + .then( + (value) => [value], + (err) => { + asyncPayloadRecord.errors.push(err); + return null; + }, + ), + ); + subsequentPayloads.push(asyncPayloadRecord); + try { + // eslint-disable-next-line no-await-in-loop + const { done } = await dataPromise; + if (done) { + break; + } + } catch (err) { + // do nothing, error is already handled above + } + index++; + } +} + +function yieldSubsequentPayloads( + exeContext: ExecutionContext, + initialResult: ExecutionResult, +): AsyncGenerator { + let _hasReturnedInitialResult = false; + let isDone = false; + + async function race(): Promise> { + if (exeContext.subsequentPayloads.length === 0) { + // async iterable resolver just finished and no more pending payloads + return { + value: { + hasNext: false, + }, + done: false, + }; + } + + const asyncPayloadRecord: AsyncPayloadRecord = await new Promise( + (resolve) => { + exeContext.subsequentPayloads.forEach((payload) => { + const data = payload.getData(); + if (isPromise(data)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + data.then(() => { + resolve(payload); + }); + } else { + resolve(payload); + } + }); + }, + ); + + const data = await asyncPayloadRecord.data; + + if (exeContext.subsequentPayloads.length === 0) { + // a different call to next has exhausted all payloads + return { value: undefined, done: true }; + } + + const index = exeContext.subsequentPayloads.indexOf(asyncPayloadRecord); + + if (index === -1) { + // a different call to next has consumed this payload + return race(); + } + + exeContext.subsequentPayloads.splice(index, 1); + + if (asyncPayloadRecord.isCompletedIterator) { + // async iterable resolver just finished but there may be pending payloads + // return the next one + return race(); + } + + const returnValue: ExecutionPatchResult = { + data, + path: asyncPayloadRecord.path ? pathToArray(asyncPayloadRecord.path) : [], + hasNext: exeContext.subsequentPayloads.length > 0, + }; + if (asyncPayloadRecord.label) { + returnValue.label = asyncPayloadRecord.label; + } + if (asyncPayloadRecord.errors.length > 0) { + returnValue.errors = asyncPayloadRecord.errors; + } + return { + value: returnValue, + done: false, + }; + } + + return { + [Symbol.asyncIterator]() { + return this; + }, + next: () => { + if (!_hasReturnedInitialResult) { + _hasReturnedInitialResult = true; + return Promise.resolve({ + value: { + ...initialResult, + hasNext: true, + }, + done: false, + }); + } else if (exeContext.subsequentPayloads.length === 0 || isDone) { + return Promise.resolve({ value: undefined, done: true }); + } + return race(); + }, + async return(): Promise> { + await Promise.all( + exeContext.subsequentPayloads.map((asyncPayloadRecord) => + asyncPayloadRecord.iterator?.return?.(), + ), + ); + isDone = true; + return { value: undefined, done: true }; + }, + async throw( + error?: unknown, + ): Promise> { + await Promise.all( + exeContext.subsequentPayloads.map((asyncPayloadRecord) => + asyncPayloadRecord.iterator?.return?.(), + ), + ); + isDone = true; + return Promise.reject(error); + }, + }; +} + +class AsyncPayloadRecord { + errors: Array; + label?: string; + path?: Path; + data?: PromiseOrValue; + parentContext?: AsyncPayloadRecord; + iterator?: AsyncIterator; + isCompletedIterator?: boolean; + constructor(opts: { + label?: string; + path?: Path; + iterator?: AsyncIterator; + parentContext?: AsyncPayloadRecord; + }) { + this.label = opts.label; + this.path = opts.path; + this.parentContext = opts.parentContext; + this.iterator = opts.iterator; + this.errors = []; + } + + addData(data: PromiseOrValue) { + this.data = data; + } + + getData(): PromiseOrValue { + const parentData = this.parentContext?.getData(); + if (parentData) { + return Promise.resolve(parentData).then(() => this.data); + } + return this.data; + } + + setIsCompletedIterator() { + this.isCompletedIterator = true; + } +} diff --git a/src/execution/flattenAsyncIterator.ts b/src/execution/flattenAsyncIterator.ts new file mode 100644 index 0000000000..1533482bb9 --- /dev/null +++ b/src/execution/flattenAsyncIterator.ts @@ -0,0 +1,50 @@ +import { isAsyncIterable } from '../jsutils/isAsyncIterable'; + +type AsyncIterableOrGenerator = + | AsyncGenerator + | AsyncIterable; + +/** + * Given an AsyncIterable that could potentially yield other async iterators, + * flatten all yielded results into a single AsyncIterable + */ +export function flattenAsyncIterator( + iterable: AsyncIterableOrGenerator>, +): AsyncGenerator { + const iteratorMethod = iterable[Symbol.asyncIterator]; + const iterator: any = iteratorMethod.call(iterable); + let iteratorStack: Array> = [iterator]; + + async function next(): Promise> { + const currentIterator = iteratorStack[0]; + if (!currentIterator) { + return { value: undefined, done: true }; + } + const result = await currentIterator.next(); + if (result.done) { + iteratorStack.shift(); + return next(); + } else if (isAsyncIterable(result.value)) { + const childIterator = result.value[ + Symbol.asyncIterator + ]() as AsyncIterator; + iteratorStack.unshift(childIterator); + return next(); + } + return result; + } + return { + next, + return() { + iteratorStack = []; + return iterator.return(); + }, + throw(error?: unknown): Promise> { + iteratorStack = []; + return iterator.throw(error); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/src/execution/index.ts b/src/execution/index.ts index b27a2c291c..1af334dc79 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -13,6 +13,9 @@ export type { ExecutionArgs, ExecutionResult, FormattedExecutionResult, + ExecutionPatchResult, + FormattedExecutionPatchResult, + AsyncExecutionResult, } from './execute'; export { diff --git a/src/graphql.ts b/src/graphql.ts index ffad9123c1..d2b959dd97 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,3 +1,4 @@ +import { isAsyncIterable } from './jsutils/isAsyncIterable'; import { isPromise } from './jsutils/isPromise'; import type { Maybe } from './jsutils/Maybe'; import type { PromiseOrValue } from './jsutils/PromiseOrValue'; @@ -14,7 +15,10 @@ import { validateSchema } from './type/validate'; import { validate } from './validation/validate'; -import type { ExecutionResult } from './execution/execute'; +import type { + AsyncExecutionResult, + ExecutionResult, +} from './execution/execute'; import { execute } from './execution/execute'; /** @@ -67,7 +71,9 @@ export interface GraphQLArgs { typeResolver?: Maybe>; } -export function graphql(args: GraphQLArgs): Promise { +export function graphql( + args: GraphQLArgs, +): Promise> { // Always return a Promise for a consistent API. return new Promise((resolve) => resolve(graphqlImpl(args))); } @@ -82,14 +88,18 @@ export function graphqlSync(args: GraphQLArgs): ExecutionResult { const result = graphqlImpl(args); // Assert that the execution was synchronous. - if (isPromise(result)) { + if (isPromise(result) || isAsyncIterable(result)) { throw new Error('GraphQL execution failed to complete synchronously.'); } return result; } -function graphqlImpl(args: GraphQLArgs): PromiseOrValue { +function graphqlImpl( + args: GraphQLArgs, +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { const { schema, source, diff --git a/src/index.ts b/src/index.ts index bce254f808..ebdf00133f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,8 @@ export { specifiedDirectives, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, + GraphQLStreamDirective, GraphQLDeprecatedDirective, GraphQLSpecifiedByDirective, // "Enum" of Type Kinds @@ -327,6 +329,9 @@ export type { ExecutionArgs, ExecutionResult, FormattedExecutionResult, + ExecutionPatchResult, + FormattedExecutionPatchResult, + AsyncExecutionResult, } from './execution/index'; // Validate GraphQL documents. diff --git a/src/type/directives.ts b/src/type/directives.ts index 13f4ad2721..9f59653f74 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -16,7 +16,7 @@ import { defineArguments, GraphQLNonNull, } from './definition'; -import { GraphQLBoolean, GraphQLString } from './scalars'; +import { GraphQLBoolean, GraphQLInt, GraphQLString } from './scalars'; /** * Test if the given value is a GraphQL directive. @@ -153,6 +153,54 @@ export const GraphQLSkipDirective: GraphQLDirective = new GraphQLDirective({ }, }); +/** + * 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: GraphQLBoolean, + description: 'Deferred when true or undefined.', + }, + 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: GraphQLBoolean, + description: 'Stream when true or undefined.', + }, + label: { + type: GraphQLString, + description: 'Unique name', + }, + initialCount: { + defaultValue: 0, + type: GraphQLInt, + description: 'Number of items to return immediately', + }, + }, +}); + /** * Constant string used for default reason for a deprecation. */ diff --git a/src/type/index.ts b/src/type/index.ts index 43b867f999..8c3e28e2c7 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -133,6 +133,8 @@ export { specifiedDirectives, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, + GraphQLStreamDirective, GraphQLDeprecatedDirective, GraphQLSpecifiedByDirective, // Constant Deprecation Reason diff --git a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts new file mode 100644 index 0000000000..c00ef14228 --- /dev/null +++ b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts @@ -0,0 +1,171 @@ +import { describe, it } from 'mocha'; + +import { DeferStreamDirectiveLabelRule } from '../rules/DeferStreamDirectiveLabelRule'; + +import { expectValidationErrors } from './harness'; + +function expectErrors(queryStr: string) { + return expectValidationErrors(DeferStreamDirectiveLabelRule, queryStr); +} + +function expectValid(queryStr: string) { + expectErrors(queryStr).toDeepEqual([]); +} + +describe('Validate: Defer/Stream directive on root field', () => { + it('Defer fragments with no label', () => { + expectValid(` + { + dog { + ...dogFragmentA @defer + ...dogFragmentB @defer + } + } + fragment dogFragmentA on Dog { + name + } + fragment dogFragmentB on Dog { + nickname + } + `); + }); + + it('Defer fragments, one with label, one without', () => { + expectValid(` + { + dog { + ...dogFragmentA @defer(label: "fragA") + ...dogFragmentB @defer + } + } + fragment dogFragmentA on Dog { + name + } + fragment dogFragmentB on Dog { + nickname + } + `); + }); + + it('Defer fragment with variable label', () => { + expectErrors(` + query($label: String) { + dog { + ...dogFragmentA @defer(label: $label) + ...dogFragmentB @defer(label: "fragA") + } + } + fragment dogFragmentA on Dog { + name + } + fragment dogFragmentB on Dog { + nickname + } + `).toDeepEqual([ + { + message: 'Directive "defer"\'s label argument must be a static string.', + locations: [{ line: 4, column: 25 }], + }, + ]); + }); + + it('Defer fragments with different labels', () => { + expectValid(` + { + dog { + ...dogFragmentA @defer(label: "fragB") + ...dogFragmentB @defer(label: "fragA") + } + } + fragment dogFragmentA on Dog { + name + } + fragment dogFragmentB on Dog { + nickname + } + `); + }); + it('Defer fragments with same label', () => { + expectErrors(` + { + dog { + ...dogFragmentA @defer(label: "fragA") + ...dogFragmentB @defer(label: "fragA") + } + } + fragment dogFragmentA on Dog { + name + } + fragment dogFragmentB on Dog { + nickname + } + `).toDeepEqual([ + { + message: 'Defer/Stream directive label argument must be unique.', + locations: [ + { line: 4, column: 25 }, + { line: 5, column: 25 }, + ], + }, + ]); + }); + it('Defer and stream with no label', () => { + expectValid(` + { + dog { + ...dogFragment @defer + } + pets @stream(initialCount: 0) @stream { + name + } + } + fragment dogFragment on Dog { + name + } + `); + }); + it('Stream with variable label', () => { + expectErrors(` + query ($label: String!) { + dog { + ...dogFragment @defer + } + pets @stream(initialCount: 0) @stream(label: $label) { + name + } + } + fragment dogFragment on Dog { + name + } + `).toDeepEqual([ + { + message: + 'Directive "stream"\'s label argument must be a static string.', + locations: [{ line: 6, column: 39 }], + }, + ]); + }); + it('Defer and stream with the same label', () => { + expectErrors(` + { + dog { + ...dogFragment @defer(label: "MyLabel") + } + pets @stream(initialCount: 0) @stream(label: "MyLabel") { + name + } + } + fragment dogFragment on Dog { + name + } + `).toDeepEqual([ + { + message: 'Defer/Stream directive label argument must be unique.', + locations: [ + { line: 4, column: 26 }, + { line: 6, column: 39 }, + ], + }, + ]); + }); +}); diff --git a/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts new file mode 100644 index 0000000000..5798047258 --- /dev/null +++ b/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts @@ -0,0 +1,258 @@ +import { describe, it } from 'mocha'; + +import { buildSchema } from '../../utilities/buildASTSchema'; + +import { DeferStreamDirectiveOnRootFieldRule } from '../rules/DeferStreamDirectiveOnRootFieldRule'; + +import { expectValidationErrorsWithSchema } from './harness'; + +function expectErrors(queryStr: string) { + return expectValidationErrorsWithSchema( + schema, + DeferStreamDirectiveOnRootFieldRule, + queryStr, + ); +} + +function expectValid(queryStr: string) { + expectErrors(queryStr).toDeepEqual([]); +} + +const schema = buildSchema(` + type Message { + body: String + sender: String + } + + type SubscriptionRoot { + subscriptionField: Message + subscriptionListField: [Message] + } + + type MutationRoot { + mutationField: Message + mutationListField: [Message] + } + + type QueryRoot { + message: Message + messages: [Message] + } + + schema { + query: QueryRoot + mutation: MutationRoot + subscription: SubscriptionRoot + } +`); + +describe('Validate: Defer/Stream directive on root field', () => { + it('Defer fragment spread on root query field', () => { + expectValid(` + { + ...rootQueryFragment @defer + } + fragment rootQueryFragment on QueryRoot { + message { + body + } + } + `); + }); + + it('Defer inline fragment spread on root query field', () => { + expectValid(` + { + ... @defer { + message { + body + } + } + } + `); + }); + + it('Defer fragment spread on root mutation field', () => { + expectErrors(` + mutation { + ...rootFragment @defer + } + fragment rootFragment on MutationRoot { + mutationField { + body + } + } + `).toDeepEqual([ + { + message: + 'Defer directive cannot be used on root mutation type "MutationRoot".', + locations: [{ line: 3, column: 25 }], + }, + ]); + }); + it('Defer inline fragment spread on root mutation field', () => { + expectErrors(` + mutation { + ... @defer { + mutationField { + body + } + } + } + `).toDeepEqual([ + { + message: + 'Defer directive cannot be used on root mutation type "MutationRoot".', + locations: [{ line: 3, column: 13 }], + }, + ]); + }); + + it('Defer fragment spread on nested mutation field', () => { + expectValid(` + mutation { + mutationField { + ... @defer { + body + } + } + } + `); + }); + + it('Defer fragment spread on root subscription field', () => { + expectErrors(` + subscription { + ...rootFragment @defer + } + fragment rootFragment on SubscriptionRoot { + subscriptionField { + body + } + } + `).toDeepEqual([ + { + message: + 'Defer directive cannot be used on root subscription type "SubscriptionRoot".', + locations: [{ line: 3, column: 25 }], + }, + ]); + }); + it('Defer inline fragment spread on root subscription field', () => { + expectErrors(` + subscription { + ... @defer { + subscriptionField { + body + } + } + } + `).toDeepEqual([ + { + message: + 'Defer directive cannot be used on root subscription type "SubscriptionRoot".', + locations: [{ line: 3, column: 13 }], + }, + ]); + }); + + it('Defer fragment spread on nested subscription field', () => { + expectValid(` + subscription { + subscriptionField { + ...nestedFragment + } + } + fragment nestedFragment on Message { + body + } + `); + }); + it('Stream field on root query field', () => { + expectValid(` + { + messages @stream { + name + } + } + `); + }); + it('Stream field on fragment on root query field', () => { + expectValid(` + { + ...rootFragment + } + fragment rootFragment on QueryType { + messages @stream { + name + } + } + `); + }); + it('Stream field on root mutation field', () => { + expectErrors(` + mutation { + mutationListField @stream { + name + } + } + `).toDeepEqual([ + { + message: + 'Stream directive cannot be used on root mutation type "MutationRoot".', + locations: [{ line: 3, column: 27 }], + }, + ]); + }); + it('Stream field on fragment on root mutation field', () => { + expectErrors(` + mutation { + ...rootFragment + } + fragment rootFragment on MutationRoot { + mutationListField @stream { + name + } + } + `).toDeepEqual([ + { + message: + 'Stream directive cannot be used on root mutation type "MutationRoot".', + locations: [{ line: 6, column: 27 }], + }, + ]); + }); + it('Stream field on root subscription field', () => { + expectErrors(` + subscription { + subscriptionListField @stream { + name + } + } + `).toDeepEqual([ + { + message: + 'Stream directive cannot be used on root subscription type "SubscriptionRoot".', + locations: [{ line: 3, column: 31 }], + }, + ]); + }); + it('Stream field on fragment on root subscription field', () => { + expectErrors(` + subscription { + ...rootFragment + } + fragment rootFragment on SubscriptionRoot { + subscriptionListField @stream { + name + } + } + `).toDeepEqual([ + { + message: + 'Stream directive cannot be used on root subscription type "SubscriptionRoot".', + locations: [{ line: 6, column: 31 }], + }, + ]); + }); +}); diff --git a/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts index 46cf014e46..c3fee6114d 100644 --- a/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts +++ b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts @@ -98,6 +98,114 @@ describe('Validate: Overlapping fields can be merged', () => { `); }); + it('Same stream directives supported', () => { + expectValid(` + fragment differentDirectivesWithDifferentAliases on Dog { + name @stream(label: "streamLabel", initialCount: 1) + name @stream(label: "streamLabel", initialCount: 1) + } + `); + }); + + it('different stream directive label', () => { + expectErrors(` + fragment conflictingArgs on Dog { + name @stream(label: "streamLabel", initialCount: 1) + name @stream(label: "anotherLabel", initialCount: 1) + } + `).toDeepEqual([ + { + message: + 'Fields "name" conflict because they have differing stream directives. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('different stream directive initialCount', () => { + expectErrors(` + fragment conflictingArgs on Dog { + name @stream(label: "streamLabel", initialCount: 1) + name @stream(label: "streamLabel", initialCount: 2) + } + `).toDeepEqual([ + { + message: + 'Fields "name" conflict because they have differing stream directives. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('different stream directive first missing args', () => { + expectErrors(` + fragment conflictingArgs on Dog { + name @stream + name @stream(label: "streamLabel", initialCount: 1) + } + `).toDeepEqual([ + { + message: + 'Fields "name" conflict because they have differing stream directives. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('different stream directive second missing args', () => { + expectErrors(` + fragment conflictingArgs on Dog { + name @stream(label: "streamLabel", initialCount: 1) + name @stream + } + `).toDeepEqual([ + { + message: + 'Fields "name" conflict because they have differing stream directives. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('mix of stream and no stream', () => { + expectErrors(` + fragment conflictingArgs on Dog { + name @stream + name + } + `).toDeepEqual([ + { + message: + 'Fields "name" conflict because they have differing stream directives. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('different stream directive both missing args', () => { + expectValid(` + fragment conflictingArgs on Dog { + name @stream + name @stream + } + `); + }); + it('Same aliases with different field targets', () => { expectErrors(` fragment sameAliasesWithDifferentFieldTargets on Dog { diff --git a/src/validation/__tests__/StreamDirectiveOnListFieldRule-test.ts b/src/validation/__tests__/StreamDirectiveOnListFieldRule-test.ts new file mode 100644 index 0000000000..261f3e6951 --- /dev/null +++ b/src/validation/__tests__/StreamDirectiveOnListFieldRule-test.ts @@ -0,0 +1,79 @@ +import { describe, it } from 'mocha'; + +import { StreamDirectiveOnListFieldRule } from '../rules/StreamDirectiveOnListFieldRule'; + +import { expectValidationErrors } from './harness'; + +function expectErrors(queryStr: string) { + return expectValidationErrors(StreamDirectiveOnListFieldRule, queryStr); +} + +function expectValid(queryStr: string) { + expectErrors(queryStr).toDeepEqual([]); +} + +describe('Validate: Stream directive on list field', () => { + it('Stream on list field', () => { + expectValid(` + fragment objectFieldSelection on Human { + pets @stream(initialCount: 0) { + name + } + } + `); + }); + + it('Stream on non-null list field', () => { + expectValid(` + fragment objectFieldSelection on Human { + relatives @stream(initialCount: 0) { + name + } + } + `); + }); + + it("Doesn't validate other directives on list fields", () => { + expectValid(` + fragment objectFieldSelection on Human { + pets @include(if: true) { + name + } + } + `); + }); + + it("Doesn't validate other directives on non-list fields", () => { + expectValid(` + fragment objectFieldSelection on Human { + pets { + name @include(if: true) + } + } + `); + }); + + it("Doesn't validate misplaced stream directives", () => { + expectValid(` + fragment objectFieldSelection on Human { + ... @stream(initialCount: 0) { + name + } + } + `); + }); + + it('reports errors when stream is used on non-list field', () => { + expectErrors(` + fragment objectFieldSelection on Human { + name @stream(initialCount: 0) + } + `).toDeepEqual([ + { + message: + 'Stream directive cannot be used on non-list field "name" on type "Human".', + locations: [{ line: 3, column: 14 }], + }, + ]); + }); +}); diff --git a/src/validation/__tests__/harness.ts b/src/validation/__tests__/harness.ts index 661256c56d..ced039d6cf 100644 --- a/src/validation/__tests__/harness.ts +++ b/src/validation/__tests__/harness.ts @@ -58,7 +58,7 @@ export const testSchema: GraphQLSchema = buildSchema(` type Human { name(surname: Boolean): String pets: [Pet] - relatives: [Human] + relatives: [Human]! } enum FurColor { diff --git a/src/validation/index.ts b/src/validation/index.ts index 58cc012ee8..bea947b121 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -6,6 +6,12 @@ export type { ValidationRule } from './ValidationContext'; // All validation rules in the GraphQL Specification. export { specifiedRules } from './specifiedRules'; +// Spec Section: "Defer And Stream Directive Labels Are Unique" +export { DeferStreamDirectiveLabelRule } from './rules/DeferStreamDirectiveLabelRule'; + +// Spec Section: "Defer And Stream Directives Are Used On Valid Root Field" +export { DeferStreamDirectiveOnRootFieldRule } from './rules/DeferStreamDirectiveOnRootFieldRule'; + // Spec Section: "Executable Definitions" export { ExecutableDefinitionsRule } from './rules/ExecutableDefinitionsRule'; @@ -57,6 +63,9 @@ export { ScalarLeafsRule } from './rules/ScalarLeafsRule'; // Spec Section: "Subscriptions with Single Root Field" export { SingleFieldSubscriptionsRule } from './rules/SingleFieldSubscriptionsRule'; +// Spec Section: "Stream Directives Are Used On List Fields" +export { StreamDirectiveOnListFieldRule } from './rules/StreamDirectiveOnListFieldRule'; + // Spec Section: "Argument Uniqueness" export { UniqueArgumentNamesRule } from './rules/UniqueArgumentNamesRule'; diff --git a/src/validation/rules/DeferStreamDirectiveLabelRule.ts b/src/validation/rules/DeferStreamDirectiveLabelRule.ts new file mode 100644 index 0000000000..2e96714a4a --- /dev/null +++ b/src/validation/rules/DeferStreamDirectiveLabelRule.ts @@ -0,0 +1,55 @@ +import { GraphQLError } from '../../error/GraphQLError'; + +import { Kind } from '../../language/kinds'; +import type { ASTVisitor } from '../../language/visitor'; + +import { + GraphQLDeferDirective, + GraphQLStreamDirective, +} from '../../type/directives'; + +import type { ValidationContext } from '../ValidationContext'; + +/** + * Stream directive on list field + * + * A GraphQL document is only valid if defer and stream directives' label argument is static and unique. + */ +export function DeferStreamDirectiveLabelRule( + context: ValidationContext, +): ASTVisitor { + const knownLabels = Object.create(null); + return { + Directive(node) { + if ( + node.name.value === GraphQLDeferDirective.name || + node.name.value === GraphQLStreamDirective.name + ) { + const labelArgument = node.arguments?.find( + (arg) => arg.name.value === 'label', + ); + const labelValue = labelArgument?.value; + if (!labelValue) { + return; + } + if (labelValue.kind !== Kind.STRING) { + context.reportError( + new GraphQLError( + `Directive "${node.name.value}"'s label argument must be a static string.`, + { nodes: node }, + ), + ); + } else if (knownLabels[labelValue.value]) { + context.reportError( + new GraphQLError( + 'Defer/Stream directive label argument must be unique.', + { nodes: [knownLabels[labelValue.value], node] }, + ), + ); + } else { + knownLabels[labelValue.value] = node; + } + } + }, + }; +} diff --git a/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts b/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts new file mode 100644 index 0000000000..8bf462936e --- /dev/null +++ b/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts @@ -0,0 +1,63 @@ +import { GraphQLError } from '../../error/GraphQLError'; + +import type { ASTVisitor } from '../../language/visitor'; + +import { + GraphQLDeferDirective, + GraphQLStreamDirective, +} from '../../type/directives'; + +import type { ValidationContext } from '../ValidationContext'; + +/** + * Stream directive on list field + * + * A GraphQL document is only valid if defer directives are not used on root mutation or subscription types. + */ +export function DeferStreamDirectiveOnRootFieldRule( + context: ValidationContext, +): ASTVisitor { + return { + Directive(node) { + const mutationType = context.getSchema().getMutationType(); + const subscriptionType = context.getSchema().getSubscriptionType(); + const parentType = context.getParentType(); + if (parentType && node.name.value === GraphQLDeferDirective.name) { + if (mutationType && parentType === mutationType) { + context.reportError( + new GraphQLError( + `Defer directive cannot be used on root mutation type "${parentType.name}".`, + { nodes: node }, + ), + ); + } + if (subscriptionType && parentType === subscriptionType) { + context.reportError( + new GraphQLError( + `Defer directive cannot be used on root subscription type "${parentType.name}".`, + { nodes: node }, + ), + ); + } + } + if (parentType && node.name.value === GraphQLStreamDirective.name) { + if (mutationType && parentType === mutationType) { + context.reportError( + new GraphQLError( + `Stream directive cannot be used on root mutation type "${parentType.name}".`, + { nodes: node }, + ), + ); + } + if (subscriptionType && parentType === subscriptionType) { + context.reportError( + new GraphQLError( + `Stream directive cannot be used on root subscription type "${parentType.name}".`, + { nodes: node }, + ), + ); + } + } + }, + }; +} diff --git a/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts index 104a4e5229..9b4cad0340 100644 --- a/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts +++ b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts @@ -5,6 +5,7 @@ import type { ObjMap } from '../../jsutils/ObjMap'; import { GraphQLError } from '../../error/GraphQLError'; import type { + DirectiveNode, FieldNode, FragmentDefinitionNode, ObjectValueNode, @@ -601,6 +602,17 @@ function findConflict( } } + // FIXME https://github.com/graphql/graphql-js/issues/2203 + const directives1 = /* c8 ignore next */ node1.directives ?? []; + const directives2 = /* c8 ignore next */ node2.directives ?? []; + if (!sameStreams(directives1, directives2)) { + return [ + [responseName, 'they have differing stream directives'], + [node1], + [node2], + ]; + } + // The return type for each field. const type1 = def1?.type; const type2 = def2?.type; @@ -638,7 +650,7 @@ function findConflict( } } -function stringifyArguments(fieldNode: FieldNode): string { +function stringifyArguments(fieldNode: FieldNode | DirectiveNode): string { // FIXME https://github.com/graphql/graphql-js/issues/2203 const args = /* c8 ignore next */ fieldNode.arguments ?? []; @@ -653,6 +665,29 @@ function stringifyArguments(fieldNode: FieldNode): string { return print(sortValueNode(inputObjectWithArgs)); } +function getStreamDirective( + directives: ReadonlyArray, +): DirectiveNode | undefined { + return directives.find((directive) => directive.name.value === 'stream'); +} + +function sameStreams( + directives1: ReadonlyArray, + directives2: ReadonlyArray, +): boolean { + const stream1 = getStreamDirective(directives1); + const stream2 = getStreamDirective(directives2); + if (!stream1 && !stream2) { + // both fields do not have streams + return true; + } else if (stream1 && stream2) { + // check if both fields have equivalent streams + return stringifyArguments(stream1) === stringifyArguments(stream2); + } + // fields have a mix of stream and no stream + return false; +} + // Two types conflict if both types could not apply to a value simultaneously. // Composite types are ignored as their individual field types will be compared // later recursively. However List and Non-Null types must match. diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index 21cb1abaf6..6ea43aa9dd 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -41,7 +41,7 @@ export function SingleFieldSubscriptionsRule( fragments[definition.name.value] = definition; } } - const fields = collectFields( + const { fields } = collectFields( schema, fragments, variableValues, diff --git a/src/validation/rules/StreamDirectiveOnListFieldRule.ts b/src/validation/rules/StreamDirectiveOnListFieldRule.ts new file mode 100644 index 0000000000..ef94f3dd6f --- /dev/null +++ b/src/validation/rules/StreamDirectiveOnListFieldRule.ts @@ -0,0 +1,41 @@ +import { GraphQLError } from '../../error/GraphQLError'; + +import type { DirectiveNode } from '../../language/ast'; +import type { ASTVisitor } from '../../language/visitor'; + +import { isListType, isWrappingType } from '../../type/definition'; +import { GraphQLStreamDirective } from '../../type/directives'; + +import type { ValidationContext } from '../ValidationContext'; + +/** + * Stream directive on list field + * + * A GraphQL document is only valid if stream directives are used on list fields. + */ +export function StreamDirectiveOnListFieldRule( + context: ValidationContext, +): ASTVisitor { + return { + Directive(node: DirectiveNode) { + const fieldDef = context.getFieldDef(); + const parentType = context.getParentType(); + if ( + fieldDef && + parentType && + node.name.value === GraphQLStreamDirective.name && + !( + isListType(fieldDef.type) || + (isWrappingType(fieldDef.type) && isListType(fieldDef.type.ofType)) + ) + ) { + context.reportError( + new GraphQLError( + `Stream directive cannot be used on non-list field "${fieldDef.name}" on type "${parentType.name}".`, + { nodes: node }, + ), + ); + } + }, + }; +} diff --git a/src/validation/specifiedRules.ts b/src/validation/specifiedRules.ts index 16e555db8a..9b5db96f3a 100644 --- a/src/validation/specifiedRules.ts +++ b/src/validation/specifiedRules.ts @@ -1,3 +1,7 @@ +// Spec Section: "Defer And Stream Directive Labels Are Unique" +import { DeferStreamDirectiveLabelRule } from './rules/DeferStreamDirectiveLabelRule'; +// Spec Section: "Defer And Stream Directives Are Used On Valid Root Field" +import { DeferStreamDirectiveOnRootFieldRule } from './rules/DeferStreamDirectiveOnRootFieldRule'; // Spec Section: "Executable Definitions" import { ExecutableDefinitionsRule } from './rules/ExecutableDefinitionsRule'; // Spec Section: "Field Selections on Objects, Interfaces, and Unions Types" @@ -41,6 +45,8 @@ import { import { ScalarLeafsRule } from './rules/ScalarLeafsRule'; // Spec Section: "Subscriptions with Single Root Field" import { SingleFieldSubscriptionsRule } from './rules/SingleFieldSubscriptionsRule'; +// Spec Section: "Stream Directives Are Used On List Fields" +import { StreamDirectiveOnListFieldRule } from './rules/StreamDirectiveOnListFieldRule'; import { UniqueArgumentDefinitionNamesRule } from './rules/UniqueArgumentDefinitionNamesRule'; // Spec Section: "Argument Uniqueness" import { UniqueArgumentNamesRule } from './rules/UniqueArgumentNamesRule'; @@ -93,6 +99,9 @@ export const specifiedRules: ReadonlyArray = Object.freeze([ NoUnusedVariablesRule, KnownDirectivesRule, UniqueDirectivesPerLocationRule, + DeferStreamDirectiveOnRootFieldRule, + DeferStreamDirectiveLabelRule, + StreamDirectiveOnListFieldRule, KnownArgumentNamesRule, UniqueArgumentNamesRule, ValuesOfCorrectTypeRule, diff --git a/website/docs/tutorials/defer-stream.md b/website/docs/tutorials/defer-stream.md new file mode 100644 index 0000000000..5235cab4fd --- /dev/null +++ b/website/docs/tutorials/defer-stream.md @@ -0,0 +1,26 @@ +--- +title: Enabling Defer & Stream +sidebar_label: Enabling Defer & Stream +--- + +The `@defer` and `@stream` directives are not enabled by default. In order to use these directives, you must add them to your GraphQL Schema. + +```js +import { + GraphQLSchema, + GraphQLDeferDirective, + GraphQLStreamDirective, + specifiedDirectives, +} from 'graphql'; + +const schema = new GraphQLSchema({ + query, + directives: [ + ...specifiedDirectives, + GraphQLDeferDirective, + GraphQLStreamDirective, + ], +}); +``` + +If the `directives` option is passed to `GraphQLSchema`, the default directives will not be included. `specifiedDirectives` must be passed to ensure all standard directives are added in addition to `defer` & `stream`. diff --git a/website/sidebars.js b/website/sidebars.js index 79fe5e9d8b..5201b4fd95 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -16,5 +16,6 @@ module.exports = { items: ['tutorials/constructing-types'], }, 'tutorials/express-graphql', + 'tutorials/defer-stream', ], };