From 42c83f3cf453179b7dbc8bf719b80e93bc9c2a90 Mon Sep 17 00:00:00 2001 From: Rob Richard Date: Wed, 12 Aug 2020 16:37:57 -0400 Subject: [PATCH 1/2] Support returning async iterables from resolver functions --- src/execution/__tests__/lists-test.js | 33 ++++++++++++++ src/execution/execute.js | 62 ++++++++++++++++++++++++++- src/jsutils/isAsyncIterable.js | 17 ++++++++ src/subscription/subscribe.js | 21 ++------- 4 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 src/jsutils/isAsyncIterable.js diff --git a/src/execution/__tests__/lists-test.js b/src/execution/__tests__/lists-test.js index c8873acf74..f8e97c0746 100644 --- a/src/execution/__tests__/lists-test.js +++ b/src/execution/__tests__/lists-test.js @@ -62,6 +62,39 @@ describe('Execute: Accepts any iterable as list value', () => { ], }); }); + + it('Accepts an AsyncGenerator function as a List value', async () => { + async function* yieldAsyncItems() { + yield await 'two'; + yield await 4; + yield await false; + } + const listField = yieldAsyncItems(); + + expect(await complete({ listField })).to.deep.equal({ + data: { listField: ['two', '4', 'false'] }, + }); + }); + + it('Handles an AsyncGenerator function that throws', async () => { + async function* yieldAsyncItemsError() { + yield await 'two'; + yield await 4; + throw new Error('bad'); + } + const listField = yieldAsyncItemsError(); + + expect(await complete({ listField })).to.deep.equal({ + data: { listField: ['two', '4', null] }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField', 2], + }, + ], + }); + }); }); describe('Execute: Handles list nullability', () => { diff --git a/src/execution/execute.js b/src/execution/execute.js index 6e982a12ca..290b117fd6 100644 --- a/src/execution/execute.js +++ b/src/execution/execute.js @@ -1,4 +1,5 @@ import arrayFrom from '../polyfills/arrayFrom'; +import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols'; import type { Path } from '../jsutils/Path'; import type { ObjMap } from '../jsutils/ObjMap'; @@ -8,6 +9,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'; @@ -850,6 +852,48 @@ 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, + index: number, + completedResults: Array, + iterator: AsyncIterator, +): Promise<$ReadOnlyArray> { + const fieldPath = addPath(path, index, undefined); + return iterator.next().then( + ({ value, done }) => { + if (done) { + return completedResults; + } + completedResults.push( + completeValue(exeContext, itemType, fieldNodes, info, fieldPath, value), + ); + return completeAsyncIteratorValue( + exeContext, + itemType, + fieldNodes, + info, + path, + index + 1, + completedResults, + iterator, + ); + }, + (error) => { + completedResults.push(null); + handleFieldError(error, fieldNodes, fieldPath, itemType, exeContext); + return completedResults; + }, + ); +} + /** * Complete a list value by completing each item in the list with the * inner type @@ -862,6 +906,23 @@ function completeListValue( path: Path, result: mixed, ): PromiseOrValue<$ReadOnlyArray> { + const itemType = returnType.ofType; + + if (isAsyncIterable(result)) { + const iterator = result[SYMBOL_ASYNC_ITERATOR](); + + return completeAsyncIteratorValue( + exeContext, + itemType, + fieldNodes, + info, + path, + 0, + [], + iterator, + ); + } + if (!isCollection(result)) { throw new GraphQLError( `Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`, @@ -870,7 +931,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 = arrayFrom(result, (item, index) => { // No need to modify the info object containing the path, diff --git a/src/jsutils/isAsyncIterable.js b/src/jsutils/isAsyncIterable.js new file mode 100644 index 0000000000..3dbbf9e214 --- /dev/null +++ b/src/jsutils/isAsyncIterable.js @@ -0,0 +1,17 @@ +import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols'; + +/** + * Returns true if the provided object implements the AsyncIterator protocol via + * either implementing a `Symbol.asyncIterator` or `"@@asyncIterator"` method. + */ +declare function isAsyncIterable(value: mixed): boolean %checks(value instanceof + AsyncIterable); + +// eslint-disable-next-line no-redeclare +export default function isAsyncIterable(maybeAsyncIterable) { + if (maybeAsyncIterable == null || typeof maybeAsyncIterable !== 'object') { + return false; + } + + return typeof maybeAsyncIterable[SYMBOL_ASYNC_ITERATOR] === 'function'; +} diff --git a/src/subscription/subscribe.js b/src/subscription/subscribe.js index 0f53906812..49de1b6cbc 100644 --- a/src/subscription/subscribe.js +++ b/src/subscription/subscribe.js @@ -1,6 +1,5 @@ -import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols'; - import inspect from '../jsutils/inspect'; +import isAsyncIterable from '../jsutils/isAsyncIterable'; import { addPath, pathToArray } from '../jsutils/Path'; import { GraphQLError } from '../error/GraphQLError'; @@ -158,7 +157,7 @@ function subscribeImpl( // Note: Flow can't refine isAsyncIterable, so explicit casts are used. isAsyncIterable(resultOrStream) ? mapAsyncIterator( - ((resultOrStream: any): AsyncIterable), + resultOrStream, mapSourceToResponse, reportGraphQLError, ) @@ -289,24 +288,10 @@ function executeSubscription( `Received: ${inspect(eventStream)}.`, ); } - - // Note: isAsyncIterable above ensures this will be correct. - return ((eventStream: any): AsyncIterable); + return eventStream; }, (error) => { throw locatedError(error, fieldNodes, pathToArray(path)); }, ); } - -/** - * Returns true if the provided object implements the AsyncIterator protocol via - * either implementing a `Symbol.asyncIterator` or `"@@asyncIterator"` method. - */ -function isAsyncIterable(maybeAsyncIterable: mixed): boolean { - if (maybeAsyncIterable == null || typeof maybeAsyncIterable !== 'object') { - return false; - } - - return typeof maybeAsyncIterable[SYMBOL_ASYNC_ITERATOR] === 'function'; -} From f0c32157baf676421ea826cc0a657af1df042c48 Mon Sep 17 00:00:00 2001 From: Ivan Goncharov Date: Tue, 25 Aug 2020 20:06:23 +0300 Subject: [PATCH 2/2] Move AsyncIteratable tests in to separate block --- src/execution/__tests__/lists-test.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/execution/__tests__/lists-test.js b/src/execution/__tests__/lists-test.js index f8e97c0746..933d1795d4 100644 --- a/src/execution/__tests__/lists-test.js +++ b/src/execution/__tests__/lists-test.js @@ -62,14 +62,23 @@ 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, + }); + } it('Accepts an AsyncGenerator function as a List value', async () => { - async function* yieldAsyncItems() { + async function* listField() { yield await 'two'; yield await 4; yield await false; } - const listField = yieldAsyncItems(); expect(await complete({ listField })).to.deep.equal({ data: { listField: ['two', '4', 'false'] }, @@ -77,12 +86,11 @@ describe('Execute: Accepts any iterable as list value', () => { }); it('Handles an AsyncGenerator function that throws', async () => { - async function* yieldAsyncItemsError() { + async function* listField() { yield await 'two'; yield await 4; throw new Error('bad'); } - const listField = yieldAsyncItemsError(); expect(await complete({ listField })).to.deep.equal({ data: { listField: ['two', '4', null] },