diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..9498e50bca Binary files /dev/null and b/.DS_Store differ diff --git a/src/execution/__tests__/directives-perf-test.js b/src/execution/__tests__/directives-perf-test.js new file mode 100644 index 0000000000..669b7fb2dc --- /dev/null +++ b/src/execution/__tests__/directives-perf-test.js @@ -0,0 +1,80 @@ +// @flow strict + +import { expect } from 'chai'; +import { it } from 'mocha'; + +import { parse } from '../../language/parser'; + +import { GraphQLSchema } from '../../type/schema'; +import { GraphQLString } from '../../type/scalars'; +import { GraphQLObjectType } from '../../type/definition'; + +import { execute } from '../execute'; +import { forAwaitEach, isAsyncIterable } from 'iterall'; + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'TestType', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + }, + }), +}); + +let count = 0; + +const rootValue = { + a() { + return 'a'; + }, + b() { + count += 1; + return `b${count}`; + }, +}; + +function executeTestQuery(query) { + const document = parse(query); + return execute({ schema, document, rootValue }); +} + +it('performance test, if the same field is deferred several times, its resolve is called only once', async () => { + const index = 100; + let fragString = ''; + let fragElementString = ''; + for (const i of Array.from(Array(index).keys())) { + fragString += ` + ...Frag${i} @defer(label: "Frag_b_defer${i}") + `; + fragElementString += ` + fragment Frag${i} on TestType { + b + } + `; + } + const result = await executeTestQuery(` + query { + a + ${fragString} + } + ${fragElementString} + `); + expect(isAsyncIterable(result)).to.equal(true); + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(index + 1); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + for (const i of Array.from(Array(index).keys())) { + expect(results[i + 1]).to.deep.equal({ + data: { b: 'b1' }, + label: `Frag_b_defer${i}`, + path: [], + }); + } +}); diff --git a/src/execution/__tests__/directives-test.js b/src/execution/__tests__/directives-test.js index 810cd51b68..96f6f74453 100644 --- a/src/execution/__tests__/directives-test.js +++ b/src/execution/__tests__/directives-test.js @@ -10,6 +10,39 @@ import { GraphQLString } from '../../type/scalars'; import { GraphQLObjectType } from '../../type/definition'; import { execute } from '../execute'; +import { forAwaitEach, isAsyncIterable } from 'iterall'; + +class Data { + d: string; + e: string; + + constructor(d) { + this.d = d; + this.e = 'e'; + } +} + +function delay(t, v) { + return new Promise(resolve => setTimeout(resolve.bind(null, v), t)); +} + +const DataType = new GraphQLObjectType({ + name: 'DataType', + fields: { + d: { + type: GraphQLString, + resolve(obj) { + return delay(5).then(() => obj.d); + }, + }, + e: { + type: GraphQLString, + resolve(obj) { + return obj.e; + }, + }, + }, +}); const schema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -17,6 +50,7 @@ const schema = new GraphQLSchema({ fields: { a: { type: GraphQLString }, b: { type: GraphQLString }, + c: { type: DataType }, }, }), }); @@ -28,6 +62,9 @@ const rootValue = { b() { return 'b'; }, + c() { + return Promise.resolve(new Data('d')); + }, }; function executeTestQuery(query) { @@ -144,8 +181,395 @@ describe('Execute: handles directives', () => { data: { a: 'a' }, }); }); - }); + describe('defer fragment spread', () => { + it('without if', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(label: "Frag_b_defer") + } + fragment Frag on TestType { + b + } + `); + expect(isAsyncIterable(result)).to.equal(true); + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(2); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { b: 'b' }, + label: 'Frag_b_defer', + path: [], + }); + }); + + it('if true', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: true, label: "Frag_b_defer") + } + fragment Frag on TestType { + b + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(2); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { b: 'b' }, + label: 'Frag_b_defer', + path: [], + }); + }); + + it('if false', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: false, label: "Frag_b_defer") + } + fragment Frag on TestType { + b + } + `); + expect(isAsyncIterable(result)).to.equal(false); + const data = await result; + expect(data).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + describe('defer fragment spread with DataType', () => { + it('without defer', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag + } + fragment FragData on DataType { + d + } + fragment Frag on TestType { + c { + ...FragData + } + } + `); + expect(isAsyncIterable(result)).to.equal(false); + const data = await result; + expect(data).to.deep.equal({ + data: { + a: 'a', + c: { + d: 'd', + }, + }, + }); + }); + + it('if false not defer', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: false, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: false, label: "Frag_d_defer") + } + } + fragment FragData on DataType { + d + } + `); + expect(isAsyncIterable(result)).to.equal(false); + const data = await result; + expect(data).to.deep.equal({ + data: { + a: 'a', + c: { + d: 'd', + }, + }, + }); + }); + it('two fragments with two field equals', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: true, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: true, label: "FragData_d_defer") + ...FragDeData @defer(if: true, label: "FragDeData_de_defer") + } + } + fragment FragData on DataType { + d + } + fragment FragDeData on DataType { + d + e + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(4); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { + c: {}, + }, + label: 'Frag_c_defer', + path: [], + }); + expect(results[2]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragData_d_defer', + path: ['c'], + }); + expect(results[3]).to.deep.equal({ + data: { d: 'd', e: 'e' }, + label: 'FragDeData_de_defer', + path: ['c'], + }); + }); + + it('race condition', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: true, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: true, label: "FragData_d_defer") + ...FragDeData @defer(if: true, label: "FragDeData_de_defer") + } + } + fragment FragData on DataType { + d + } + fragment FragDeData on DataType { + e + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(4); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { + c: {}, + }, + label: 'Frag_c_defer', + path: [], + }); + + expect(results[2]).to.deep.equal({ + data: { e: 'e' }, + label: 'FragDeData_de_defer', + path: ['c'], + }); + expect(results[3]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragData_d_defer', + path: ['c'], + }); + }); + + it('with two equals fragments', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: true, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: true, label: "FragData_d_defer") + ...FragDeData @defer(if: true, label: "FragDeData_de_defer") + } + } + fragment FragData on DataType { + d + } + fragment FragDeData on DataType { + d + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(4); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { + c: {}, + }, + label: 'Frag_c_defer', + path: [], + }); + expect(results[2]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragData_d_defer', + path: ['c'], + }); + expect(results[3]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragDeData_de_defer', + path: ['c'], + }); + }); + + it('mixed if true & if false', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: true, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: false, label: "FragData_d_defer") + } + } + fragment FragData on DataType { + d + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(2); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { + c: { d: 'd' }, + }, + label: 'Frag_c_defer', + path: [], + }); + }); + + it('mixed if false & if true', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: false, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: true, label: "FragData_d_defer") + } + } + fragment FragData on DataType { + d + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(2); + expect(results[0]).to.deep.equal({ + data: { + a: 'a', + c: {}, + }, + }); + expect(results[1]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragData_d_defer', + path: ['c'], + }); + }); + + it('if true', async () => { + const result = await executeTestQuery(` + query { + a + ...Frag @defer(if: true, label: "Frag_c_defer") + } + fragment Frag on TestType { + c { + ...FragData @defer(if: true, label: "FragData_d_defer") + } + } + fragment FragData on DataType { + d + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(3); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { + c: {}, + }, + label: 'Frag_c_defer', + path: [], + }); + expect(results[2]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragData_d_defer', + path: ['c'], + }); + }); + }); + }); + }); describe('works on inline fragment', () => { it('if false omits inline fragment', () => { const result = executeTestQuery(` @@ -204,6 +628,99 @@ describe('Execute: handles directives', () => { data: { a: 'a' }, }); }); + + describe('defer on inline fragment', () => { + it('without if', async () => { + const result = await executeTestQuery(` + query { + a + ... on TestType @defer(label: "Frag_b_defer") { + b + } + } + `); + expect(isAsyncIterable(result)).to.equal(true); + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(2); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { b: 'b' }, + label: 'Frag_b_defer', + path: [], + }); + }); + + it('if true', async () => { + const result = await executeTestQuery(` + query { + a + ... on TestType @defer(label: "Frag_b_defer") { + b + } + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(2); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { b: 'b' }, + label: 'Frag_b_defer', + path: [], + }); + }); + + it('if true DataType', async () => { + const result = await executeTestQuery(` + query { + a + ... on TestType @defer(if: true, label: "Frag_c_defer") { + c { + ... on DataType @defer(if: true, label: "FragData_d_defer") { + d + } + } + } + } + `); + expect(isAsyncIterable(result)).to.equal(true); + + const results = []; + await forAwaitEach(((result: any): AsyncIterable), value => { + results.push(value); + }); + + expect(results.length).to.equal(3); + expect(results[0]).to.deep.equal({ + data: { a: 'a' }, + }); + expect(results[1]).to.deep.equal({ + data: { + c: {}, + }, + label: 'Frag_c_defer', + path: [], + }); + expect(results[2]).to.deep.equal({ + data: { d: 'd' }, + label: 'FragData_d_defer', + path: ['c'], + }); + }); + }); }); describe('works on anonymous inline fragment', () => { diff --git a/src/execution/execute.js b/src/execution/execute.js index fe80d6c980..7843af295d 100644 --- a/src/execution/execute.js +++ b/src/execution/execute.js @@ -1,6 +1,6 @@ // @flow strict -import { forEach, isCollection } from 'iterall'; +import { $$asyncIterator, forEach, isCollection } from 'iterall'; import inspect from '../jsutils/inspect'; import memoize3 from '../jsutils/memoize3'; @@ -22,6 +22,7 @@ import { locatedError } from '../error/locatedError'; import { Kind } from '../language/kinds'; import { type DocumentNode, + type DirectiveNode, type OperationDefinitionNode, type SelectionSetNode, type FieldNode, @@ -40,6 +41,7 @@ import { import { GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, } from '../type/directives'; import { type GraphQLObjectType, @@ -61,10 +63,14 @@ import { import { typeFromAST } from '../utilities/typeFromAST'; import { getOperationRootType } from '../utilities/getOperationRootType'; +import objectValues from '../polyfills/objectValues'; +import objectEntries from '../polyfills/objectEntries'; + import { getVariableValues, getArgumentValues, getDirectiveValues, + getDirective, } from './values'; /** @@ -103,6 +109,7 @@ export type ExecutionContext = {| fieldResolver: GraphQLFieldResolver, typeResolver: GraphQLTypeResolver, errors: Array, + resultResolver: ResultResolver, |}; /** @@ -114,6 +121,13 @@ export type ExecutionContext = {| export type ExecutionResult = {| errors?: $ReadOnlyArray, data?: ObjMap | null, + label?: string, + path?: Array, +|}; + +export type AsyncExecutionResult = {| + value: ExecutionResult, + done: boolean, |}; export type ExecutionArgs = {| @@ -127,6 +141,175 @@ export type ExecutionArgs = {| typeResolver?: ?GraphQLTypeResolver, |}; +/** + * ResultResolver class that allows us to create the result of the execution: + * AsyncIterable > when there are deferred results + * PromiseOrValue for standard executions + */ +class ResultResolver { + initialResult: Promise | void; + executionResults: ObjMap>; + deferResults: ObjMap<{| + path: Array, + label: string, + resolvers: Array<{| + responseName: string, + resolver: () => PromiseOrValue, + |}>, + |}>; + + constructor() { + this.executionResults = Object.create(null); + this.deferResults = Object.create(null); + } + + addDeferredResult( + path: Path | void, + label: string, + responseName: string, + resolver: () => PromiseOrValue, + ): void { + const pathArray = pathToArray(path); + const keyDeferResult = pathArray.toString() + label; + const deferResult = this.deferResults[keyDeferResult] || { + resolvers: [], + label, + path: pathArray, + }; + deferResult.resolvers.push({ resolver, responseName }); + this.deferResults[keyDeferResult] = deferResult; + } + + resolveResults(exeContext: ExecutionContext) { + const deferResultsArray = objectEntries(this.deferResults); + this.deferResults = Object.create(null); + + // avoid multiple resolve for same responseName in same path + const resultsResolver = Object.create({}); + for (const [keyDeferResult, deferResult] of deferResultsArray) { + const { label, path, resolvers } = deferResult; + const resolve = () => { + const results = Object.create({}); + let containsPromise = false; + resolvers.forEach(({ resolver, responseName }) => { + const keyResultResolve = path.toString() + responseName; + const result = resultsResolver[keyResultResolve] + ? resultsResolver[keyResultResolve] + : resolver(); + + resultsResolver[keyResultResolve] = result; + if (result !== undefined) { + results[responseName] = result; + if (!containsPromise && isPromise(result)) { + containsPromise = true; + } + } + }); + + // If there are no promises, we can just return the object + if (!containsPromise) { + // deferred result must always be a Promise + return results; + } + // Otherwise, results is a map from field name to the result of resolving that + // field, which is possibly a promise. Return a promise that will return this + // same map, but with any promises replaced with the values they resolved to. + return promiseForObject(results); + }; + + this.executionResults[keyDeferResult] = this.buildAsyncResponse( + exeContext, + resolve(), + { + label, + path, + }, + ); + } + } + + /** + * Given a completed execution context, data and iterable object , build the { value, done } + * response defined by the "AsyncIterable" + */ + buildAsyncResponse( + exeContext: ExecutionContext, + data: PromiseOrValue | null>, + iterable?: {| + label?: string, + path?: Array, + |}, + ): Promise { + const result = isPromise(data) ? data : Promise.resolve(data); + return result.then(resolved => { + const value = + exeContext.errors.length === 0 + ? { data: resolved, ...iterable } + : { errors: exeContext.errors, data: resolved, ...iterable }; + // defer results are added only after the parent's response has been resolved + this.resolveResults(exeContext); + return { + value, + done: false, + }; + }); + } + + /** + * Given a completed execution context and data, build the { errors, data } + * response defined by the "Response" section of the GraphQL specification. + */ + buildResponse( + exeContext: ExecutionContext, + data: PromiseOrValue | null>, + ): PromiseOrValue> | ExecutionResult> { + if (isPromise(data)) { + return data.then(resolved => this.buildResponse(exeContext, resolved)); + } + if (!Object.keys(this.deferResults).length) { + return exeContext.errors.length === 0 + ? { data } + : { errors: exeContext.errors, data }; + } + this.initialResult = this.buildAsyncResponse(exeContext, data); + return this.getAsyncIterable(); + } + + getAsyncIterable(): AsyncIterable> { + const self = this; + return ({ + [$$asyncIterator]() { + return { + next() { + if (self.initialResult) { + return ( + self.initialResult && + self.initialResult.then(value => { + self.initialResult = undefined; + return value; + }) + ); + } + const promises = objectValues(self.executionResults); + if (promises.length === 0) { + return Promise.resolve({ value: undefined, done: true }); + } + return Promise.race(promises) + .then(r => r) + .then(response => { + const { path, label } = response.value; + if (path) { + delete self.executionResults[path.toString() + label]; + } + return response; + }); + }, + }; + }, + }: any); + } +} + /** * Implements the "Evaluating requests" section of the GraphQL specification. * @@ -142,7 +325,7 @@ export type ExecutionArgs = {| declare function execute( ExecutionArgs, ..._: [] -): PromiseOrValue; +): PromiseOrValue> | ExecutionResult>; /* eslint-disable no-redeclare */ declare function execute( schema: GraphQLSchema, @@ -153,7 +336,7 @@ declare function execute( operationName?: ?string, fieldResolver?: ?GraphQLFieldResolver, typeResolver?: ?GraphQLTypeResolver, -): PromiseOrValue; +): PromiseOrValue> | ExecutionResult>; export function execute( argsOrSchema, document, @@ -180,7 +363,9 @@ export function execute( }); } -function executeImpl(args: ExecutionArgs): PromiseOrValue { +function executeImpl( + args: ExecutionArgs, +): PromiseOrValue> | ExecutionResult> { const { schema, document, @@ -221,23 +406,7 @@ function executeImpl(args: ExecutionArgs): PromiseOrValue { // be executed. An execution which encounters errors will still result in a // resolved Promise. const data = executeOperation(exeContext, exeContext.operation, rootValue); - return buildResponse(exeContext, data); -} - -/** - * Given a completed execution context and data, build the { errors, data } - * response defined by the "Response" section of the GraphQL specification. - */ -function buildResponse( - exeContext: ExecutionContext, - data: PromiseOrValue | null>, -): PromiseOrValue { - if (isPromise(data)) { - return data.then(resolved => buildResponse(exeContext, resolved)); - } - return exeContext.errors.length === 0 - ? { data } - : { errors: exeContext.errors, data }; + return exeContext.resultResolver.buildResponse(exeContext, data); } /** @@ -333,6 +502,7 @@ export function buildExecutionContext( fieldResolver: fieldResolver || defaultFieldResolver, typeResolver: typeResolver || defaultTypeResolver, errors: [], + resultResolver: new ResultResolver(), }; } @@ -417,6 +587,20 @@ function executeFieldsSerially( ); } +function getDeferredInfo(exeContext, fieldNodes) { + let every = true; + const deferLabels: Array = []; + for (const node of fieldNodes) { + const lastDefer = getDeferDirectiveValues(exeContext, node); + every = every && lastDefer; + if (lastDefer && !deferLabels.includes(lastDefer.label)) { + // $FlowFixMe(>=0.90.0) + deferLabels.push(lastDefer.label); + } + } + return [every, deferLabels]; +} + /** * Implements the "Evaluating selection sets" section of the spec * for "read" mode. @@ -430,17 +614,24 @@ function executeFields( ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; - for (const responseName of Object.keys(fields)) { const fieldNodes = fields[responseName]; const fieldPath = addPath(path, responseName); - const result = resolveField( - exeContext, - parentType, - sourceValue, - fieldNodes, - fieldPath, - ); + const [every, deferLabels] = getDeferredInfo(exeContext, fieldNodes); + + const resolve = () => + resolveField(exeContext, parentType, sourceValue, fieldNodes, fieldPath); + + const result = every ? undefined : resolve(); + + for (const label of deferLabels) { + exeContext.resultResolver.addDeferredResult( + path, + label, + responseName, + () => (every ? resolve() : result), + ); + } if (result !== undefined) { results[responseName] = result; @@ -477,6 +668,7 @@ export function collectFields( selectionSet: SelectionSetNode, fields: ObjMap>, visitedFragmentNames: ObjMap, + deferDirective?: DirectiveNode, ): ObjMap> { for (const selection of selectionSet.selections) { switch (selection.kind) { @@ -488,6 +680,15 @@ export function collectFields( if (!fields[name]) { fields[name] = []; } + /* + in order to support defer on field + const isFieldDefer = shouldDeferNode(exeContext, selection); + if (deferDirective && !isFieldDefer) { + */ + if (deferDirective) { + // $FlowFixMe(>=0.90.0) + selection.directives.push(deferDirective); + } fields[name].push(selection); break; } @@ -498,12 +699,15 @@ export function collectFields( ) { continue; } + const fragmentDeferDirective = + shouldDeferNode(exeContext, selection) || deferDirective; collectFields( exeContext, runtimeType, selection.selectionSet, fields, visitedFragmentNames, + fragmentDeferDirective, ); break; } @@ -523,12 +727,15 @@ export function collectFields( ) { continue; } + const fragmentDeferDirective = + shouldDeferNode(exeContext, selection) || deferDirective; collectFields( exeContext, runtimeType, fragment.selectionSet, fields, visitedFragmentNames, + fragmentDeferDirective, ); break; } @@ -565,6 +772,32 @@ function shouldIncludeNode( return true; } +/** + * Determines if a field should be deferred. @skip and @include has higher + * precedence than @defer. + */ +function shouldDeferNode( + exeContext: ExecutionContext, + node: FragmentSpreadNode | InlineFragmentNode, +): DirectiveNode | void { + const shouldDefer = getDeferDirectiveValues(exeContext, node); + return shouldDefer && getDirective(GraphQLDeferDirective, node); +} + +function getDeferDirectiveValues( + exeContext: ExecutionContext, + node: FragmentSpreadNode | InlineFragmentNode | FieldNode, +): void | { [argument: string]: mixed, ... } { + const defer = getDirectiveValues( + GraphQLDeferDirective, + node, + exeContext.variableValues, + ); + if (defer && defer.if !== false) { + return defer; + } +} + /** * Determines if a fragment is applicable to the given type. */ diff --git a/src/execution/values.js b/src/execution/values.js index 1d09672e5f..998db4b810 100644 --- a/src/execution/values.js +++ b/src/execution/values.js @@ -230,6 +230,22 @@ export function getArgumentValues( return coercedValues; } +/** + * If the directive does not exist on the node, returns undefined otherwise returns a DirectiveNode. + */ +export function getDirective( + directiveDef: GraphQLDirective, + node: { +directives?: $ReadOnlyArray, ... }, +): void | DirectiveNode { + return ( + node.directives && + find( + node.directives, + directive => directive.name.value === directiveDef.name, + ) + ); +} + /** * Prepares an object map of argument values given a directive definition * and a AST node which may contain directives. Optionally also accepts a map @@ -246,12 +262,7 @@ export function getDirectiveValues( node: { +directives?: $ReadOnlyArray, ... }, variableValues?: ?ObjMap, ): void | { [argument: string]: mixed, ... } { - const directiveNode = - node.directives && - find( - node.directives, - directive => directive.name.value === directiveDef.name, - ); + const directiveNode = getDirective(directiveDef, node); if (directiveNode) { return getArgumentValues(directiveDef, directiveNode, variableValues); diff --git a/src/graphql.js b/src/graphql.js index a49f032166..c13fb2b82b 100644 --- a/src/graphql.js +++ b/src/graphql.js @@ -66,7 +66,10 @@ export type GraphQLArgs = {| fieldResolver?: ?GraphQLFieldResolver, typeResolver?: ?GraphQLTypeResolver, |}; -declare function graphql(GraphQLArgs, ..._: []): Promise; +declare function graphql( + GraphQLArgs, + ..._: [] +): PromiseOrValue> | ExecutionResult>; /* eslint-disable no-redeclare */ declare function graphql( schema: GraphQLSchema, @@ -77,7 +80,7 @@ declare function graphql( operationName?: ?string, fieldResolver?: ?GraphQLFieldResolver, typeResolver?: ?GraphQLTypeResolver, -): Promise; +): PromiseOrValue> | ExecutionResult>; export function graphql( argsOrSchema, source, @@ -161,7 +164,9 @@ export function graphqlSync( return result; } -function graphqlImpl(args: GraphQLArgs): PromiseOrValue { +function graphqlImpl( + args: GraphQLArgs, +): PromiseOrValue> | ExecutionResult> { const { schema, source, diff --git a/src/index.d.ts b/src/index.d.ts index 214f7a1371..f2f3cf4a68 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -54,6 +54,7 @@ export { specifiedDirectives, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLDeprecatedDirective, // "Enum" of Type Kinds TypeKind, diff --git a/src/index.js b/src/index.js index 9ea2be9858..850cb47efd 100644 --- a/src/index.js +++ b/src/index.js @@ -55,6 +55,7 @@ export { specifiedDirectives, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLDeprecatedDirective, // "Enum" of Type Kinds TypeKind, diff --git a/src/subscription/subscribe.js b/src/subscription/subscribe.js index ac90c3e1c5..12667afee1 100644 --- a/src/subscription/subscribe.js +++ b/src/subscription/subscribe.js @@ -4,6 +4,7 @@ import { isAsyncIterable } from 'iterall'; import inspect from '../jsutils/inspect'; import { addPath, pathToArray } from '../jsutils/Path'; +import { type PromiseOrValue } from '../jsutils/PromiseOrValue'; import { GraphQLError } from '../error/GraphQLError'; import { locatedError } from '../error/locatedError'; @@ -161,7 +162,9 @@ function subscribeImpl( isAsyncIterable(resultOrStream) ? mapAsyncIterator( ((resultOrStream: any): AsyncIterable), - mapSourceToResponse, + ((mapSourceToResponse: any): ( + payload: any, + ) => PromiseOrValue), reportGraphQLError, ) : ((resultOrStream: any): ExecutionResult), diff --git a/src/type/__tests__/introspection-test.js b/src/type/__tests__/introspection-test.js index 4a57cda970..e93c9d67ac 100644 --- a/src/type/__tests__/introspection-test.js +++ b/src/type/__tests__/introspection-test.js @@ -845,6 +845,34 @@ describe('Introspection', () => { }, ], }, + { + name: 'defer', + locations: ['FRAGMENT_SPREAD', 'INLINE_FRAGMENT'], + args: [ + { + defaultValue: null, + name: 'if', + type: { + kind: 'SCALAR', + name: 'Boolean', + ofType: null, + }, + }, + { + defaultValue: null, + name: 'label', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + ], + }, { name: 'deprecated', locations: ['FIELD_DEFINITION', 'ENUM_VALUE'], diff --git a/src/type/directives.d.ts b/src/type/directives.d.ts index b133c66483..17c0839725 100644 --- a/src/type/directives.d.ts +++ b/src/type/directives.d.ts @@ -54,6 +54,11 @@ export const GraphQLIncludeDirective: GraphQLDirective; */ export const GraphQLSkipDirective: GraphQLDirective; +/** + * Used to conditionally defer fragments. + */ +export const GraphQLDeferDirective: GraphQLDirective; + /** * Constant string used for default reason for a deprecation. */ diff --git a/src/type/directives.js b/src/type/directives.js index 13f579c8d9..0bae9b5228 100644 --- a/src/type/directives.js +++ b/src/type/directives.js @@ -167,6 +167,29 @@ export const GraphQLSkipDirective = new GraphQLDirective({ }, }); +/** + * Used to conditionally defer fragments. + */ +export const GraphQLDeferDirective = new GraphQLDirective({ + name: 'defer', + description: + 'Directs the executor to defer fragment when the `if` argument is true.', + locations: [ + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + ], + args: { + if: { + type: GraphQLBoolean, + description: 'Deferred when true.', + }, + label: { + type: GraphQLNonNull(GraphQLString), + description: 'label', + }, + }, +}); + /** * Constant string used for default reason for a deprecation. */ @@ -195,6 +218,7 @@ export const GraphQLDeprecatedDirective = new GraphQLDirective({ export const specifiedDirectives = Object.freeze([ GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLDeprecatedDirective, ]); diff --git a/src/type/index.d.ts b/src/type/index.d.ts index b6780d8171..f7d904daa5 100644 --- a/src/type/index.d.ts +++ b/src/type/index.d.ts @@ -114,6 +114,7 @@ export { specifiedDirectives, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLDeprecatedDirective, // Constant Deprecation Reason DEFAULT_DEPRECATION_REASON, diff --git a/src/type/index.js b/src/type/index.js index ec87a1c7b0..de843599c9 100644 --- a/src/type/index.js +++ b/src/type/index.js @@ -78,6 +78,7 @@ export { specifiedDirectives, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLDeprecatedDirective, // Constant Deprecation Reason DEFAULT_DEPRECATION_REASON, diff --git a/src/utilities/__tests__/buildASTSchema-test.js b/src/utilities/__tests__/buildASTSchema-test.js index 47fe0bab8c..e43d63223e 100644 --- a/src/utilities/__tests__/buildASTSchema-test.js +++ b/src/utilities/__tests__/buildASTSchema-test.js @@ -14,6 +14,7 @@ import { validateSchema } from '../../type/validate'; import { assertDirective, GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLIncludeDirective, GraphQLDeprecatedDirective, } from '../../type/directives'; @@ -211,9 +212,10 @@ describe('Schema Builder', () => { it('Maintains @skip & @include', () => { const schema = buildSchema('type Query'); - expect(schema.getDirectives()).to.have.lengthOf(3); + expect(schema.getDirectives()).to.have.lengthOf(4); expect(schema.getDirective('skip')).to.equal(GraphQLSkipDirective); expect(schema.getDirective('include')).to.equal(GraphQLIncludeDirective); + expect(schema.getDirective('defer')).to.equal(GraphQLDeferDirective); expect(schema.getDirective('deprecated')).to.equal( GraphQLDeprecatedDirective, ); @@ -224,9 +226,10 @@ describe('Schema Builder', () => { directive @skip on FIELD directive @include on FIELD directive @deprecated on FIELD_DEFINITION + directive @defer on FRAGMENT_SPREAD `); - expect(schema.getDirectives()).to.have.lengthOf(3); + expect(schema.getDirectives()).to.have.lengthOf(4); expect(schema.getDirective('skip')).to.not.equal(GraphQLSkipDirective); expect(schema.getDirective('include')).to.not.equal( GraphQLIncludeDirective, @@ -234,6 +237,7 @@ describe('Schema Builder', () => { expect(schema.getDirective('deprecated')).to.not.equal( GraphQLDeprecatedDirective, ); + expect(schema.getDirective('defer')).to.not.equal(GraphQLDeferDirective); }); it('Adding directives maintains @skip & @include', () => { @@ -241,10 +245,11 @@ describe('Schema Builder', () => { directive @foo(arg: Int) on FIELD `); - expect(schema.getDirectives()).to.have.lengthOf(4); + expect(schema.getDirectives()).to.have.lengthOf(5); expect(schema.getDirective('skip')).to.not.equal(undefined); expect(schema.getDirective('include')).to.not.equal(undefined); expect(schema.getDirective('deprecated')).to.not.equal(undefined); + expect(schema.getDirective('defer')).to.not.equal(undefined); }); it('Type modifiers', () => { diff --git a/src/utilities/__tests__/findBreakingChanges-test.js b/src/utilities/__tests__/findBreakingChanges-test.js index b3fd7dae6e..c46e3568f1 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.js +++ b/src/utilities/__tests__/findBreakingChanges-test.js @@ -7,6 +7,7 @@ import { GraphQLSchema } from '../../type/schema'; import { GraphQLSkipDirective, GraphQLIncludeDirective, + GraphQLDeferDirective, GraphQLDeprecatedDirective, } from '../../type/directives'; @@ -790,7 +791,11 @@ describe('findBreakingChanges', () => { const oldSchema = new GraphQLSchema({}); const newSchema = new GraphQLSchema({ - directives: [GraphQLSkipDirective, GraphQLIncludeDirective], + directives: [ + GraphQLSkipDirective, + GraphQLIncludeDirective, + GraphQLDeferDirective, + ], }); expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ diff --git a/src/utilities/__tests__/schemaPrinter-test.js b/src/utilities/__tests__/schemaPrinter-test.js index 14edfdca62..474156d798 100644 --- a/src/utilities/__tests__/schemaPrinter-test.js +++ b/src/utilities/__tests__/schemaPrinter-test.js @@ -590,6 +590,15 @@ describe('Type System Printer', () => { if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + """Directs the executor to defer fragment when the \`if\` argument is true.""" + directive @defer( + """Deferred when true.""" + if: Boolean + + """label""" + label: String! + ) on FRAGMENT_SPREAD | INLINE_FRAGMENT + """Marks an element of a GraphQL schema as no longer supported.""" directive @deprecated( """ @@ -803,6 +812,15 @@ describe('Type System Printer', () => { if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + # Directs the executor to defer fragment when the \`if\` argument is true. + directive @defer( + # Deferred when true. + if: Boolean + + # label + label: String! + ) on FRAGMENT_SPREAD | INLINE_FRAGMENT + # Marks an element of a GraphQL schema as no longer supported. directive @deprecated( # Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/). diff --git a/src/utilities/buildASTSchema.js b/src/utilities/buildASTSchema.js index 0259e2c1d2..1c30ddcf34 100644 --- a/src/utilities/buildASTSchema.js +++ b/src/utilities/buildASTSchema.js @@ -15,6 +15,7 @@ import { } from '../type/schema'; import { GraphQLSkipDirective, + GraphQLDeferDirective, GraphQLIncludeDirective, GraphQLDeprecatedDirective, } from '../type/directives'; @@ -101,6 +102,10 @@ export function buildASTSchema( directives.push(GraphQLSkipDirective); } + if (!directives.some(directive => directive.name === 'defer')) { + directives.push(GraphQLDeferDirective); + } + if (!directives.some(directive => directive.name === 'include')) { directives.push(GraphQLIncludeDirective); } diff --git a/src/utilities/introspectionFromSchema.js b/src/utilities/introspectionFromSchema.js index b3ff3d8e60..a1c340a3b3 100644 --- a/src/utilities/introspectionFromSchema.js +++ b/src/utilities/introspectionFromSchema.js @@ -28,6 +28,6 @@ export function introspectionFromSchema( ): IntrospectionQuery { const document = parse(getIntrospectionQuery(options)); const result = execute({ schema, document }); - invariant(!isPromise(result) && !result.errors && result.data); + invariant(!isPromise(result) && result.errors == null && result.data != null); return (result.data: any); } diff --git a/src/validation/__tests__/KnownDirectives-test.js b/src/validation/__tests__/KnownDirectives-test.js index d875cbc4a1..59129f99a4 100644 --- a/src/validation/__tests__/KnownDirectives-test.js +++ b/src/validation/__tests__/KnownDirectives-test.js @@ -61,6 +61,7 @@ describe('Validate: Known directives', () => { human @skip(if: false) { name } + ...DeferFrag @defer(if: true) } `); }); @@ -116,6 +117,8 @@ describe('Validate: Known directives', () => { ...Frag @include(if: true) skippedField @skip(if: true) ...SkippedFrag @skip(if: true) + ...DeferVarFrag @defer(if: $var) + ...DeferFrag @defer(if: true) } mutation Bar @onMutation { @@ -135,7 +138,7 @@ describe('Validate: Known directives', () => { it('with misplaced directives', () => { expectErrors(` query Foo($var: Boolean) @include(if: true) { - name @onQuery @include(if: $var) + name @onQuery @include(if: $var) @defer(if: $var) ...Frag @onQuery } @@ -151,6 +154,10 @@ describe('Validate: Known directives', () => { message: 'Directive "@onQuery" may not be used on FIELD.', locations: [{ line: 3, column: 14 }], }, + { + message: 'Directive "@defer" may not be used on FIELD.', + locations: [{ line: 3, column: 42 }], + }, { message: 'Directive "@onQuery" may not be used on FRAGMENT_SPREAD.', locations: [{ line: 4, column: 17 }], diff --git a/src/validation/__tests__/ProvidedRequiredArguments-test.js b/src/validation/__tests__/ProvidedRequiredArguments-test.js index d969838169..f6130f0068 100644 --- a/src/validation/__tests__/ProvidedRequiredArguments-test.js +++ b/src/validation/__tests__/ProvidedRequiredArguments-test.js @@ -227,6 +227,8 @@ describe('Validate: Provided required arguments', () => { human @skip(if: false) { name } + ...DeferFrag @defer(label: "DeferFrag_defer") + ...DeferIfFrag @defer(if: true, label: "DeferFrag_defer") } `); }); @@ -237,6 +239,7 @@ describe('Validate: Provided required arguments', () => { dog @include { name @skip } + ...DeferFrag @defer } `).to.deep.equal([ { @@ -249,6 +252,11 @@ describe('Validate: Provided required arguments', () => { 'Directive "@skip" argument "if" of type "Boolean!" is required, but it was not provided.', locations: [{ line: 4, column: 18 }], }, + { + message: + 'Directive "@defer" argument "label" of type "String!" is required, but it was not provided.', + locations: [{ line: 6, column: 24 }], + }, ]); }); }); diff --git a/src/validation/__tests__/harness.js b/src/validation/__tests__/harness.js index d84d7520e5..d81c137f96 100644 --- a/src/validation/__tests__/harness.js +++ b/src/validation/__tests__/harness.js @@ -11,6 +11,7 @@ import { GraphQLDirective, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, } from '../../type/directives'; import { GraphQLInt, @@ -362,6 +363,7 @@ export const testSchema = new GraphQLSchema({ directives: [ GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLDeferDirective, new GraphQLDirective({ name: 'onQuery', locations: ['QUERY'],