Skip to content

Commit 90c359c

Browse files
committed
Support returning async iterables from resolver functions
Support returning async iterables from resolver functions
1 parent bb52f73 commit 90c359c

File tree

2 files changed

+263
-1
lines changed

2 files changed

+263
-1
lines changed

src/execution/__tests__/lists-test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33

44
import { expectJSON } from '../../__testUtils__/expectJSON';
5+
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue';
56

67
import { parse } from '../../language/parser';
8+
import type { GraphQLFieldResolver } from '../../type/definition';
9+
import { GraphQLList, GraphQLObjectType } from '../../type/definition';
10+
import { GraphQLString } from '../../type/scalars';
11+
import { GraphQLSchema } from '../../type/schema';
712

813
import { buildSchema } from '../../utilities/buildASTSchema';
914

15+
import type { ExecutionResult } from '../execute';
1016
import { execute, executeSync } from '../execute';
1117

1218
describe('Execute: Accepts any iterable as list value', () => {
@@ -66,6 +72,175 @@ describe('Execute: Accepts any iterable as list value', () => {
6672
});
6773
});
6874

75+
describe('Execute: Accepts async iterables as list value', () => {
76+
function complete(rootValue: unknown, as: string = '[String]') {
77+
return execute({
78+
schema: buildSchema(`type Query { listField: ${as} }`),
79+
document: parse('{ listField }'),
80+
rootValue,
81+
});
82+
}
83+
84+
function completeObjectList(
85+
resolve: GraphQLFieldResolver<{ index: number }, unknown>,
86+
): PromiseOrValue<ExecutionResult> {
87+
const schema = new GraphQLSchema({
88+
query: new GraphQLObjectType({
89+
name: 'Query',
90+
fields: {
91+
listField: {
92+
resolve: async function* listField() {
93+
yield await Promise.resolve({ index: 0 });
94+
yield await Promise.resolve({ index: 1 });
95+
yield await Promise.resolve({ index: 2 });
96+
},
97+
type: new GraphQLList(
98+
new GraphQLObjectType({
99+
name: 'ObjectWrapper',
100+
fields: {
101+
index: {
102+
type: GraphQLString,
103+
resolve,
104+
},
105+
},
106+
}),
107+
),
108+
},
109+
},
110+
}),
111+
});
112+
return execute({
113+
schema,
114+
document: parse('{ listField { index } }'),
115+
});
116+
}
117+
118+
it('Accepts an AsyncGenerator function as a List value', async () => {
119+
async function* listField() {
120+
yield await Promise.resolve('two');
121+
yield await Promise.resolve(4);
122+
yield await Promise.resolve(false);
123+
}
124+
125+
expectJSON(await complete({ listField })).toDeepEqual({
126+
data: { listField: ['two', '4', 'false'] },
127+
});
128+
});
129+
130+
it('Handles an AsyncGenerator function that throws', async () => {
131+
async function* listField() {
132+
yield await Promise.resolve('two');
133+
yield await Promise.resolve(4);
134+
throw new Error('bad');
135+
}
136+
137+
expectJSON(await complete({ listField })).toDeepEqual({
138+
data: { listField: ['two', '4', null] },
139+
errors: [
140+
{
141+
message: 'bad',
142+
locations: [{ line: 1, column: 3 }],
143+
path: ['listField', 2],
144+
},
145+
],
146+
});
147+
});
148+
149+
it('Handles an AsyncGenerator function where an intermediate value triggers an error', async () => {
150+
async function* listField() {
151+
yield await Promise.resolve('two');
152+
yield await Promise.resolve({});
153+
yield await Promise.resolve(4);
154+
}
155+
156+
expectJSON(await complete({ listField })).toDeepEqual({
157+
data: { listField: ['two', null, '4'] },
158+
errors: [
159+
{
160+
message: 'String cannot represent value: {}',
161+
locations: [{ line: 1, column: 3 }],
162+
path: ['listField', 1],
163+
},
164+
],
165+
});
166+
});
167+
168+
it('Handles errors from `completeValue` in AsyncIterables', async () => {
169+
async function* listField() {
170+
yield await Promise.resolve('two');
171+
yield await Promise.resolve({});
172+
}
173+
174+
expectJSON(await complete({ listField })).toDeepEqual({
175+
data: { listField: ['two', null] },
176+
errors: [
177+
{
178+
message: 'String cannot represent value: {}',
179+
locations: [{ line: 1, column: 3 }],
180+
path: ['listField', 1],
181+
},
182+
],
183+
});
184+
});
185+
186+
it('Handles promises from `completeValue` in AsyncIterables', async () => {
187+
expectJSON(
188+
await completeObjectList(({ index }) => Promise.resolve(index)),
189+
).toDeepEqual({
190+
data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] },
191+
});
192+
});
193+
194+
it('Handles rejected promises from `completeValue` in AsyncIterables', async () => {
195+
expectJSON(
196+
await completeObjectList(({ index }) => {
197+
if (index === 2) {
198+
return Promise.reject(new Error('bad'));
199+
}
200+
return Promise.resolve(index);
201+
}),
202+
).toDeepEqual({
203+
data: { listField: [{ index: '0' }, { index: '1' }, { index: null }] },
204+
errors: [
205+
{
206+
message: 'bad',
207+
locations: [{ line: 1, column: 15 }],
208+
path: ['listField', 2, 'index'],
209+
},
210+
],
211+
});
212+
});
213+
it('Handles nulls yielded by async generator', async () => {
214+
async function* listField() {
215+
yield await Promise.resolve(1);
216+
yield await Promise.resolve(null);
217+
yield await Promise.resolve(2);
218+
}
219+
const errors = [
220+
{
221+
message: 'Cannot return null for non-nullable field Query.listField.',
222+
locations: [{ line: 1, column: 3 }],
223+
path: ['listField', 1],
224+
},
225+
];
226+
227+
expect(await complete({ listField }, '[Int]')).to.deep.equal({
228+
data: { listField: [1, null, 2] },
229+
});
230+
expect(await complete({ listField }, '[Int]!')).to.deep.equal({
231+
data: { listField: [1, null, 2] },
232+
});
233+
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
234+
data: { listField: null },
235+
errors,
236+
});
237+
expectJSON(await complete({ listField }, '[Int!]!')).toDeepEqual({
238+
data: null,
239+
errors,
240+
});
241+
});
242+
});
243+
69244
describe('Execute: Handles list nullability', () => {
70245
async function complete(args: { listField: unknown; as: string }) {
71246
const { listField, as } = args;

src/execution/execute.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { promiseReduce } from '../jsutils/promiseReduce';
1212
import { promiseForObject } from '../jsutils/promiseForObject';
1313
import { addPath, pathToArray } from '../jsutils/Path';
1414
import { isIterableObject } from '../jsutils/isIterableObject';
15+
import { isAsyncIterable } from '../jsutils/isAsyncIterable';
1516

1617
import type { GraphQLFormattedError } from '../error/GraphQLError';
1718
import { GraphQLError } from '../error/GraphQLError';
@@ -703,6 +704,78 @@ function completeValue(
703704
);
704705
}
705706

707+
/**
708+
* Complete a async iterator value by completing the result and calling
709+
* recursively until all the results are completed.
710+
*/
711+
function completeAsyncIteratorValue(
712+
exeContext: ExecutionContext,
713+
itemType: GraphQLOutputType,
714+
fieldNodes: ReadonlyArray<FieldNode>,
715+
info: GraphQLResolveInfo,
716+
path: Path,
717+
iterator: AsyncIterator<unknown>,
718+
): Promise<ReadonlyArray<unknown>> {
719+
let containsPromise = false;
720+
return new Promise<ReadonlyArray<unknown>>((resolve, reject) => {
721+
function next(index: number, completedResults: Array<unknown>) {
722+
const fieldPath = addPath(path, index, undefined);
723+
iterator
724+
.next()
725+
.then(
726+
({ value, done }) => {
727+
if (done) {
728+
resolve(completedResults);
729+
return;
730+
}
731+
// TODO can the error checking logic be consolidated with completeListValue?
732+
try {
733+
const completedItem = completeValue(
734+
exeContext,
735+
itemType,
736+
fieldNodes,
737+
info,
738+
fieldPath,
739+
value,
740+
);
741+
if (isPromise(completedItem)) {
742+
containsPromise = true;
743+
}
744+
completedResults.push(completedItem);
745+
} catch (rawError) {
746+
completedResults.push(null);
747+
const error = locatedError(
748+
rawError,
749+
fieldNodes,
750+
pathToArray(fieldPath),
751+
);
752+
handleFieldError(error, itemType, exeContext);
753+
resolve(completedResults);
754+
}
755+
756+
next(index + 1, completedResults);
757+
},
758+
(rawError) => {
759+
completedResults.push(null);
760+
const error = locatedError(
761+
rawError,
762+
fieldNodes,
763+
pathToArray(fieldPath),
764+
);
765+
handleFieldError(error, itemType, exeContext);
766+
resolve(completedResults);
767+
},
768+
)
769+
.then(null, (e) => {
770+
reject(e);
771+
});
772+
}
773+
next(0, []);
774+
}).then((completedResults) =>
775+
containsPromise ? Promise.all(completedResults) : completedResults,
776+
);
777+
}
778+
706779
/**
707780
* Complete a list value by completing each item in the list with the
708781
* inner type
@@ -715,6 +788,21 @@ function completeListValue(
715788
path: Path,
716789
result: unknown,
717790
): PromiseOrValue<ReadonlyArray<unknown>> {
791+
const itemType = returnType.ofType;
792+
793+
if (isAsyncIterable(result)) {
794+
const iterator = result[Symbol.asyncIterator]();
795+
796+
return completeAsyncIteratorValue(
797+
exeContext,
798+
itemType,
799+
fieldNodes,
800+
info,
801+
path,
802+
iterator,
803+
);
804+
}
805+
718806
if (!isIterableObject(result)) {
719807
throw new GraphQLError(
720808
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -723,7 +811,6 @@ function completeListValue(
723811

724812
// This is specified as a simple map, however we're optimizing the path
725813
// where the list contains no Promises by avoiding creating another Promise.
726-
const itemType = returnType.ofType;
727814
let containsPromise = false;
728815
const completedResults = Array.from(result, (item, index) => {
729816
// No need to modify the info object containing the path,

0 commit comments

Comments
 (0)