diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index ac6460d5477..3fdd77ab569 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -3,10 +3,18 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue'; + import { parse } from '../../language/parser'; +import type { GraphQLFieldResolver } from '../../type/definition'; +import { GraphQLList, GraphQLObjectType } from '../../type/definition'; +import { GraphQLString } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; + import { buildSchema } from '../../utilities/buildASTSchema'; +import type { ExecutionResult } from '../execute'; import { execute, executeSync } from '../execute'; describe('Execute: Accepts any iterable as list value', () => { @@ -66,6 +74,175 @@ describe('Execute: Accepts any iterable as list value', () => { }); }); +describe('Execute: Accepts async iterables as list value', () => { + function complete(rootValue: unknown, as: string = '[String]') { + return execute({ + schema: buildSchema(`type Query { listField: ${as} }`), + document: parse('{ listField }'), + rootValue, + }); + } + + function completeObjectList( + resolve: GraphQLFieldResolver<{ index: number }, unknown>, + ): PromiseOrValue { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + listField: { + resolve: async function* listField() { + yield await Promise.resolve({ index: 0 }); + yield await Promise.resolve({ index: 1 }); + yield await Promise.resolve({ 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 Promise.resolve('two'); + yield await Promise.resolve(4); + yield await Promise.resolve(false); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: ['two', '4', 'false'] }, + }); + }); + + it('Handles an AsyncGenerator function that throws', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve(4); + throw new Error('bad'); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: ['two', '4', null] }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField', 2], + }, + ], + }); + }); + + it('Handles an AsyncGenerator function where an intermediate value triggers an error', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve({}); + yield await Promise.resolve(4); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: ['two', null, '4'] }, + errors: [ + { + message: 'String cannot represent value: {}', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ], + }); + }); + + it('Handles errors from `completeValue` in AsyncIterables', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve({}); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + 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 () => { + expectJSON( + await completeObjectList(({ index }) => Promise.resolve(index)), + ).toDeepEqual({ + data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] }, + }); + }); + + it('Handles rejected promises from `completeValue` in AsyncIterables', async () => { + expectJSON( + await completeObjectList(({ index }) => { + if (index === 2) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(index); + }), + ).toDeepEqual({ + data: { listField: [{ index: '0' }, { index: '1' }, { index: null }] }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 15 }], + path: ['listField', 2, 'index'], + }, + ], + }); + }); + it('Handles nulls yielded by async generator', async () => { + async function* listField() { + yield await Promise.resolve(1); + yield await Promise.resolve(null); + yield await Promise.resolve(2); + } + const errors = [ + { + message: 'Cannot return null for non-nullable field Query.listField.', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ]; + + expect(await complete({ listField }, '[Int]')).to.deep.equal({ + data: { listField: [1, null, 2] }, + }); + expect(await complete({ listField }, '[Int]!')).to.deep.equal({ + data: { listField: [1, null, 2] }, + }); + expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ + data: { listField: null }, + errors, + }); + expectJSON(await complete({ listField }, '[Int!]!')).toDeepEqual({ + data: null, + errors, + }); + }); +}); + describe('Execute: Handles list nullability', () => { async function complete(args: { listField: unknown; as: string }) { const { listField, as } = args; diff --git a/src/execution/execute.ts b/src/execution/execute.ts index d3c21385e85..9298d94a3f8 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,6 +1,7 @@ import { devAssert } from '../jsutils/devAssert'; import { inspect } from '../jsutils/inspect'; import { invariant } from '../jsutils/invariant'; +import { isAsyncIterable } from '../jsutils/isAsyncIterable'; import { isIterableObject } from '../jsutils/isIterableObject'; import { isObjectLike } from '../jsutils/isObjectLike'; import { isPromise } from '../jsutils/isPromise'; @@ -703,6 +704,66 @@ function completeValue( ); } +/** + * Complete a async iterator value by completing the result and calling + * recursively until all the results are completed. + */ +async function completeAsyncIteratorValue( + exeContext: ExecutionContext, + itemType: GraphQLOutputType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: Path, + iterator: AsyncIterator, +): Promise> { + let containsPromise = false; + const completedResults = []; + let index = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const fieldPath = addPath(path, index, undefined); + try { + // eslint-disable-next-line no-await-in-loop + const { value, done } = await iterator.next(); + if (done) { + break; + } + + try { + // TODO can the error checking logic be consolidated with completeListValue? + 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); + continue; + } + } catch (rawError) { + completedResults.push(null); + const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath)); + handleFieldError(error, itemType, exeContext); + break; + } + index += 1; + } + return containsPromise ? Promise.all(completedResults) : completedResults; +} + /** * Complete a list value by completing each item in the list with the * inner type @@ -715,6 +776,21 @@ function completeListValue( path: Path, result: unknown, ): PromiseOrValue> { + const itemType = returnType.ofType; + + if (isAsyncIterable(result)) { + const iterator = result[Symbol.asyncIterator](); + + return completeAsyncIteratorValue( + exeContext, + itemType, + fieldNodes, + info, + path, + iterator, + ); + } + if (!isIterableObject(result)) { throw new GraphQLError( `Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`, @@ -723,7 +799,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,