diff --git a/benchmark/benchmark.js b/benchmark/benchmark.js index 0f83a2efdc..b780279ac2 100644 --- a/benchmark/benchmark.js +++ b/benchmark/benchmark.js @@ -347,9 +347,9 @@ function sampleModule(modulePath) { clock(7, module.measure); // warm up global.gc(); - process.nextTick(() => { + process.nextTick(async () => { const memBaseline = process.memoryUsage().heapUsed; - const clocked = clock(module.count, module.measure); + const clocked = await clock(module.count, module.measure); process.send({ name: module.name, clocked: clocked / module.count, @@ -358,10 +358,10 @@ function sampleModule(modulePath) { }); // Clocks the time taken to execute a test per cycle (secs). - function clock(count, fn) { + async function clock(count, fn) { const start = process.hrtime.bigint(); for (let i = 0; i < count; ++i) { - fn(); + await fn(); } return Number(process.hrtime.bigint() - start); } diff --git a/benchmark/list-async-benchmark.js b/benchmark/list-async-benchmark.js new file mode 100644 index 0000000000..ef83b8174e --- /dev/null +++ b/benchmark/list-async-benchmark.js @@ -0,0 +1,28 @@ +'use strict'; + +const { parse } = require('graphql/language/parser.js'); +const { execute } = require('graphql/execution/execute.js'); +const { buildSchema } = require('graphql/utilities/buildASTSchema.js'); + +const schema = buildSchema('type Query { listField: [String] }'); +const document = parse('{ listField }'); + +function listField() { + const results = []; + for (let index = 0; index < 100000; index++) { + results.push(Promise.resolve(index)); + } + return results; +} + +module.exports = { + name: 'Execute Asynchronous List Field', + count: 10, + async measure() { + await execute({ + schema, + document, + rootValue: { listField }, + }); + }, +}; diff --git a/benchmark/list-asyncIterable-benchmark.js b/benchmark/list-asyncIterable-benchmark.js new file mode 100644 index 0000000000..3863cca833 --- /dev/null +++ b/benchmark/list-asyncIterable-benchmark.js @@ -0,0 +1,26 @@ +'use strict'; + +const { parse } = require('graphql/language/parser.js'); +const { execute } = require('graphql/execution/execute.js'); +const { buildSchema } = require('graphql/utilities/buildASTSchema.js'); + +const schema = buildSchema('type Query { listField: [String] }'); +const document = parse('{ listField }'); + +async function* listField() { + for (let index = 0; index < 100000; index++) { + yield index; + } +} + +module.exports = { + name: 'Execute Async Iterable List Field', + count: 10, + async measure() { + await execute({ + schema, + document, + rootValue: { listField }, + }); + }, +}; diff --git a/benchmark/list-sync-benchmark.js b/benchmark/list-sync-benchmark.js new file mode 100644 index 0000000000..172d87a967 --- /dev/null +++ b/benchmark/list-sync-benchmark.js @@ -0,0 +1,28 @@ +'use strict'; + +const { parse } = require('graphql/language/parser.js'); +const { execute } = require('graphql/execution/execute.js'); +const { buildSchema } = require('graphql/utilities/buildASTSchema.js'); + +const schema = buildSchema('type Query { listField: [String] }'); +const document = parse('{ listField }'); + +function listField() { + const results = []; + for (let index = 0; index < 100000; index++) { + results.push(index); + } + return results; +} + +module.exports = { + name: 'Execute Synchronous List Field', + count: 10, + async measure() { + await execute({ + schema, + document, + rootValue: { listField }, + }); + }, +}; diff --git a/src/execution/__tests__/lists-test.js b/src/execution/__tests__/lists-test.js index 5654b55ccc..c5f1b393a0 100644 --- a/src/execution/__tests__/lists-test.js +++ b/src/execution/__tests__/lists-test.js @@ -2,6 +2,9 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import { parse } from '../../language/parser'; +import { GraphQLList, GraphQLObjectType } from '../../type/definition'; +import { GraphQLString } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; import { buildSchema } from '../../utilities/buildASTSchema'; @@ -64,6 +67,125 @@ describe('Execute: Accepts any iterable as list value', () => { }); }); +describe('Execute: Accepts async iterables as list value', () => { + function complete(rootValue: mixed) { + return execute({ + schema: buildSchema('type Query { listField: [String] }'), + document: parse('{ listField }'), + rootValue, + }); + } + + function completeObjectList(resolve) { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + listField: { + resolve: async function* listField() { + yield await { index: 0 }; + yield await { index: 1 }; + yield await { index: 2 }; + }, + type: new GraphQLList( + new GraphQLObjectType({ + name: 'ObjectWrapper', + fields: { + index: { + type: GraphQLString, + resolve, + }, + }, + }), + ), + }, + }, + }), + }); + return execute({ + schema, + document: parse('{ listField { index } }'), + }); + } + + it('Accepts an AsyncGenerator function as a List value', async () => { + async function* listField() { + yield await 'two'; + yield await 4; + yield await false; + } + + expect(await complete({ listField })).to.deep.equal({ + data: { listField: ['two', '4', 'false'] }, + }); + }); + + it('Handles an AsyncGenerator function that throws', async () => { + async function* listField() { + yield await 'two'; + yield await 4; + throw new Error('bad'); + } + + expect(await complete({ listField })).to.deep.equal({ + data: { listField: ['two', '4', null] }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField', 2], + }, + ], + }); + }); + + it('Handles errors from `completeValue` in AsyncIterables', async () => { + async function* listField() { + yield await 'two'; + yield await {}; + } + + expect(await complete({ listField })).to.deep.equal({ + data: { listField: ['two', null] }, + errors: [ + { + message: 'String cannot represent value: {}', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ], + }); + }); + + it('Handles promises from `completeValue` in AsyncIterables', async () => { + expect( + await completeObjectList(({ index }) => Promise.resolve(index)), + ).to.deep.equal({ + data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] }, + }); + }); + + it('Handles rejected promises from `completeValue` in AsyncIterables', async () => { + expect( + await completeObjectList(({ index }) => { + if (index === 2) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(index); + }), + ).to.deep.equal({ + data: { listField: [{ index: '0' }, { index: '1' }, { index: null }] }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 15 }], + path: ['listField', 2, 'index'], + }, + ], + }); + }); +}); + describe('Execute: Handles list nullability', () => { async function complete(args: {| listField: mixed, as: string |}) { const { listField, as } = args; diff --git a/src/execution/execute.js b/src/execution/execute.js index c9c2c6a809..b2afb785d4 100644 --- a/src/execution/execute.js +++ b/src/execution/execute.js @@ -6,6 +6,7 @@ import memoize3 from '../jsutils/memoize3'; import invariant from '../jsutils/invariant'; import devAssert from '../jsutils/devAssert'; import isPromise from '../jsutils/isPromise'; +import isAsyncIterable from '../jsutils/isAsyncIterable'; import isObjectLike from '../jsutils/isObjectLike'; import isCollection from '../jsutils/isCollection'; import promiseReduce from '../jsutils/promiseReduce'; @@ -809,6 +810,74 @@ function completeValue( ); } +/** + * Complete a async iterator value by completing the result and calling + * recursively until all the results are completed. + */ +function completeAsyncIteratorValue( + exeContext: ExecutionContext, + itemType: GraphQLOutputType, + fieldNodes: $ReadOnlyArray, + info: GraphQLResolveInfo, + path: Path, + iterator: AsyncIterator, +): Promise<$ReadOnlyArray> { + let containsPromise = false; + return new Promise((resolve) => { + function next(index, completedResults) { + const fieldPath = addPath(path, index, undefined); + iterator.next().then( + ({ value, done }) => { + if (done) { + resolve(completedResults); + return; + } + // TODO can the error checking logic be consolidated with completeListValue? + try { + const completedItem = completeValue( + exeContext, + itemType, + fieldNodes, + info, + fieldPath, + value, + ); + if (isPromise(completedItem)) { + containsPromise = true; + } + completedResults.push(completedItem); + } catch (rawError) { + completedResults.push(null); + const error = locatedError( + rawError, + fieldNodes, + pathToArray(fieldPath), + ); + handleFieldError(error, itemType, exeContext); + resolve(completedResults); + return; + } + + next(index + 1, completedResults); + }, + (rawError) => { + completedResults.push(null); + const error = locatedError( + rawError, + fieldNodes, + pathToArray(fieldPath), + ); + handleFieldError(error, itemType, exeContext); + resolve(completedResults); + }, + ); + } + next(0, []); + }).then((completedResults) => + containsPromise ? Promise.all(completedResults) : completedResults, + ); +} + /** * Complete a list value by completing each item in the list with the * inner type @@ -821,6 +890,21 @@ function completeListValue( path: Path, result: mixed, ): PromiseOrValue<$ReadOnlyArray> { + const itemType = returnType.ofType; + + if (isAsyncIterable(result)) { + const iterator = result[Symbol.asyncIterator](); + + return completeAsyncIteratorValue( + exeContext, + itemType, + fieldNodes, + info, + path, + iterator, + ); + } + if (!isCollection(result)) { throw new GraphQLError( `Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`, @@ -829,7 +913,6 @@ function completeListValue( // 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 itemType = returnType.ofType; let containsPromise = false; const completedResults = Array.from(result, (item, index) => { // No need to modify the info object containing the path,