Skip to content

Commit 59c87c3

Browse files
Support returning async iterables from resolver functions (#2757)
Co-authored-by: Rob Richard <[email protected]>
1 parent 51537f9 commit 59c87c3

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
@@ -691,6 +691,65 @@ function completeValue(
691691
);
692692
}
693693

694+
/**
695+
* Complete a async iterator value by completing the result and calling
696+
* recursively until all the results are completed.
697+
*/
698+
async function completeAsyncIteratorValue(
699+
exeContext: ExecutionContext,
700+
itemType: GraphQLOutputType,
701+
fieldNodes: ReadonlyArray<FieldNode>,
702+
info: GraphQLResolveInfo,
703+
path: Path,
704+
iterator: AsyncIterator<unknown>,
705+
): Promise<ReadonlyArray<unknown>> {
706+
let containsPromise = false;
707+
const completedResults = [];
708+
let index = 0;
709+
// eslint-disable-next-line no-constant-condition
710+
while (true) {
711+
const fieldPath = addPath(path, index, undefined);
712+
try {
713+
// eslint-disable-next-line no-await-in-loop
714+
const { value, done } = await iterator.next();
715+
if (done) {
716+
break;
717+
}
718+
719+
try {
720+
// TODO can the error checking logic be consolidated with completeListValue?
721+
const completedItem = completeValue(
722+
exeContext,
723+
itemType,
724+
fieldNodes,
725+
info,
726+
fieldPath,
727+
value,
728+
);
729+
if (isPromise(completedItem)) {
730+
containsPromise = true;
731+
}
732+
completedResults.push(completedItem);
733+
} catch (rawError) {
734+
completedResults.push(null);
735+
const error = locatedError(
736+
rawError,
737+
fieldNodes,
738+
pathToArray(fieldPath),
739+
);
740+
handleFieldError(error, itemType, exeContext);
741+
}
742+
} catch (rawError) {
743+
completedResults.push(null);
744+
const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath));
745+
handleFieldError(error, itemType, exeContext);
746+
break;
747+
}
748+
index += 1;
749+
}
750+
return containsPromise ? Promise.all(completedResults) : completedResults;
751+
}
752+
694753
/**
695754
* Complete a list value by completing each item in the list with the
696755
* inner type
@@ -703,6 +762,21 @@ function completeListValue(
703762
path: Path,
704763
result: unknown,
705764
): PromiseOrValue<ReadonlyArray<unknown>> {
765+
const itemType = returnType.ofType;
766+
767+
if (isAsyncIterable(result)) {
768+
const iterator = result[Symbol.asyncIterator]();
769+
770+
return completeAsyncIteratorValue(
771+
exeContext,
772+
itemType,
773+
fieldNodes,
774+
info,
775+
path,
776+
iterator,
777+
);
778+
}
779+
706780
if (!isIterableObject(result)) {
707781
throw new GraphQLError(
708782
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -711,7 +785,6 @@ function completeListValue(
711785

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

0 commit comments

Comments
 (0)