Skip to content

Commit 85c257f

Browse files
IvanGoncharovrobrichard
authored andcommitted
Support returning async iterables from resolver functions (graphql#2757)
Co-authored-by: Rob Richard <[email protected]>
1 parent e4f759d commit 85c257f

File tree

3 files changed

+275
-1
lines changed

3 files changed

+275
-1
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { execute } from 'graphql/execution/execute.js';
2+
import { parse } from 'graphql/language/parser.js';
3+
import { buildSchema } from 'graphql/utilities/buildASTSchema.js';
4+
5+
const schema = buildSchema('type Query { listField: [String] }');
6+
const document = parse('{ listField }');
7+
8+
async function* listField() {
9+
for (let index = 0; index < 1000; index++) {
10+
yield index;
11+
}
12+
}
13+
14+
export const benchmark = {
15+
name: 'Execute Async Iterable List Field',
16+
count: 10,
17+
async measure() {
18+
await execute({
19+
schema,
20+
document,
21+
rootValue: { listField },
22+
});
23+
},
24+
};

src/execution/__tests__/lists-test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ import { describe, it } from 'mocha';
33

44
import { expectJSON } from '../../__testUtils__/expectJSON';
55

6+
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue';
7+
68
import { parse } from '../../language/parser';
79

10+
import type { GraphQLFieldResolver } from '../../type/definition';
11+
import { GraphQLList, GraphQLObjectType } from '../../type/definition';
12+
import { GraphQLString } from '../../type/scalars';
13+
import { GraphQLSchema } from '../../type/schema';
14+
815
import { buildSchema } from '../../utilities/buildASTSchema';
916

17+
import type { ExecutionResult } from '../execute';
1018
import { execute, executeSync } from '../execute';
1119

1220
describe('Execute: Accepts any iterable as list value', () => {
@@ -66,6 +74,175 @@ describe('Execute: Accepts any iterable as list value', () => {
6674
});
6775
});
6876

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

src/execution/execute.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,65 @@ function completeValue(
713713
);
714714
}
715715

716+
/**
717+
* Complete a async iterator value by completing the result and calling
718+
* recursively until all the results are completed.
719+
*/
720+
async function completeAsyncIteratorValue(
721+
exeContext: ExecutionContext,
722+
itemType: GraphQLOutputType,
723+
fieldNodes: ReadonlyArray<FieldNode>,
724+
info: GraphQLResolveInfo,
725+
path: Path,
726+
iterator: AsyncIterator<unknown>,
727+
): Promise<ReadonlyArray<unknown>> {
728+
let containsPromise = false;
729+
const completedResults = [];
730+
let index = 0;
731+
// eslint-disable-next-line no-constant-condition
732+
while (true) {
733+
const fieldPath = addPath(path, index, undefined);
734+
try {
735+
// eslint-disable-next-line no-await-in-loop
736+
const { value, done } = await iterator.next();
737+
if (done) {
738+
break;
739+
}
740+
741+
try {
742+
// TODO can the error checking logic be consolidated with completeListValue?
743+
const completedItem = completeValue(
744+
exeContext,
745+
itemType,
746+
fieldNodes,
747+
info,
748+
fieldPath,
749+
value,
750+
);
751+
if (isPromise(completedItem)) {
752+
containsPromise = true;
753+
}
754+
completedResults.push(completedItem);
755+
} catch (rawError) {
756+
completedResults.push(null);
757+
const error = locatedError(
758+
rawError,
759+
fieldNodes,
760+
pathToArray(fieldPath),
761+
);
762+
handleFieldError(error, itemType, exeContext);
763+
}
764+
} catch (rawError) {
765+
completedResults.push(null);
766+
const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath));
767+
handleFieldError(error, itemType, exeContext);
768+
break;
769+
}
770+
index += 1;
771+
}
772+
return containsPromise ? Promise.all(completedResults) : completedResults;
773+
}
774+
716775
/**
717776
* Complete a list value by completing each item in the list with the
718777
* inner type
@@ -725,6 +784,21 @@ function completeListValue(
725784
path: Path,
726785
result: unknown,
727786
): PromiseOrValue<ReadonlyArray<unknown>> {
787+
const itemType = returnType.ofType;
788+
789+
if (isAsyncIterable(result)) {
790+
const iterator = result[Symbol.asyncIterator]();
791+
792+
return completeAsyncIteratorValue(
793+
exeContext,
794+
itemType,
795+
fieldNodes,
796+
info,
797+
path,
798+
iterator,
799+
);
800+
}
801+
728802
if (!isIterableObject(result)) {
729803
throw new GraphQLError(
730804
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -733,7 +807,6 @@ function completeListValue(
733807

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

0 commit comments

Comments
 (0)