Skip to content

Commit 34fbe0b

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

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
@@ -706,6 +706,65 @@ function completeValue(
706706
);
707707
}
708708

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

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

0 commit comments

Comments
 (0)