diff --git a/.changeset/many-olives-eat.md b/.changeset/many-olives-eat.md new file mode 100644 index 0000000000..123ff0db0a --- /dev/null +++ b/.changeset/many-olives-eat.md @@ -0,0 +1,8 @@ +--- +'graphql-executor': patch +--- + +Support incremental delivery with defer/stream directives + +Port of https://github.com/graphql/graphql-js/pull/2839 +defer/stream support is enabled by default, but can be disabled using the `disableIncremental` argument. diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts new file mode 100644 index 0000000000..60a080cf40 --- /dev/null +++ b/src/execution/__tests__/defer-test.ts @@ -0,0 +1,300 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { DocumentNode } from 'graphql'; +import { + GraphQLID, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + parse, +} from 'graphql'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + +import { execute } from '../execute'; +import { expectJSON } from '../../__testUtils__/expectJSON'; + +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 }, + errorField: { + type: GraphQLString, + resolve: () => { + throw new Error('bad'); + }, + }, + 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, disableIncremental = false) { + const schema = new GraphQLSchema({ query }); + + const result = await execute({ + schema, + document, + rootValue: {}, + disableIncremental, + }); + + 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); + + expect(result).to.deep.equal([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + data: { + id: '1', + name: 'Luke', + }, + path: ['hero'], + hasNext: false, + }, + ]); + }); + it('Can disable defer using disableIncremental argument', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document, true); + + expect(result).to.deep.equal({ + data: { + hero: { + id: '1', + name: 'Luke', + }, + }, + }); + }); + 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); + + expect(result).to.deep.equal({ + data: { + hero: { + id: '1', + name: 'Luke', + }, + }, + }); + }); + it('Can defer fragments containing 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); + + expect(result).to.deep.equal([ + { + data: {}, + hasNext: true, + }, + { + data: { + hero: { + id: '1', + }, + }, + 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); + + expect(result).to.deep.equal([ + { + 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 an inline fragment', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ... on Hero @defer(label: "InlineDeferred") { + name + } + } + } + `); + const result = await complete(document); + + expect(result).to.deep.equal([ + { + 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, + }, + ]); + }); +}); diff --git a/src/execution/__tests__/flattenAsyncIterator-test.ts b/src/execution/__tests__/flattenAsyncIterator-test.ts new file mode 100644 index 0000000000..ece7402157 --- /dev/null +++ b/src/execution/__tests__/flattenAsyncIterator-test.ts @@ -0,0 +1,141 @@ +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); + // istanbul ignore next (Shouldn't be reached) + yield await Promise.resolve(2.2); + })(), + ); + // istanbul ignore next (Shouldn't be reached) + yield await Promise.resolve(3); + } + + 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); + // istanbul ignore next (Shouldn't be reached) + yield await Promise.resolve(2.2); + })(), + ); + // istanbul ignore next (Shouldn't be reached) + yield await Promise.resolve(3); + } + + 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'); + } 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 3e10df3a8d..66ca8946a6 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import type { ExecutionResult, GraphQLFieldResolver } from 'graphql'; +import type { GraphQLFieldResolver } from 'graphql'; import { GraphQLList, GraphQLObjectType, @@ -15,6 +15,7 @@ import type { PromiseOrValue } from '../../jsutils/PromiseOrValue'; import { expectJSON } from '../../__testUtils__/expectJSON'; +import type { ExecutionResult, AsyncExecutionResult } from '../executor'; import { execute, executeSync } from '../execute'; describe('Execute: Accepts any iterable as list value', () => { @@ -85,7 +86,7 @@ describe('Execute: Accepts async iterables as list value', () => { function completeObjectList( resolve: GraphQLFieldResolver<{ index: number }, unknown>, - ): PromiseOrValue { + ): PromiseOrValue> { 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 df42db5791..6c450eaf6e 100644 --- a/src/execution/__tests__/mutations-test.ts +++ b/src/execution/__tests__/mutations-test.ts @@ -3,6 +3,9 @@ import { describe, it } from 'mocha'; import { GraphQLInt, GraphQLObjectType, GraphQLSchema, parse } from 'graphql'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; +import { invariant } from '../../jsutils/invariant'; + import { expectJSON } from '../../__testUtils__/expectJSON'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick'; @@ -47,6 +50,15 @@ class Root { const numberHolderType = new GraphQLObjectType({ fields: { theNumber: { type: GraphQLInt }, + promiseToGetTheNumber: { + type: GraphQLInt, + resolve: (root) => + new Promise((resolve) => { + process.nextTick(() => { + resolve(root.theNumber); + }); + }), + }, }, name: 'NumberHolder', }); @@ -188,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 = []; + + invariant(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 = []; + + invariant(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 e1605d4e9f..b27da27ce0 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import type { ExecutionResult } from 'graphql'; import { GraphQLNonNull, GraphQLObjectType, @@ -11,8 +10,11 @@ import { parse, } from 'graphql'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue'; + import { expectJSON } from '../../__testUtils__/expectJSON'; +import type { ExecutionResult, AsyncExecutionResult } from '../executor'; import { execute, executeSync } from '../execute'; const syncError = new Error('sync'); @@ -110,7 +112,7 @@ const schema = buildSchema(` function executeQuery( query: string, rootValue: unknown, -): ExecutionResult | Promise { +): PromiseOrValue> { 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..5c7cd5a18e --- /dev/null +++ b/src/execution/__tests__/stream-test.ts @@ -0,0 +1,984 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { DocumentNode } from 'graphql'; +import { + GraphQLID, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + parse, +} from 'graphql'; + +import { invariant } from '../../jsutils/invariant'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + +import { execute } from '../execute'; +import { expectJSON } from '../../__testUtils__/expectJSON'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + asyncName: { + type: GraphQLString, + resolve(rootValue) { + return Promise.resolve(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'], + }, + asyncList: { + type: new GraphQLList(friendType), + resolve: () => friends.map((f) => Promise.resolve(f)), + }, + 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'); + }, + }, + asyncIterableInvalid: { + type: new GraphQLList(GraphQLString), + async *resolve() { + yield await Promise.resolve(friends[0].name); + yield await Promise.resolve({}); + }, + }, + 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 new Promise((r) => setTimeout(r, 1)); + yield friend; + } + }, + }, + asyncIterableListNoReturn: { + type: new GraphQLList(friendType), + resolve() { + let i = 0; + return { + [Symbol.asyncIterator]: () => ({ + async next() { + const friend = friends[i++]; + if (friend) { + await new Promise((r) => setTimeout(r, 1)); + 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, 1)); + }, + }, + }, + name: 'Query', +}); + +async function complete(document: DocumentNode, disableIncremental = false) { + const schema = new GraphQLSchema({ query }); + + const result = await execute({ + schema, + document, + rootValue: {}, + disableIncremental, + }); + + 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: {} }); + + invariant(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); + + expect(result).to.deep.equal([ + { + 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); + + expect(result).to.deep.equal([ + { + 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('Returns label from stream directive', async () => { + const document = parse( + '{ scalarList @stream(initialCount: 1, label: "scalar-stream") }', + ); + const result = await complete(document); + + expect(result).to.deep.equal([ + { + 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 disableIncremental argument', async () => { + const document = parse('{ scalarList @stream(initialCount: 0) }'); + const result = await complete(document, true); + + expect(result).to.deep.equal({ + data: { scalarList: ['apple', 'banana', 'coconut'] }, + }); + }); + it('Can disable @stream using if argument', async () => { + const document = parse( + '{ scalarList @stream(initialCount: 0, if: false) }', + ); + const result = await complete(document); + + expect(result).to.deep.equal({ + data: { scalarList: ['apple', 'banana', 'coconut'] }, + }); + }); + 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); + expect(result).to.deep.equal([ + { + data: { + asyncList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + data: { + name: 'Leia', + id: '3', + }, + path: ['asyncList', 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); + expect(result).to.deep.equal([ + { + 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); + expect(result).to.deep.equal([ + { + 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('Can stream a field that returns an async iterable', async () => { + const document = parse(` + query { + asyncIterableList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await completeAsync(document, 4); + expect(result).to.deep.equal([ + { + 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 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 promises returned by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + asyncIterableList @stream(initialCount: 1) { + name + asyncName + } + } + `); + const result = await complete(document); + expect(result).to.deep.equal([ + { + 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: 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 { + name + } + `); + const result = await complete(document); + expect(result).to.deep.equal([ + { + data: { + asyncIterableList: [ + { + id: '1', + }, + ], + }, + hasNext: true, + }, + { + data: { + name: 'Luke', + }, + path: ['asyncIterableList', 0], + label: 'DeferName', + 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: { + name: 'Han', + }, + path: ['asyncIterableList', 1], + label: 'DeferName', + hasNext: true, + }, + { + data: { + name: '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); + expect(result).to.deep.equal([ + { + 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: {} }); + invariant(isAsyncIterable(executeResult)); + const iterator = executeResult[Symbol.asyncIterator](); + + const result1 = await iterator.next(); + expect(result1).to.deep.equal({ + done: false, + value: { + data: { + asyncIterableListDelayed: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + }); + + iterator.return?.(); + + // this result had started processing before return was called + const result2 = await iterator.next(); + expect(result2).to.deep.equal({ + 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(); + expect(result3).to.deep.equal({ + done: false, + value: { + hasNext: false, + }, + }); + }); + 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: {} }); + invariant(isAsyncIterable(executeResult)); + const iterator = executeResult[Symbol.asyncIterator](); + + const result1 = await iterator.next(); + expect(result1).to.deep.equal({ + done: false, + value: { + data: { + asyncIterableListNoReturn: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + }); + + iterator.return?.(); + + // this result had started processing before return was called + const result2 = await iterator.next(); + expect(result2).to.deep.equal({ + 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(); + expect(result3).to.deep.equal({ + done: false, + value: { + hasNext: false, + }, + }); + }); + 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: {} }); + invariant(isAsyncIterable(executeResult)); + const iterator = executeResult[Symbol.asyncIterator](); + + const result1 = await iterator.next(); + expect(result1).to.deep.equal({ + done: false, + value: { + data: { + asyncIterableListDelayed: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }, + }); + + iterator.throw?.(new Error('bad')); + + // this result had started processing before return was called + const result2 = await iterator.next(); + expect(result2).to.deep.equal({ + 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(); + expect(result3).to.deep.equal({ + done: false, + value: { + hasNext: false, + }, + }); + }); +}); diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index a894cf4588..543e769264 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -85,17 +85,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 + } } } } @@ -125,7 +130,12 @@ function createSubscription(pubsub: SimplePubSub) { }), }; - return subscribe({ schema: emailSchema, document, rootValue: data }); + return subscribe({ + schema: emailSchema, + document, + rootValue: data, + variableValues, + }); } async function expectPromise(promise: Promise) { @@ -683,6 +693,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, + }); + invariant(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 = await createSubscription(pubsub); diff --git a/src/execution/__tests__/sync-test.ts b/src/execution/__tests__/sync-test.ts index 11fe03ea29..8d0ea00cbf 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 d4c3f13283..6a36e4a468 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -16,10 +16,23 @@ import { typeFromAST, } from 'graphql'; +import type { Maybe } from '../jsutils/Maybe'; import type { ObjMap } from '../jsutils/ObjMap'; +import { GraphQLDeferDirective } from '../type/index'; + import { getDirectiveValues } from './values'; +export interface PatchFields { + label?: string; + fields: Map>; +} + +export interface FieldsAndPatches { + fields: Map>; + patches: Array; +} + /** * Given a selectionSet, collect all of the fields and returns it at the end. * @@ -35,8 +48,10 @@ export function collectFields( variableValues: { [variable: string]: unknown }, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, -): Map> { + ignoreDefer?: Maybe, +): FieldsAndPatches { const fields = new Map(); + const patches: Array = []; collectFieldsImpl( schema, fragments, @@ -44,9 +59,11 @@ export function collectFields( runtimeType, selectionSet, fields, + patches, new Set(), + ignoreDefer, ); - return fields; + return { fields, patches }; } /** @@ -65,8 +82,10 @@ export function collectSubfields( variableValues: { [variable: string]: unknown }, returnType: GraphQLObjectType, fieldNodes: ReadonlyArray, -): Map> { + ignoreDefer?: Maybe, +): FieldsAndPatches { const subFieldNodes = new Map(); + const subPatches: Array = []; const visitedFragmentNames = new Set(); for (const node of fieldNodes) { if (node.selectionSet) { @@ -77,11 +96,16 @@ export function collectSubfields( returnType, node.selectionSet, subFieldNodes, + subPatches, visitedFragmentNames, + ignoreDefer, ); } } - return subFieldNodes; + return { + fields: subFieldNodes, + patches: subPatches, + }; } function collectFieldsImpl( @@ -91,7 +115,9 @@ function collectFieldsImpl( runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, fields: Map>, + patches: Array, visitedFragmentNames: Set, + ignoreDefer?: Maybe, ): void { for (const selection of selectionSet.selections) { switch (selection.kind) { @@ -115,26 +141,53 @@ function collectFieldsImpl( ) { continue; } - collectFieldsImpl( - schema, - fragments, - variableValues, - runtimeType, - selection.selectionSet, - fields, - visitedFragmentNames, - ); + + const defer = getDeferValues(variableValues, selection); + + if (!ignoreDefer && defer) { + const patchFields = new Map(); + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + selection.selectionSet, + patchFields, + patches, + visitedFragmentNames, + ignoreDefer, + ); + patches.push({ + label: defer.label, + fields: patchFields, + }); + } else { + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + selection.selectionSet, + fields, + patches, + visitedFragmentNames, + ignoreDefer, + ); + } break; } case Kind.FRAGMENT_SPREAD: { const fragName = selection.name.value; - if ( - visitedFragmentNames.has(fragName) || - !shouldIncludeNode(variableValues, selection) - ) { + + if (!shouldIncludeNode(variableValues, selection)) { continue; } - visitedFragmentNames.add(fragName); + + const defer = getDeferValues(variableValues, selection); + if (visitedFragmentNames.has(fragName) && !defer) { + continue; + } + const fragment = fragments[fragName]; if ( !fragment || @@ -142,21 +195,68 @@ function collectFieldsImpl( ) { continue; } - collectFieldsImpl( - schema, - fragments, - variableValues, - runtimeType, - fragment.selectionSet, - fields, - visitedFragmentNames, - ); + visitedFragmentNames.add(fragName); + + if (!ignoreDefer && defer) { + const patchFields = new Map(); + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + fragment.selectionSet, + patchFields, + patches, + visitedFragmentNames, + ignoreDefer, + ); + patches.push({ + label: defer.label, + fields: patchFields, + }); + } else { + collectFieldsImpl( + schema, + fragments, + variableValues, + runtimeType, + fragment.selectionSet, + fields, + patches, + visitedFragmentNames, + ignoreDefer, + ); + } 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 2bd7efe754..c9e4011fc4 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,9 +1,12 @@ -import type { ExecutionResult } from 'graphql'; - import type { PromiseOrValue } from '../jsutils/PromiseOrValue'; import { isPromise } from '../jsutils/isPromise'; +import { isAsyncIterable } from '../jsutils/isAsyncIterable'; -import type { ExecutionArgs } from './executor'; +import type { + ExecutionArgs, + ExecutionResult, + AsyncExecutionResult, +} from './executor'; import { Executor } from './executor'; /** @@ -16,7 +19,9 @@ import { Executor } from './executor'; * 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> { const executor = new Executor(); return executor.executeQueryOrMutation(args); } @@ -30,7 +35,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.'); } diff --git a/src/execution/executor.ts b/src/execution/executor.ts index db042aad57..1c2fa8ea2e 100644 --- a/src/execution/executor.ts +++ b/src/execution/executor.ts @@ -1,6 +1,5 @@ import type { DocumentNode, - ExecutionResult, GraphQLSchema, GraphQLObjectType, GraphQLOutputType, @@ -32,6 +31,8 @@ import { locatedError, } from 'graphql'; +import { GraphQLStreamDirective } from '../type/directives'; + import type { Path } from '../jsutils/Path'; import type { ObjMap } from '../jsutils/ObjMap'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue'; @@ -48,12 +49,17 @@ import { isAsyncIterable } from '../jsutils/isAsyncIterable'; import { isIterableObject } from '../jsutils/isIterableObject'; import { resolveAfterAll } from '../jsutils/resolveAfterAll'; -import { getVariableValues, getArgumentValues } from './values'; +import { + getVariableValues, + getArgumentValues, + getDirectiveValues, +} from './values'; import { collectFields, collectSubfields as _collectSubfields, } from './collectFields'; import { mapAsyncIterator } from './mapAsyncIterator'; +import { flattenAsyncIterator } from './flattenAsyncIterator'; /** * Terminology @@ -91,7 +97,13 @@ interface ExecutionContext { fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; + disableIncremental: boolean; errors: Array; + subsequentPayloads: Array>>; + initialResult?: ExecutionResult; + iterators: Array>; + isDone: boolean; + hasReturnedInitialResult: boolean; } export interface ExecutionArgs { @@ -104,8 +116,62 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + disableIncremental?: Maybe; +} + +/** + * The result of GraphQL execution. + * + * - `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, + TExtensions = ObjMap, +> { + 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 = ObjMap | unknown, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data?: TData | null; + path?: ReadonlyArray; + label?: string; + hasNext: boolean; + extensions?: TExtensions; +} + +/** + * Same as ExecutionPatchResult, but without hasNext + */ +interface DispatcherResult { + errors?: ReadonlyArray; + data?: ObjMap | unknown | null; + path: ReadonlyArray; + label?: string; + extensions?: ObjMap; } +export type AsyncExecutionResult = ExecutionResult | ExecutionPatchResult; + /** * Executor class responsible for implementing the Execution section of the GraphQL spec. * @@ -130,20 +196,29 @@ export class Executor { exeContext: ExecutionContext, returnType: GraphQLObjectType, fieldNodes: ReadonlyArray, - ) => - _collectSubfields( - exeContext.schema, - exeContext.fragments, - exeContext.variableValues, + ) => { + const { schema, fragments, variableValues, disableIncremental } = + exeContext; + return _collectSubfields( + schema, + fragments, + variableValues, returnType, fieldNodes, - ), + disableIncremental, + ); + }, ); /** * Implements the "Executing requests" section of the spec for queries and mutations. */ - executeQueryOrMutation(args: ExecutionArgs): PromiseOrValue { + executeQueryOrMutation( + args: ExecutionArgs, + ): PromiseOrValue< + | ExecutionResult + | AsyncGenerator + > { const exeContext = this.buildExecutionContext(args); // If a valid execution context cannot be created due to incorrect arguments, @@ -157,7 +232,10 @@ export class Executor { async executeSubscription( args: ExecutionArgs, - ): Promise | ExecutionResult> { + ): Promise< + | AsyncGenerator + | ExecutionResult + > { const exeContext = this.buildExecutionContext(args); // If a valid execution context cannot be created due to incorrect arguments, @@ -185,7 +263,10 @@ export class Executor { executeQueryOrMutationImpl( exeContext: ExecutionContext, - ): PromiseOrValue { + ): PromiseOrValue< + | ExecutionResult + | AsyncGenerator + > { // Return data or a Promise that will eventually resolve to the data described // by the "Response" section of the GraphQL specification. @@ -201,17 +282,17 @@ export class Executor { const result = this.executeQueryOrMutationRootFields(exeContext); if (isPromise(result)) { return result.then( - (data) => this.buildResponse(data, exeContext.errors), + (data) => this.buildResponse(exeContext, data), (error) => { exeContext.errors.push(error); - return this.buildResponse(null, exeContext.errors); + return this.buildResponse(exeContext, null); }, ); } - return this.buildResponse(result, exeContext.errors); + return this.buildResponse(exeContext, result); } catch (error) { exeContext.errors.push(error); - return this.buildResponse(null, exeContext.errors); + return this.buildResponse(exeContext, null); } } @@ -220,10 +301,22 @@ export class Executor { * response defined by the "Response" section of the GraphQL specification. */ buildResponse( + exeContext: ExecutionContext, data: ObjMap | null, - errors: ReadonlyArray, - ): ExecutionResult { - return errors.length === 0 ? { data } : { errors, data }; + ): PromiseOrValue< + | ExecutionResult + | AsyncGenerator + > { + const initialResult = + exeContext.errors.length === 0 + ? { data } + : { errors: exeContext.errors, data }; + + if (this.hasSubsequentPayloads(exeContext)) { + return this.get(exeContext, initialResult); + } + + return initialResult; } /** @@ -267,6 +360,7 @@ export class Executor { fieldResolver, typeResolver, subscribeFieldResolver, + disableIncremental, } = args; // If arguments are missing or incorrectly typed, this is an internal @@ -330,7 +424,12 @@ export class Executor { fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, + disableIncremental: disableIncremental ?? false, errors: [], + subsequentPayloads: [], + iterators: [], + isDone: false, + hasReturnedInitialResult: false, }; } @@ -354,9 +453,15 @@ export class Executor { */ executeQueryOrMutationRootFields( exeContext: ExecutionContext, - // @ts-expect-error ): PromiseOrValue | null> { - const { schema, operation, rootValue } = exeContext; + const { + schema, + fragments, + rootValue, + operation, + variableValues, + disableIncremental, + } = exeContext; // TODO: replace getOperationRootType with schema.getRootType const rootType = getOperationRootType(schema, operation); @@ -367,45 +472,72 @@ export class Executor { ); } */ - const rootFields = collectFields( - exeContext.schema, - exeContext.fragments, - exeContext.variableValues, + const { fields, patches } = collectFields( + schema, + fragments, + variableValues, rootType, operation.selectionSet, + disableIncremental, ); const path = undefined; + let result; switch (operation.operation) { // TODO: Change 'query', etc. => to OperationTypeNode.QUERY, etc. when upstream // graphql-js properly exports OperationTypeNode as a value. case 'query': - return this.executeFields( + result = this.executeFields( exeContext, rootType, rootValue, path, - rootFields, + fields, + exeContext.errors, ); + break; case 'mutation': - return this.executeFieldsSerially( + result = this.executeFieldsSerially( exeContext, rootType, rootValue, path, - rootFields, + fields, ); - case 'subscription': - // TODO: deprecate `subscribe` and move all logic here + break; + default: // Temporary solution until we finish merging execute and subscribe together - return this.executeFields( + result = this.executeFields( exeContext, rootType, rootValue, path, - rootFields, + fields, + exeContext.errors, ); } + + for (const patch of patches) { + const { label, fields: patchFields } = patch; + const errors: Array = []; + + this.addFields( + exeContext, + this.executeFields( + exeContext, + rootType, + rootValue, + path, + patchFields, + errors, + ), + errors, + label, + path, + ); + } + + return result; } /** @@ -429,6 +561,7 @@ export class Executor { sourceValue, fieldNodes, fieldPath, + exeContext.errors, ); if (result === undefined) { return results; @@ -456,6 +589,7 @@ export class Executor { sourceValue: unknown, path: Path | undefined, fields: Map>, + errors: Array, ): PromiseOrValue> { const results = Object.create(null); const promises: Array> = []; @@ -468,6 +602,7 @@ export class Executor { sourceValue, fieldNodes, fieldPath, + errors, ); if (result !== undefined) { @@ -505,6 +640,7 @@ export class Executor { source: unknown, fieldNodes: ReadonlyArray, path: Path, + errors: Array, ): PromiseOrValue { const fieldDef = this.getFieldDef( exeContext.schema, @@ -554,6 +690,7 @@ export class Executor { info, path, resolved, + errors, ), ); } else { @@ -564,6 +701,7 @@ export class Executor { info, path, result, + errors, ); } @@ -572,13 +710,13 @@ export class Executor { // to take a second callback for the error case. return completed.then(undefined, (rawError) => { const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return this.handleFieldError(error, returnType, exeContext); + return this.handleFieldError(error, returnType, errors); }); } return completed; } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return this.handleFieldError(error, returnType, exeContext); + return this.handleFieldError(error, returnType, errors); } } @@ -608,7 +746,7 @@ export class Executor { 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. @@ -618,7 +756,7 @@ export class Executor { // 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; } @@ -650,6 +788,7 @@ export class Executor { info: GraphQLResolveInfo, path: Path, result: unknown, + errors: Array, ): PromiseOrValue { // If result is an Error, throw a located error. if (result instanceof Error) { @@ -666,6 +805,7 @@ export class Executor { info, path, result, + errors, ); if (completed === null) { throw new Error( @@ -689,6 +829,7 @@ export class Executor { info, path, result, + errors, ); } @@ -708,6 +849,7 @@ export class Executor { info, path, result, + errors, ); } @@ -721,6 +863,7 @@ export class Executor { info, path, result, + errors, ); } @@ -742,6 +885,7 @@ export class Executor { info: GraphQLResolveInfo, path: Path, result: unknown, + errors: Array, ): PromiseOrValue> { const itemType = returnType.ofType; @@ -755,6 +899,7 @@ export class Executor { info, path, iterator, + errors, ); } @@ -764,6 +909,8 @@ export class Executor { ); } + const stream = this.getStreamValues(exeContext, fieldNodes); + // This is specified as a simple map, however we're optimizing the path // where the list contains no Promises by avoiding creating another Promise. const promises: Array> = []; @@ -774,6 +921,24 @@ export class Executor { // since from here on it is not ever accessed by resolver functions. const itemPath = addPath(path, index, undefined); + if ( + stream && + typeof stream.initialCount === 'number' && + index >= stream.initialCount + ) { + this.addValue( + itemPath, + item, + exeContext, + fieldNodes, + info, + itemType, + stream.label, + ); + index++; + continue; + } + this.completeListItemValue( completedResults, index++, @@ -784,6 +949,7 @@ export class Executor { fieldNodes, info, itemPath, + errors, ); } @@ -794,6 +960,50 @@ export class Executor { return resolveAfterAll(completedResults, promises); } + /** + * 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. + */ + getStreamValues( + exeContext: ExecutionContext, + fieldNodes: ReadonlyArray, + ): + | undefined + | { + initialCount?: number; + label?: string; + } { + if (exeContext.disableIncremental) { + 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; + } + + return { + initialCount: + // istanbul ignore next (initialCount is required number argument) + typeof stream.initialCount === 'number' + ? stream.initialCount + : undefined, + 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. @@ -805,7 +1015,10 @@ export class Executor { info: GraphQLResolveInfo, path: Path, iterator: AsyncIterator, + errors: Array, ): Promise> { + const stream = this.getStreamValues(exeContext, fieldNodes); + // This is specified as a simple map, however we're optimizing the path // where the list contains no Promises by avoiding creating another Promise. const promises: Array> = []; @@ -813,6 +1026,24 @@ export class Executor { let index = 0; // eslint-disable-next-line no-constant-condition while (true) { + if ( + stream && + typeof stream.initialCount === 'number' && + index >= stream.initialCount + ) { + this.addAsyncIteratorValue( + index, + iterator, + exeContext, + fieldNodes, + info, + itemType, + path, + stream.label, + ); + break; + } + const itemPath = addPath(path, index, undefined); let iteratorResult: IteratorResult; @@ -821,9 +1052,7 @@ export class Executor { iteratorResult = await iterator.next(); } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); - completedResults.push( - this.handleFieldError(error, itemType, exeContext), - ); + completedResults.push(this.handleFieldError(error, itemType, errors)); break; } @@ -842,6 +1071,7 @@ export class Executor { fieldNodes, info, itemPath, + errors, ); index++; @@ -862,6 +1092,7 @@ export class Executor { fieldNodes: ReadonlyArray, info: GraphQLResolveInfo, itemPath: Path, + errors: Array, ): void { try { let completedItem; @@ -874,6 +1105,7 @@ export class Executor { info, itemPath, resolved, + errors, ), ); } else { @@ -884,6 +1116,7 @@ export class Executor { info, itemPath, item, + errors, ); } @@ -902,7 +1135,7 @@ export class Executor { fieldNodes, pathToArray(itemPath), ); - return this.handleFieldError(error, itemType, exeContext); + return this.handleFieldError(error, itemType, errors); }) .then((resolved) => { completedResults[index] = resolved; @@ -911,11 +1144,7 @@ export class Executor { promises.push(promise); } catch (rawError) { const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); - completedResults[index] = this.handleFieldError( - error, - itemType, - exeContext, - ); + completedResults[index] = this.handleFieldError(error, itemType, errors); } } @@ -947,6 +1176,7 @@ export class Executor { info: GraphQLResolveInfo, path: Path, result: unknown, + errors: Array, ): PromiseOrValue> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -968,6 +1198,7 @@ export class Executor { info, path, result, + errors, ), ); } @@ -986,6 +1217,7 @@ export class Executor { info, path, result, + errors, ); } @@ -1054,14 +1286,8 @@ export class Executor { info: GraphQLResolveInfo, path: Path, result: unknown, + errors: Array, ): PromiseOrValue> { - // Collect sub-fields to execute to complete this value. - const subFieldNodes = this.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. @@ -1077,12 +1303,13 @@ export class Executor { if (!resolvedIsTypeOf) { throw this.invalidReturnTypeError(returnType, result, fieldNodes); } - return this.executeFields( + return this.collectAndExecuteSubfields( exeContext, returnType, - result, + fieldNodes, path, - subFieldNodes, + result, + errors, ); }); } @@ -1092,12 +1319,13 @@ export class Executor { } } - return this.executeFields( + return this.collectAndExecuteSubfields( exeContext, returnType, - result, + fieldNodes, path, - subFieldNodes, + result, + errors, ); } @@ -1114,6 +1342,49 @@ export class Executor { ); } + collectAndExecuteSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + path: Path, + result: unknown, + errors: Array, + ): PromiseOrValue> { + // Collect sub-fields to execute to complete this value. + const { fields: subFieldNodes, patches: subPatches } = + this.collectSubfields(exeContext, returnType, fieldNodes); + + const subFields = this.executeFields( + exeContext, + returnType, + result, + path, + subFieldNodes, + errors, + ); + + for (const subPatch of subPatches) { + const { label, fields: subPatchFieldNodes } = subPatch; + const subPatchErrors: Array = []; + this.addFields( + exeContext, + this.executeFields( + exeContext, + returnType, + result, + path, + subPatchFieldNodes, + subPatchErrors, + ), + subPatchErrors, + label, + path, + ); + } + + return subFields; + } + /** * This method looks up the field on the given type definition. * It has special casing for the three introspection fields, @@ -1149,7 +1420,10 @@ export class Executor { async executeSubscriptionImpl( exeContext: ExecutionContext, - ): Promise | ExecutionResult> { + ): Promise< + | AsyncGenerator + | ExecutionResult + > { const resultOrStream = await this.createSourceEventStreamImpl(exeContext); if (!isAsyncIterable(resultOrStream)) { @@ -1171,7 +1445,9 @@ export class Executor { }; // Map every source value to a ExecutionResult value as described above. - return mapAsyncIterator(resultOrStream, mapSourceToResponse); + return flattenAsyncIterator( + mapAsyncIterator(resultOrStream, mapSourceToResponse), + ); } async createSourceEventStreamImpl( @@ -1202,8 +1478,14 @@ export class Executor { async executeSubscriptionRootField( exeContext: ExecutionContext, ): Promise { - const { schema, fragments, operation, variableValues, rootValue } = - exeContext; + const { + schema, + fragments, + rootValue, + operation, + variableValues, + disableIncremental, + } = exeContext; const rootType = schema.getSubscriptionType(); if (rootType == null) { @@ -1213,14 +1495,15 @@ export class Executor { ); } - const rootFields = collectFields( + const { fields } = collectFields( schema, fragments, variableValues, rootType, operation.selectionSet, + disableIncremental, ); - const [responseName, fieldNodes] = [...rootFields.entries()][0]; + const [responseName, fieldNodes] = [...fields.entries()][0]; const fieldDef = this.getFieldDef(schema, rootType, fieldNodes[0]); if (!fieldDef) { @@ -1266,6 +1549,309 @@ export class Executor { throw locatedError(error, fieldNodes, pathToArray(path)); } } + + hasSubsequentPayloads(exeContext: ExecutionContext) { + return exeContext.subsequentPayloads.length !== 0; + } + + addFields( + exeContext: ExecutionContext, + promiseOrData: PromiseOrValue>, + errors: Array, + label?: string, + path?: Path, + ): void { + exeContext.subsequentPayloads.push( + Promise.resolve(promiseOrData).then((data) => ({ + value: this.createPatchResult(data, label, path, errors), + done: false, + })), + ); + } + + addValue( + path: Path, + promiseOrData: PromiseOrValue, + exeContext: ExecutionContext, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + label?: string, + ): void { + const errors: Array = []; + exeContext.subsequentPayloads.push( + Promise.resolve(promiseOrData) + .then((resolved) => + this.completeValue( + exeContext, + itemType, + fieldNodes, + info, + path, + resolved, + errors, + ), + ) + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + .then(undefined, (rawError) => { + const error = locatedError(rawError, fieldNodes, pathToArray(path)); + return this.handleFieldError(error, itemType, errors); + }) + .then((data) => ({ + value: this.createPatchResult(data, label, path, errors), + done: false, + })), + ); + } + + addAsyncIteratorValue( + initialIndex: number, + iterator: AsyncIterator, + exeContext: ExecutionContext, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + path?: Path, + label?: string, + ): void { + const { subsequentPayloads, iterators } = exeContext; + iterators.push(iterator); + const next = (index: number) => { + const fieldPath = addPath(path, index, undefined); + const patchErrors: Array = []; + subsequentPayloads.push( + iterator.next().then( + ({ value: data, done }) => { + if (done) { + iterators.splice(iterators.indexOf(iterator), 1); + return { value: undefined, done: true }; + } + + // eslint-disable-next-line node/callback-return + next(index + 1); + + try { + const completedItem = this.completeValue( + exeContext, + itemType, + fieldNodes, + info, + fieldPath, + data, + patchErrors, + ); + + if (isPromise(completedItem)) { + return completedItem.then((resolveItem) => ({ + value: this.createPatchResult( + resolveItem, + label, + fieldPath, + patchErrors, + ), + done: false, + })); + } + + return { + value: this.createPatchResult( + completedItem, + label, + fieldPath, + patchErrors, + ), + done: false, + }; + } catch (rawError) { + const error = locatedError( + rawError, + fieldNodes, + pathToArray(fieldPath), + ); + this.handleFieldError(error, itemType, patchErrors); + return { + value: this.createPatchResult( + null, + label, + fieldPath, + patchErrors, + ), + done: false, + }; + } + }, + (rawError) => { + const error = locatedError( + rawError, + fieldNodes, + pathToArray(fieldPath), + ); + this.handleFieldError(error, itemType, patchErrors); + return { + value: this.createPatchResult( + null, + label, + fieldPath, + patchErrors, + ), + done: false, + }; + }, + ), + ); + }; + next(initialIndex); + } + + _race( + exeContext: ExecutionContext, + ): Promise> { + if (exeContext.isDone) { + return Promise.resolve({ + value: { + hasNext: false, + }, + done: false, + }); + } + return new Promise((resolve) => { + let resolved = false; + exeContext.subsequentPayloads.forEach((promise) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + promise.then((payload) => { + if (resolved) { + return; + } + + resolved = true; + + if (exeContext.subsequentPayloads.length === 0) { + // a different call to next has exhausted all payloads + resolve({ value: undefined, done: true }); + return; + } + + const index = exeContext.subsequentPayloads.indexOf(promise); + + if (index === -1) { + // a different call to next has consumed this payload + resolve(this._race(exeContext)); + return; + } + + exeContext.subsequentPayloads.splice(index, 1); + + const { value, done } = payload; + + if (done && exeContext.subsequentPayloads.length === 0) { + // async iterable resolver just finished and no more pending payloads + resolve({ + value: { + hasNext: false, + }, + done: false, + }); + return; + } else if (done) { + // async iterable resolver just finished but there are pending payloads + // return the next one + resolve(this._race(exeContext)); + return; + } + + const returnValue: ExecutionPatchResult = { + ...value, + hasNext: exeContext.subsequentPayloads.length > 0, + }; + resolve({ + value: returnValue, + done: false, + }); + }); + }); + }); + } + + _next( + exeContext: ExecutionContext, + ): Promise> { + if (!exeContext.hasReturnedInitialResult) { + exeContext.hasReturnedInitialResult = true; + return Promise.resolve({ + value: { + ...exeContext.initialResult, + hasNext: true, + }, + done: false, + }); + } else if (exeContext.subsequentPayloads.length === 0) { + return Promise.resolve({ value: undefined, done: true }); + } + return this._race(exeContext); + } + + async _return( + exeContext: ExecutionContext, + ): Promise> { + await Promise.all( + exeContext.iterators.map((iterator) => iterator.return?.()), + ); + // no updates will be missed, transitions only happen to `done` state + // eslint-disable-next-line require-atomic-updates + exeContext.isDone = true; + return { value: undefined, done: true }; + } + + async _throw( + exeContext: ExecutionContext, + error?: unknown, + ): Promise> { + await Promise.all( + exeContext.iterators.map((iterator) => iterator.return?.()), + ); + // no updates will be missed, transitions only happen to `done` state + // eslint-disable-next-line require-atomic-updates + exeContext.isDone = true; + return Promise.reject(error); + } + + get( + exeContext: ExecutionContext, + initialResult: ExecutionResult, + ): AsyncGenerator { + exeContext.initialResult = initialResult; + return { + [Symbol.asyncIterator]() { + return this; + }, + next: () => this._next(exeContext), + return: () => this._return(exeContext), + throw: (error?: unknown) => this._throw(exeContext, error), + }; + } + + createPatchResult( + data: ObjMap | unknown | null, + label?: string, + path?: Path, + errors?: ReadonlyArray, + ): DispatcherResult { + const value: DispatcherResult = { + data, + path: path ? pathToArray(path) : [], + }; + + if (label != null) { + value.label = label; + } + + if (errors && errors.length > 0) { + value.errors = errors; + } + + return value; + } } /** 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 a567a30f81..884350d530 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -6,7 +6,11 @@ export { defaultTypeResolver, } from './executor'; -export type { ExecutionArgs } from './executor'; +export type { + ExecutionArgs, + ExecutionResult, + AsyncExecutionResult, +} from './executor'; export { execute, executeSync } from './execute'; diff --git a/src/execution/subscribe.ts b/src/execution/subscribe.ts index 89f60fe159..c409ff613d 100644 --- a/src/execution/subscribe.ts +++ b/src/execution/subscribe.ts @@ -1,13 +1,16 @@ import type { DocumentNode, - ExecutionResult, GraphQLFieldResolver, GraphQLSchema, } from 'graphql'; import type { Maybe } from '../jsutils/Maybe'; -import type { ExecutionArgs } from './executor'; +import type { + ExecutionArgs, + ExecutionResult, + AsyncExecutionResult, +} from './executor'; import { Executor } from './executor'; /** @@ -33,7 +36,10 @@ import { Executor } from './executor'; */ export async function subscribe( args: ExecutionArgs, -): Promise | ExecutionResult> { +): Promise< + | AsyncGenerator + | ExecutionResult +> { const executor = new Executor(); return executor.executeSubscription(args); } diff --git a/src/graphql.ts b/src/graphql.ts index ae7cd5fa94..4077ee9a5e 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,5 +1,4 @@ import type { - ExecutionResult, GraphQLFieldResolver, GraphQLSchema, GraphQLTypeResolver, @@ -11,7 +10,12 @@ import { parse, validate, validateSchema } from 'graphql'; import type { PromiseOrValue } from './jsutils/PromiseOrValue'; import { isPromise } from './jsutils/isPromise'; import type { Maybe } from './jsutils/Maybe'; +import { isAsyncIterable } from './jsutils/isAsyncIterable'; +import type { + ExecutionResult, + AsyncExecutionResult, +} from './execution/executor'; import { execute } from './execution/execute'; /** @@ -64,7 +68,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))); } @@ -79,14 +85,16 @@ 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> { const { schema, source, diff --git a/src/index.ts b/src/index.ts index cc862db82b..afea24e75d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,6 @@ export { subscribe, createSourceEventStream, } from './execution/index'; + +/** Directives for defer/stream support */ +export { GraphQLDeferDirective, GraphQLStreamDirective } from './type/index'; diff --git a/src/type/directives.ts b/src/type/directives.ts new file mode 100644 index 0000000000..a555396258 --- /dev/null +++ b/src/type/directives.ts @@ -0,0 +1,55 @@ +import { + DirectiveLocation, + GraphQLBoolean, + GraphQLDirective, + GraphQLInt, + GraphQLString, +} from 'graphql'; + +/** + * Used to conditionally defer fragments. + */ +export const GraphQLDeferDirective = new GraphQLDirective({ + name: 'defer', + description: + 'Directs the executor to defer this fragment when the `if` argument is true or undefined.', + locations: [ + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + ], + args: { + if: { + type: 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', + }, + }, +}); diff --git a/src/type/index.ts b/src/type/index.ts new file mode 100644 index 0000000000..f88c390999 --- /dev/null +++ b/src/type/index.ts @@ -0,0 +1,2 @@ +/** Directives for defer/stream support */ +export { GraphQLDeferDirective, GraphQLStreamDirective } from './directives'; diff --git a/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts new file mode 100644 index 0000000000..0b6d0a3c0a --- /dev/null +++ b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts @@ -0,0 +1,1167 @@ +import { describe, it } from 'mocha'; + +import type { GraphQLSchema } from 'graphql'; + +import { buildSchema } from 'graphql'; + +import { OverlappingFieldsCanBeMergedRule } from '../rules/OverlappingFieldsCanBeMergedRule'; + +import { + expectValidationErrors, + expectValidationErrorsWithSchema, +} from './harness'; + +function expectErrors(queryStr: string) { + return expectValidationErrors(OverlappingFieldsCanBeMergedRule, queryStr); +} + +function expectValid(queryStr: string) { + expectErrors(queryStr).toDeepEqual([]); +} + +function expectErrorsWithSchema(schema: GraphQLSchema, queryStr: string) { + return expectValidationErrorsWithSchema( + schema, + OverlappingFieldsCanBeMergedRule, + queryStr, + ); +} + +function expectValidWithSchema(schema: GraphQLSchema, queryStr: string) { + expectErrorsWithSchema(schema, queryStr).toDeepEqual([]); +} + +describe('Validate: Overlapping fields can be merged', () => { + it('unique fields', () => { + expectValid(` + fragment uniqueFields on Dog { + name + nickname + } + `); + }); + + it('identical fields', () => { + expectValid(` + fragment mergeIdenticalFields on Dog { + name + name + } + `); + }); + + it('identical fields with identical args', () => { + expectValid(` + fragment mergeIdenticalFieldsWithIdenticalArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: SIT) + } + `); + }); + + it('identical fields with identical directives', () => { + expectValid(` + fragment mergeSameFieldsWithSameDirectives on Dog { + name @include(if: true) + name @include(if: true) + } + `); + }); + + it('different args with different aliases', () => { + expectValid(` + fragment differentArgsWithDifferentAliases on Dog { + knowsSit: doesKnowCommand(dogCommand: SIT) + knowsDown: doesKnowCommand(dogCommand: DOWN) + } + `); + }); + + it('different directives with different aliases', () => { + expectValid(` + fragment differentDirectivesWithDifferentAliases on Dog { + nameIfTrue: name @include(if: true) + nameIfFalse: name @include(if: false) + } + `); + }); + + it('different skip/include directives accepted', () => { + // Note: Differing skip/include directives don't create an ambiguous return + // value and are acceptable in conditions where differing runtime values + // may have the same desired effect of including or skipping a field. + expectValid(` + fragment differentDirectivesWithDifferentAliases on Dog { + name @include(if: true) + name @include(if: false) + } + `); + }); + + 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', () => { + expectErrors(` + fragment conflictingArgs on Dog { + name @stream + 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('Same aliases with different field targets', () => { + expectErrors(` + fragment sameAliasesWithDifferentFieldTargets on Dog { + fido: name + fido: nickname + } + `).toDeepEqual([ + { + message: + 'Fields "fido" conflict because "name" and "nickname" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('Same aliases allowed on non-overlapping fields', () => { + // This is valid since no object can be both a "Dog" and a "Cat", thus + // these fields can never overlap. + expectValid(` + fragment sameAliasesWithDifferentFieldTargets on Pet { + ... on Dog { + name + } + ... on Cat { + name: nickname + } + } + `); + }); + + it('Alias masking direct field access', () => { + expectErrors(` + fragment aliasMaskingDirectFieldAccess on Dog { + name: nickname + name + } + `).toDeepEqual([ + { + message: + 'Fields "name" conflict because "nickname" and "name" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('different args, second adds an argument', () => { + expectErrors(` + fragment conflictingArgs on Dog { + doesKnowCommand + doesKnowCommand(dogCommand: HEEL) + } + `).toDeepEqual([ + { + message: + 'Fields "doesKnowCommand" conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('different args, second missing an argument', () => { + expectErrors(` + fragment conflictingArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand + } + `).toDeepEqual([ + { + message: + 'Fields "doesKnowCommand" conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('conflicting arg values', () => { + expectErrors(` + fragment conflictingArgs on Dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: HEEL) + } + `).toDeepEqual([ + { + message: + 'Fields "doesKnowCommand" conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('conflicting arg names', () => { + expectErrors(` + fragment conflictingArgs on Dog { + isAtLocation(x: 0) + isAtLocation(y: 0) + } + `).toDeepEqual([ + { + message: + 'Fields "isAtLocation" conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('allows different args where no conflict is possible', () => { + // This is valid since no object can be both a "Dog" and a "Cat", thus + // these fields can never overlap. + expectValid(` + fragment conflictingArgs on Pet { + ... on Dog { + name(surname: true) + } + ... on Cat { + name + } + } + `); + }); + + it('encounters conflict in fragments', () => { + expectErrors(` + { + ...A + ...B + } + fragment A on Type { + x: a + } + fragment B on Type { + x: b + } + `).toDeepEqual([ + { + message: + 'Fields "x" conflict because "a" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 7, column: 9 }, + { line: 10, column: 9 }, + ], + }, + ]); + }); + + it('reports each conflict once', () => { + expectErrors(` + { + f1 { + ...A + ...B + } + f2 { + ...B + ...A + } + f3 { + ...A + ...B + x: c + } + } + fragment A on Type { + x: a + } + fragment B on Type { + x: b + } + `).toDeepEqual([ + { + message: + 'Fields "x" conflict because "a" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 18, column: 9 }, + { line: 21, column: 9 }, + ], + }, + { + message: + 'Fields "x" conflict because "c" and "a" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 14, column: 11 }, + { line: 18, column: 9 }, + ], + }, + { + message: + 'Fields "x" conflict because "c" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 14, column: 11 }, + { line: 21, column: 9 }, + ], + }, + ]); + }); + + it('deep conflict', () => { + expectErrors(` + { + field { + x: a + }, + field { + x: b + } + } + `).toDeepEqual([ + { + message: + 'Fields "field" conflict because subfields "x" conflict because "a" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 11 }, + { line: 6, column: 9 }, + { line: 7, column: 11 }, + ], + }, + ]); + }); + + it('deep conflict with multiple issues', () => { + expectErrors(` + { + field { + x: a + y: c + }, + field { + x: b + y: d + } + } + `).toDeepEqual([ + { + message: + 'Fields "field" conflict because subfields "x" conflict because "a" and "b" are different fields and subfields "y" conflict because "c" and "d" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 11 }, + { line: 5, column: 11 }, + { line: 7, column: 9 }, + { line: 8, column: 11 }, + { line: 9, column: 11 }, + ], + }, + ]); + }); + + it('very deep conflict', () => { + expectErrors(` + { + field { + deepField { + x: a + } + }, + field { + deepField { + x: b + } + } + } + `).toDeepEqual([ + { + message: + 'Fields "field" conflict because subfields "deepField" conflict because subfields "x" conflict because "a" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 11 }, + { line: 5, column: 13 }, + { line: 8, column: 9 }, + { line: 9, column: 11 }, + { line: 10, column: 13 }, + ], + }, + ]); + }); + + it('reports deep conflict to nearest common ancestor', () => { + expectErrors(` + { + field { + deepField { + x: a + } + deepField { + x: b + } + }, + field { + deepField { + y + } + } + } + `).toDeepEqual([ + { + message: + 'Fields "deepField" conflict because subfields "x" conflict because "a" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 4, column: 11 }, + { line: 5, column: 13 }, + { line: 7, column: 11 }, + { line: 8, column: 13 }, + ], + }, + ]); + }); + + it('reports deep conflict to nearest common ancestor in fragments', () => { + expectErrors(` + { + field { + ...F + } + field { + ...F + } + } + fragment F on T { + deepField { + deeperField { + x: a + } + deeperField { + x: b + } + }, + deepField { + deeperField { + y + } + } + } + `).toDeepEqual([ + { + message: + 'Fields "deeperField" conflict because subfields "x" conflict because "a" and "b" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 12, column: 11 }, + { line: 13, column: 13 }, + { line: 15, column: 11 }, + { line: 16, column: 13 }, + ], + }, + ]); + }); + + it('reports deep conflict in nested fragments', () => { + expectErrors(` + { + field { + ...F + } + field { + ...I + } + } + fragment F on T { + x: a + ...G + } + fragment G on T { + y: c + } + fragment I on T { + y: d + ...J + } + fragment J on T { + x: b + } + `).toDeepEqual([ + { + message: + 'Fields "field" conflict because subfields "x" conflict because "a" and "b" are different fields and subfields "y" conflict because "c" and "d" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 3, column: 9 }, + { line: 11, column: 9 }, + { line: 15, column: 9 }, + { line: 6, column: 9 }, + { line: 22, column: 9 }, + { line: 18, column: 9 }, + ], + }, + ]); + }); + + it('ignores unknown fragments', () => { + expectValid(` + { + field + ...Unknown + ...Known + } + + fragment Known on T { + field + ...OtherUnknown + } + `); + }); + + describe('return types must be unambiguous', () => { + const schema = buildSchema(` + interface SomeBox { + deepBox: SomeBox + unrelatedField: String + } + + type StringBox implements SomeBox { + scalar: String + deepBox: StringBox + unrelatedField: String + listStringBox: [StringBox] + stringBox: StringBox + intBox: IntBox + } + + type IntBox implements SomeBox { + scalar: Int + deepBox: IntBox + unrelatedField: String + listStringBox: [StringBox] + stringBox: StringBox + intBox: IntBox + } + + interface NonNullStringBox1 { + scalar: String! + } + + type NonNullStringBox1Impl implements SomeBox & NonNullStringBox1 { + scalar: String! + unrelatedField: String + deepBox: SomeBox + } + + interface NonNullStringBox2 { + scalar: String! + } + + type NonNullStringBox2Impl implements SomeBox & NonNullStringBox2 { + scalar: String! + unrelatedField: String + deepBox: SomeBox + } + + type Connection { + edges: [Edge] + } + + type Edge { + node: Node + } + + type Node { + id: ID + name: String + } + + type Query { + someBox: SomeBox + connection: Connection + } + `); + + it('conflicting return types which potentially overlap', () => { + // This is invalid since an object could potentially be both the Object + // type IntBox and the interface type NonNullStringBox1. While that + // condition does not exist in the current schema, the schema could + // expand in the future to allow this. Thus it is invalid. + expectErrorsWithSchema( + schema, + ` + { + someBox { + ...on IntBox { + scalar + } + ...on NonNullStringBox1 { + scalar + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "scalar" conflict because they return conflicting types "Int" and "String!". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 8, column: 17 }, + ], + }, + ]); + }); + + it('compatible return shapes on different return types', () => { + // In this case `deepBox` returns `SomeBox` in the first usage, and + // `StringBox` in the second usage. These return types are not the same! + // however this is valid because the return *shapes* are compatible. + expectValidWithSchema( + schema, + ` + { + someBox { + ... on SomeBox { + deepBox { + unrelatedField + } + } + ... on StringBox { + deepBox { + unrelatedField + } + } + } + } + `, + ); + }); + + it('disallows differing return types despite no overlap', () => { + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + scalar + } + ... on StringBox { + scalar + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "scalar" conflict because they return conflicting types "Int" and "String". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 8, column: 17 }, + ], + }, + ]); + }); + + it('reports correctly when a non-exclusive follows an exclusive', () => { + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + deepBox { + ...X + } + } + } + someBox { + ... on StringBox { + deepBox { + ...Y + } + } + } + memoed: someBox { + ... on IntBox { + deepBox { + ...X + } + } + } + memoed: someBox { + ... on StringBox { + deepBox { + ...Y + } + } + } + other: someBox { + ...X + } + other: someBox { + ...Y + } + } + fragment X on SomeBox { + scalar + } + fragment Y on SomeBox { + scalar: unrelatedField + } + `, + ).toDeepEqual([ + { + message: + 'Fields "other" conflict because subfields "scalar" conflict because "scalar" and "unrelatedField" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 31, column: 13 }, + { line: 39, column: 13 }, + { line: 34, column: 13 }, + { line: 42, column: 13 }, + ], + }, + ]); + }); + + it('disallows differing return type nullability despite no overlap', () => { + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on NonNullStringBox1 { + scalar + } + ... on StringBox { + scalar + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "scalar" conflict because they return conflicting types "String!" and "String". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 8, column: 17 }, + ], + }, + ]); + }); + + it('disallows differing return type list despite no overlap', () => { + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + box: listStringBox { + scalar + } + } + ... on StringBox { + box: stringBox { + scalar + } + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "box" conflict because they return conflicting types "[StringBox]" and "StringBox". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 10, column: 17 }, + ], + }, + ]); + + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + box: stringBox { + scalar + } + } + ... on StringBox { + box: listStringBox { + scalar + } + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "box" conflict because they return conflicting types "StringBox" and "[StringBox]". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 10, column: 17 }, + ], + }, + ]); + }); + + it('disallows differing subfields', () => { + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + box: stringBox { + val: scalar + val: unrelatedField + } + } + ... on StringBox { + box: stringBox { + val: scalar + } + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "val" conflict because "scalar" and "unrelatedField" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 6, column: 19 }, + { line: 7, column: 19 }, + ], + }, + ]); + }); + + it('disallows differing deep return types despite no overlap', () => { + expectErrorsWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + box: stringBox { + scalar + } + } + ... on StringBox { + box: intBox { + scalar + } + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "box" conflict because subfields "scalar" conflict because they return conflicting types "String" and "Int". Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 17 }, + { line: 6, column: 19 }, + { line: 10, column: 17 }, + { line: 11, column: 19 }, + ], + }, + ]); + }); + + it('allows non-conflicting overlapping types', () => { + expectValidWithSchema( + schema, + ` + { + someBox { + ... on IntBox { + scalar: unrelatedField + } + ... on StringBox { + scalar + } + } + } + `, + ); + }); + + it('same wrapped scalar return types', () => { + expectValidWithSchema( + schema, + ` + { + someBox { + ...on NonNullStringBox1 { + scalar + } + ...on NonNullStringBox2 { + scalar + } + } + } + `, + ); + }); + + it('allows inline fragments without type condition', () => { + expectValidWithSchema( + schema, + ` + { + a + ... { + a + } + } + `, + ); + }); + + it('compares deep types including list', () => { + expectErrorsWithSchema( + schema, + ` + { + connection { + ...edgeID + edges { + node { + id: name + } + } + } + } + + fragment edgeID on Connection { + edges { + node { + id + } + } + } + `, + ).toDeepEqual([ + { + message: + 'Fields "edges" conflict because subfields "node" conflict because subfields "id" conflict because "name" and "id" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 5, column: 15 }, + { line: 6, column: 17 }, + { line: 7, column: 19 }, + { line: 14, column: 13 }, + { line: 15, column: 15 }, + { line: 16, column: 17 }, + ], + }, + ]); + }); + + it('ignores unknown types', () => { + expectValidWithSchema( + schema, + ` + { + someBox { + ...on UnknownType { + scalar + } + ...on NonNullStringBox2 { + scalar + } + } + } + `, + ); + }); + + it('works for field names that are JS keywords', () => { + const schemaWithKeywords = buildSchema(` + type Foo { + constructor: String + } + + type Query { + foo: Foo + } + `); + + expectValidWithSchema( + schemaWithKeywords, + ` + { + foo { + constructor + } + } + `, + ); + }); + }); + + it('does not infinite loop on recursive fragment', () => { + expectValid(` + fragment fragA on Human { name, relatives { name, ...fragA } } + `); + }); + + it('does not infinite loop on immediately recursive fragment', () => { + expectValid(` + fragment fragA on Human { name, ...fragA } + `); + }); + + it('does not infinite loop on transitively recursive fragment', () => { + expectValid(` + fragment fragA on Human { name, ...fragB } + fragment fragB on Human { name, ...fragC } + fragment fragC on Human { name, ...fragA } + `); + }); + + it('finds invalid case even with immediately recursive fragment', () => { + expectErrors(` + fragment sameAliasesWithDifferentFieldTargets on Dog { + ...sameAliasesWithDifferentFieldTargets + fido: name + fido: nickname + } + `).toDeepEqual([ + { + message: + 'Fields "fido" conflict because "name" and "nickname" are different fields. Use different aliases on the fields to fetch both if this was intentional.', + locations: [ + { line: 4, column: 9 }, + { line: 5, column: 9 }, + ], + }, + ]); + }); +}); diff --git a/src/validation/__tests__/StreamDirectiveOnListFieldRule-test.ts b/src/validation/__tests__/StreamDirectiveOnListFieldRule-test.ts new file mode 100644 index 0000000000..c6c5075bc4 --- /dev/null +++ b/src/validation/__tests__/StreamDirectiveOnListFieldRule-test.ts @@ -0,0 +1,69 @@ +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("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 new file mode 100644 index 0000000000..f4e6e28416 --- /dev/null +++ b/src/validation/__tests__/harness.ts @@ -0,0 +1,125 @@ +import type { GraphQLSchema, ValidationRule } from 'graphql'; +import { buildSchema, parse, validate } from 'graphql'; + +import { expectJSON } from '../../__testUtils__/expectJSON'; + +export const testSchema: GraphQLSchema = buildSchema(` + interface Mammal { + mother: Mammal + father: Mammal + } + + interface Pet { + name(surname: Boolean): String + } + + interface Canine implements Mammal { + name(surname: Boolean): String + mother: Canine + father: Canine + } + + enum DogCommand { + SIT + HEEL + DOWN + } + + type Dog implements Pet & Mammal & Canine { + name(surname: Boolean): String + nickname: String + barkVolume: Int + barks: Boolean + doesKnowCommand(dogCommand: DogCommand): Boolean + isHouseTrained(atOtherHomes: Boolean = true): Boolean + isAtLocation(x: Int, y: Int): Boolean + mother: Dog + father: Dog + } + + type Cat implements Pet { + name(surname: Boolean): String + nickname: String + meows: Boolean + meowsVolume: Int + furColor: FurColor + } + + union CatOrDog = Cat | Dog + + type Human { + name(surname: Boolean): String + pets: [Pet] + relatives: [Human] + } + + enum FurColor { + BROWN + BLACK + TAN + SPOTTED + NO_FUR + UNKNOWN + } + + input ComplexInput { + requiredField: Boolean! + nonNullField: Boolean! = false + intField: Int + stringField: String + booleanField: Boolean + stringListField: [String] + } + + type ComplicatedArgs { + # TODO List + # TODO Coercion + # TODO NotNulls + intArgField(intArg: Int): String + nonNullIntArgField(nonNullIntArg: Int!): String + stringArgField(stringArg: String): String + booleanArgField(booleanArg: Boolean): String + enumArgField(enumArg: FurColor): String + floatArgField(floatArg: Float): String + idArgField(idArg: ID): String + stringListArgField(stringListArg: [String]): String + stringListNonNullArgField(stringListNonNullArg: [String!]): String + complexArgField(complexArg: ComplexInput): String + multipleReqs(req1: Int!, req2: Int!): String + nonNullFieldWithDefault(arg: Int! = 0): String + multipleOpts(opt1: Int = 0, opt2: Int = 0): String + multipleOptAndReq(req1: Int!, req2: Int!, opt1: Int = 0, opt2: Int = 0): String + } + + type QueryRoot { + human(id: ID): Human + dog: Dog + cat: Cat + pet: Pet + catOrDog: CatOrDog + complicatedArgs: ComplicatedArgs + } + + schema { + query: QueryRoot + } + + directive @onField on FIELD +`); + +export function expectValidationErrorsWithSchema( + schema: GraphQLSchema, + rule: ValidationRule, + queryStr: string, +): any { + const doc = parse(queryStr); + const errors = validate(schema, doc, [rule]); + return expectJSON(errors); +} + +export function expectValidationErrors( + rule: ValidationRule, + queryStr: string, +): any { + return expectValidationErrorsWithSchema(testSchema, rule, queryStr); +} diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000000..45ce915727 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,5 @@ +/** Spec Section (with defer/stream support): "Field Selection Merging" */ +export { OverlappingFieldsCanBeMergedRule } from './rules/OverlappingFieldsCanBeMergedRule'; + +/** Spec Section (with defer/stream support): "Stream Directives Are Used On List Fields" */ +export { StreamDirectiveOnListFieldRule } from './rules/StreamDirectiveOnListFieldRule'; diff --git a/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts new file mode 100644 index 0000000000..3e8e4c3669 --- /dev/null +++ b/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts @@ -0,0 +1,874 @@ +import type { + ArgumentNode, + ASTVisitor, + DirectiveNode, + FieldNode, + FragmentDefinitionNode, + GraphQLField, + GraphQLNamedType, + GraphQLOutputType, + SelectionSetNode, + ValidationContext, + ValueNode, +} from 'graphql'; +import { + GraphQLError, + Kind, + getNamedType, + isInterfaceType, + isLeafType, + isListType, + isNonNullType, + isObjectType, + print, + typeFromAST, +} from 'graphql'; + +import type { ObjMap } from '../../jsutils/ObjMap'; +import { inspect } from '../../jsutils/inspect'; + +import type { Maybe } from '../../jsutils/Maybe'; + +function reasonMessage(reason: ConflictReasonMessage): string { + if (Array.isArray(reason)) { + return reason + .map( + ([responseName, subReason]) => + `subfields "${responseName}" conflict because ` + + reasonMessage(subReason), + ) + .join(' and '); + } + return reason; +} + +/** + * Overlapping fields can be merged + * + * A selection set is only valid if all fields (including spreading any + * fragments) either correspond to distinct response names or can be merged + * without ambiguity. + */ +export function OverlappingFieldsCanBeMergedRule( + context: ValidationContext, +): ASTVisitor { + // A memoization for when two fragments are compared "between" each other for + // conflicts. Two fragments may be compared many times, so memoizing this can + // dramatically improve the performance of this validator. + const comparedFragmentPairs = new PairSet(); + + // A cache for the "field map" and list of fragment names found in any given + // selection set. Selection sets may be asked for this information multiple + // times, so this improves the performance of this validator. + const cachedFieldsAndFragmentNames = new Map(); + + return { + SelectionSet(selectionSet) { + const conflicts = findConflictsWithinSelectionSet( + context, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + context.getParentType(), + selectionSet, + ); + for (const [[responseName, reason], fields1, fields2] of conflicts) { + const reasonMsg = reasonMessage(reason); + context.reportError( + new GraphQLError( + `Fields "${responseName}" conflict because ${reasonMsg}. Use different aliases on the fields to fetch both if this was intentional.`, + fields1.concat(fields2), + ), + ); + } + }, + }; +} + +type Conflict = [ConflictReason, Array, Array]; +// Field name and reason. +type ConflictReason = [string, ConflictReasonMessage]; +// Reason is a string, or a nested list of conflicts. +type ConflictReasonMessage = string | Array; +// Tuple defining a field node in a context. +type NodeAndDef = [ + Maybe, + FieldNode, + Maybe>, +]; +// Map of array of those. +type NodeAndDefCollection = ObjMap>; +type FragmentNames = Array; +type FieldsAndFragmentNames = readonly [NodeAndDefCollection, FragmentNames]; + +/** + * Algorithm: + * + * Conflicts occur when two fields exist in a query which will produce the same + * response name, but represent differing values, thus creating a conflict. + * The algorithm below finds all conflicts via making a series of comparisons + * between fields. In order to compare as few fields as possible, this makes + * a series of comparisons "within" sets of fields and "between" sets of fields. + * + * Given any selection set, a collection produces both a set of fields by + * also including all inline fragments, as well as a list of fragments + * referenced by fragment spreads. + * + * A) Each selection set represented in the document first compares "within" its + * collected set of fields, finding any conflicts between every pair of + * overlapping fields. + * Note: This is the *only time* that a the fields "within" a set are compared + * to each other. After this only fields "between" sets are compared. + * + * B) Also, if any fragment is referenced in a selection set, then a + * comparison is made "between" the original set of fields and the + * referenced fragment. + * + * C) Also, if multiple fragments are referenced, then comparisons + * are made "between" each referenced fragment. + * + * D) When comparing "between" a set of fields and a referenced fragment, first + * a comparison is made between each field in the original set of fields and + * each field in the the referenced set of fields. + * + * E) Also, if any fragment is referenced in the referenced selection set, + * then a comparison is made "between" the original set of fields and the + * referenced fragment (recursively referring to step D). + * + * F) When comparing "between" two fragments, first a comparison is made between + * each field in the first referenced set of fields and each field in the the + * second referenced set of fields. + * + * G) Also, any fragments referenced by the first must be compared to the + * second, and any fragments referenced by the second must be compared to the + * first (recursively referring to step F). + * + * H) When comparing two fields, if both have selection sets, then a comparison + * is made "between" both selection sets, first comparing the set of fields in + * the first selection set with the set of fields in the second. + * + * I) Also, if any fragment is referenced in either selection set, then a + * comparison is made "between" the other set of fields and the + * referenced fragment. + * + * J) Also, if two fragments are referenced in both selection sets, then a + * comparison is made "between" the two fragments. + * + */ + +// Find all conflicts found "within" a selection set, including those found +// via spreading in fragments. Called when visiting each SelectionSet in the +// GraphQL Document. +function findConflictsWithinSelectionSet( + context: ValidationContext, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + parentType: Maybe, + selectionSet: SelectionSetNode, +): Array { + const conflicts: Array = []; + + const [fieldMap, fragmentNames] = getFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + parentType, + selectionSet, + ); + + // (A) Find find all conflicts "within" the fields of this selection set. + // Note: this is the *only place* `collectConflictsWithin` is called. + collectConflictsWithin( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + fieldMap, + ); + + if (fragmentNames.length !== 0) { + // (B) Then collect conflicts between these fields and those represented by + // each spread fragment name found. + for (let i = 0; i < fragmentNames.length; i++) { + collectConflictsBetweenFieldsAndFragment( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + false, + fieldMap, + fragmentNames[i], + ); + // (C) Then compare this fragment with all other fragments found in this + // selection set to collect conflicts between fragments spread together. + // This compares each item in the list of fragment names to every other + // item in that same list (except for itself). + for (let j = i + 1; j < fragmentNames.length; j++) { + collectConflictsBetweenFragments( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + false, + fragmentNames[i], + fragmentNames[j], + ); + } + } + } + return conflicts; +} + +// Collect all conflicts found between a set of fields and a fragment reference +// including via spreading in any nested fragments. +function collectConflictsBetweenFieldsAndFragment( + context: ValidationContext, + conflicts: Array, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + areMutuallyExclusive: boolean, + fieldMap: NodeAndDefCollection, + fragmentName: string, +): void { + const fragment = context.getFragment(fragmentName); + if (!fragment) { + return; + } + + const [fieldMap2, referencedFragmentNames] = + getReferencedFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + fragment, + ); + + // Do not compare a fragment's fieldMap to itself. + if (fieldMap === fieldMap2) { + return; + } + + // (D) First collect any conflicts between the provided collection of fields + // and the collection of fields represented by the given fragment. + collectConflictsBetween( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fieldMap, + fieldMap2, + ); + + // (E) Then collect any conflicts between the provided collection of fields + // and any fragment names found in the given fragment. + for (const referencedFragmentName of referencedFragmentNames) { + collectConflictsBetweenFieldsAndFragment( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fieldMap, + referencedFragmentName, + ); + } +} + +// Collect all conflicts found between two fragments, including via spreading in +// any nested fragments. +function collectConflictsBetweenFragments( + context: ValidationContext, + conflicts: Array, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + areMutuallyExclusive: boolean, + fragmentName1: string, + fragmentName2: string, +): void { + // No need to compare a fragment to itself. + if (fragmentName1 === fragmentName2) { + return; + } + + // Memoize so two fragments are not compared for conflicts more than once. + if ( + comparedFragmentPairs.has( + fragmentName1, + fragmentName2, + areMutuallyExclusive, + ) + ) { + return; + } + comparedFragmentPairs.add(fragmentName1, fragmentName2, areMutuallyExclusive); + + const fragment1 = context.getFragment(fragmentName1); + const fragment2 = context.getFragment(fragmentName2); + if (!fragment1 || !fragment2) { + return; + } + + const [fieldMap1, referencedFragmentNames1] = + getReferencedFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + fragment1, + ); + const [fieldMap2, referencedFragmentNames2] = + getReferencedFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + fragment2, + ); + + // (F) First, collect all conflicts between these two collections of fields + // (not including any nested fragments). + collectConflictsBetween( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fieldMap1, + fieldMap2, + ); + + // (G) Then collect conflicts between the first fragment and any nested + // fragments spread in the second fragment. + for (const referencedFragmentName2 of referencedFragmentNames2) { + collectConflictsBetweenFragments( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fragmentName1, + referencedFragmentName2, + ); + } + + // (G) Then collect conflicts between the second fragment and any nested + // fragments spread in the first fragment. + for (const referencedFragmentName1 of referencedFragmentNames1) { + collectConflictsBetweenFragments( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + referencedFragmentName1, + fragmentName2, + ); + } +} + +// Find all conflicts found between two selection sets, including those found +// via spreading in fragments. Called when determining if conflicts exist +// between the sub-fields of two overlapping fields. +function findConflictsBetweenSubSelectionSets( + context: ValidationContext, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + areMutuallyExclusive: boolean, + parentType1: Maybe, + selectionSet1: SelectionSetNode, + parentType2: Maybe, + selectionSet2: SelectionSetNode, +): Array { + const conflicts: Array = []; + + const [fieldMap1, fragmentNames1] = getFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + parentType1, + selectionSet1, + ); + const [fieldMap2, fragmentNames2] = getFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + parentType2, + selectionSet2, + ); + + // (H) First, collect all conflicts between these two collections of field. + collectConflictsBetween( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fieldMap1, + fieldMap2, + ); + + // (I) Then collect conflicts between the first collection of fields and + // those referenced by each fragment name associated with the second. + for (const fragmentName2 of fragmentNames2) { + collectConflictsBetweenFieldsAndFragment( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fieldMap1, + fragmentName2, + ); + } + + // (I) Then collect conflicts between the second collection of fields and + // those referenced by each fragment name associated with the first. + for (const fragmentName1 of fragmentNames1) { + collectConflictsBetweenFieldsAndFragment( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fieldMap2, + fragmentName1, + ); + } + + // (J) Also collect conflicts between any fragment names by the first and + // fragment names by the second. This compares each item in the first set of + // names to each item in the second set of names. + for (const fragmentName1 of fragmentNames1) { + for (const fragmentName2 of fragmentNames2) { + collectConflictsBetweenFragments( + context, + conflicts, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + fragmentName1, + fragmentName2, + ); + } + } + return conflicts; +} + +// Collect all Conflicts "within" one collection of fields. +function collectConflictsWithin( + context: ValidationContext, + conflicts: Array, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + fieldMap: NodeAndDefCollection, +): void { + // A field map is a keyed collection, where each key represents a response + // name and the value at that key is a list of all fields which provide that + // response name. For every response name, if there are multiple fields, they + // must be compared to find a potential conflict. + for (const [responseName, fields] of Object.entries(fieldMap)) { + // This compares every field in the list to every other field in this list + // (except to itself). If the list only has one item, nothing needs to + // be compared. + if (fields.length > 1) { + for (let i = 0; i < fields.length; i++) { + for (let j = i + 1; j < fields.length; j++) { + const conflict = findConflict( + context, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + false, // within one collection is never mutually exclusive + responseName, + fields[i], + fields[j], + ); + if (conflict) { + conflicts.push(conflict); + } + } + } + } + } +} + +// Collect all Conflicts between two collections of fields. This is similar to, +// but different from the `collectConflictsWithin` function above. This check +// assumes that `collectConflictsWithin` has already been called on each +// provided collection of fields. This is true because this validator traverses +// each individual selection set. +function collectConflictsBetween( + context: ValidationContext, + conflicts: Array, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + parentFieldsAreMutuallyExclusive: boolean, + fieldMap1: NodeAndDefCollection, + fieldMap2: NodeAndDefCollection, +): void { + // A field map is a keyed collection, where each key represents a response + // name and the value at that key is a list of all fields which provide that + // response name. For any response name which appears in both provided field + // maps, each field from the first field map must be compared to every field + // in the second field map to find potential conflicts. + for (const [responseName, fields1] of Object.entries(fieldMap1)) { + const fields2 = fieldMap2[responseName]; + if (fields2) { + for (const field1 of fields1) { + for (const field2 of fields2) { + const conflict = findConflict( + context, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + parentFieldsAreMutuallyExclusive, + responseName, + field1, + field2, + ); + if (conflict) { + conflicts.push(conflict); + } + } + } + } + } +} + +// Determines if there is a conflict between two particular fields, including +// comparing their sub-fields. +function findConflict( + context: ValidationContext, + cachedFieldsAndFragmentNames: Map, + comparedFragmentPairs: PairSet, + parentFieldsAreMutuallyExclusive: boolean, + responseName: string, + field1: NodeAndDef, + field2: NodeAndDef, +): Maybe { + const [parentType1, node1, def1] = field1; + const [parentType2, node2, def2] = field2; + + // If it is known that two fields could not possibly apply at the same + // time, due to the parent types, then it is safe to permit them to diverge + // in aliased field or arguments used as they will not present any ambiguity + // by differing. + // It is known that two parent types could never overlap if they are + // different Object types. Interface or Union types might overlap - if not + // in the current state of the schema, then perhaps in some future version, + // thus may not safely diverge. + const areMutuallyExclusive = + parentFieldsAreMutuallyExclusive || + (parentType1 !== parentType2 && + isObjectType(parentType1) && + isObjectType(parentType2)); + + if (!areMutuallyExclusive) { + // Two aliases must refer to the same field. + const name1 = node1.name.value; + const name2 = node2.name.value; + if (name1 !== name2) { + return [ + [responseName, `"${name1}" and "${name2}" are different fields`], + [node1], + [node2], + ]; + } + + // istanbul ignore next (See: 'https://github.com/graphql/graphql-js/issues/2203') + const args1 = node1.arguments ?? []; + // istanbul ignore next (See: 'https://github.com/graphql/graphql-js/issues/2203') + const args2 = node2.arguments ?? []; + // Two field calls must have the same arguments. + if (!sameArguments(args1, args2)) { + return [ + [responseName, 'they have differing arguments'], + [node1], + [node2], + ]; + } + + // istanbul ignore next (See: 'https://github.com/graphql/graphql-js/issues/2203') + const directives1 = node1.directives ?? []; + // istanbul ignore next (See: 'https://github.com/graphql/graphql-js/issues/2203') + const directives2 = 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; + + if (type1 && type2 && doTypesConflict(type1, type2)) { + return [ + [ + responseName, + `they return conflicting types "${inspect(type1)}" and "${inspect( + type2, + )}"`, + ], + [node1], + [node2], + ]; + } + + // Collect and compare sub-fields. Use the same "visited fragment names" list + // for both collections so fields in a fragment reference are never + // compared to themselves. + const selectionSet1 = node1.selectionSet; + const selectionSet2 = node2.selectionSet; + if (selectionSet1 && selectionSet2) { + const conflicts = findConflictsBetweenSubSelectionSets( + context, + cachedFieldsAndFragmentNames, + comparedFragmentPairs, + areMutuallyExclusive, + getNamedType(type1), + selectionSet1, + getNamedType(type2), + selectionSet2, + ); + return subfieldConflicts(conflicts, responseName, node1, node2); + } +} + +function sameArguments( + arguments1: ReadonlyArray, + arguments2: ReadonlyArray, +): boolean { + if (arguments1.length !== arguments2.length) { + return false; + } + return arguments1.every((argument1) => { + const argument2 = arguments2.find( + (argument) => argument.name.value === argument1.name.value, + ); + if (!argument2) { + return false; + } + return sameValue(argument1.value, argument2.value); + }); +} + +function sameDirectiveArgument( + directive1: DirectiveNode, + directive2: DirectiveNode, + argumentName: string, +): boolean { + /* istanbul ignore next (See https://github.com/graphql/graphql-js/issues/2203) */ + const args1 = directive1.arguments ?? []; + const arg1 = args1.find((argument) => argument.name.value === argumentName); + if (!arg1) { + return false; + } + + /* istanbul ignore next (See https://github.com/graphql/graphql-js/issues/2203) */ + const args2 = directive2.arguments ?? []; + const arg2 = args2.find((argument) => argument.name.value === argumentName); + if (!arg2) { + return false; + } + return sameValue(arg1.value, arg2.value); +} + +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 ( + sameDirectiveArgument(stream1, stream2, 'initialCount') && + sameDirectiveArgument(stream1, stream2, 'label') + ); + } + // fields have a mix of stream and no stream + return false; +} + +function sameValue(value1: ValueNode, value2: ValueNode): boolean { + return print(value1) === print(value2); +} + +// 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. +function doTypesConflict( + type1: GraphQLOutputType, + type2: GraphQLOutputType, +): boolean { + if (isListType(type1)) { + return isListType(type2) + ? doTypesConflict(type1.ofType, type2.ofType) + : true; + } + if (isListType(type2)) { + return true; + } + if (isNonNullType(type1)) { + return isNonNullType(type2) + ? doTypesConflict(type1.ofType, type2.ofType) + : true; + } + if (isNonNullType(type2)) { + return true; + } + if (isLeafType(type1) || isLeafType(type2)) { + return type1 !== type2; + } + return false; +} + +// Given a selection set, return the collection of fields (a mapping of response +// name to field nodes and definitions) as well as a list of fragment names +// referenced via fragment spreads. +function getFieldsAndFragmentNames( + context: ValidationContext, + cachedFieldsAndFragmentNames: Map, + parentType: Maybe, + selectionSet: SelectionSetNode, +): FieldsAndFragmentNames { + const cached = cachedFieldsAndFragmentNames.get(selectionSet); + if (cached) { + return cached; + } + const nodeAndDefs: NodeAndDefCollection = Object.create(null); + const fragmentNames: ObjMap = Object.create(null); + _collectFieldsAndFragmentNames( + context, + parentType, + selectionSet, + nodeAndDefs, + fragmentNames, + ); + const result = [nodeAndDefs, Object.keys(fragmentNames)] as const; + cachedFieldsAndFragmentNames.set(selectionSet, result); + return result; +} + +// Given a reference to a fragment, return the represented collection of fields +// as well as a list of nested fragment names referenced via fragment spreads. +function getReferencedFieldsAndFragmentNames( + context: ValidationContext, + cachedFieldsAndFragmentNames: Map, + fragment: FragmentDefinitionNode, +) { + // Short-circuit building a type from the node if possible. + const cached = cachedFieldsAndFragmentNames.get(fragment.selectionSet); + if (cached) { + return cached; + } + + const fragmentType = typeFromAST(context.getSchema(), fragment.typeCondition); + return getFieldsAndFragmentNames( + context, + cachedFieldsAndFragmentNames, + fragmentType, + fragment.selectionSet, + ); +} + +function _collectFieldsAndFragmentNames( + context: ValidationContext, + parentType: Maybe, + selectionSet: SelectionSetNode, + nodeAndDefs: NodeAndDefCollection, + fragmentNames: ObjMap, +): void { + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: { + const fieldName = selection.name.value; + let fieldDef; + if (isObjectType(parentType) || isInterfaceType(parentType)) { + fieldDef = parentType.getFields()[fieldName]; + } + const responseName = selection.alias + ? selection.alias.value + : fieldName; + if (!nodeAndDefs[responseName]) { + nodeAndDefs[responseName] = []; + } + nodeAndDefs[responseName].push([parentType, selection, fieldDef]); + break; + } + case Kind.FRAGMENT_SPREAD: + fragmentNames[selection.name.value] = true; + break; + case Kind.INLINE_FRAGMENT: { + const typeCondition = selection.typeCondition; + const inlineFragmentType = typeCondition + ? typeFromAST(context.getSchema(), typeCondition) + : parentType; + _collectFieldsAndFragmentNames( + context, + inlineFragmentType, + selection.selectionSet, + nodeAndDefs, + fragmentNames, + ); + break; + } + } + } +} + +// Given a series of Conflicts which occurred between two sub-fields, generate +// a single Conflict. +function subfieldConflicts( + conflicts: ReadonlyArray, + responseName: string, + node1: FieldNode, + node2: FieldNode, +): Maybe { + if (conflicts.length > 0) { + return [ + [responseName, conflicts.map(([reason]) => reason)], + [node1, ...conflicts.map(([, fields1]) => fields1).flat()], + [node2, ...conflicts.map(([, , fields2]) => fields2).flat()], + ]; + } +} + +/** + * A way to keep track of pairs of things when the ordering of the pair does not matter. + */ +class PairSet { + _data: Map>; + + constructor() { + this._data = new Map(); + } + + has(a: string, b: string, areMutuallyExclusive: boolean): boolean { + const [key1, key2] = a < b ? [a, b] : [b, a]; + + const result = this._data.get(key1)?.get(key2); + if (result === undefined) { + return false; + } + + // areMutuallyExclusive being false is a superset of being true, hence if + // we want to know if this PairSet "has" these two with no exclusivity, + // we have to ensure it was added as such. + return areMutuallyExclusive ? true : areMutuallyExclusive === result; + } + + add(a: string, b: string, areMutuallyExclusive: boolean): void { + const [key1, key2] = a < b ? [a, b] : [b, a]; + + const map = this._data.get(key1); + if (map === undefined) { + this._data.set(key1, new Map([[key2, areMutuallyExclusive]])); + } else { + map.set(key2, areMutuallyExclusive); + } + } +} diff --git a/src/validation/rules/StreamDirectiveOnListFieldRule.ts b/src/validation/rules/StreamDirectiveOnListFieldRule.ts new file mode 100644 index 0000000000..b7620eb867 --- /dev/null +++ b/src/validation/rules/StreamDirectiveOnListFieldRule.ts @@ -0,0 +1,33 @@ +import type { ASTVisitor, DirectiveNode, ValidationContext } from 'graphql'; +import { GraphQLError, isListType } from 'graphql'; + +import { GraphQLStreamDirective } from '../../type/directives'; + +/** + * 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) + ) { + context.reportError( + new GraphQLError( + `Stream directive cannot be used on non-list field "${fieldDef.name}" on type "${parentType.name}".`, + node, + ), + ); + } + }, + }; +}