Skip to content

Commit 24a64b0

Browse files
committed
Support returning async iterables from resolver functions
Support returning async iterables from resolver functions
1 parent 6192e2e commit 24a64b0

File tree

2 files changed

+252
-1
lines changed

2 files changed

+252
-1
lines changed

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: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { devAssert } from '../jsutils/devAssert';
22
import { inspect } from '../jsutils/inspect';
33
import { invariant } from '../jsutils/invariant';
4+
import { isAsyncIterable } from '../jsutils/isAsyncIterable';
45
import { isIterableObject } from '../jsutils/isIterableObject';
56
import { isObjectLike } from '../jsutils/isObjectLike';
67
import { isPromise } from '../jsutils/isPromise';
@@ -703,6 +704,65 @@ 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+
async 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+
const completedResults = [];
721+
let index = 0;
722+
// eslint-disable-next-line no-constant-condition
723+
while (true) {
724+
const fieldPath = addPath(path, index, undefined);
725+
try {
726+
// eslint-disable-next-line no-await-in-loop
727+
const { value, done } = await iterator.next();
728+
if (done) {
729+
break;
730+
}
731+
732+
try {
733+
// TODO can the error checking logic be consolidated with completeListValue?
734+
const completedItem = completeValue(
735+
exeContext,
736+
itemType,
737+
fieldNodes,
738+
info,
739+
fieldPath,
740+
value,
741+
);
742+
if (isPromise(completedItem)) {
743+
containsPromise = true;
744+
}
745+
completedResults.push(completedItem);
746+
} catch (rawError) {
747+
completedResults.push(null);
748+
const error = locatedError(
749+
rawError,
750+
fieldNodes,
751+
pathToArray(fieldPath),
752+
);
753+
handleFieldError(error, itemType, exeContext);
754+
}
755+
} catch (rawError) {
756+
completedResults.push(null);
757+
const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath));
758+
handleFieldError(error, itemType, exeContext);
759+
break;
760+
}
761+
index += 1;
762+
}
763+
return containsPromise ? Promise.all(completedResults) : completedResults;
764+
}
765+
706766
/**
707767
* Complete a list value by completing each item in the list with the
708768
* inner type
@@ -715,6 +775,21 @@ function completeListValue(
715775
path: Path,
716776
result: unknown,
717777
): PromiseOrValue<ReadonlyArray<unknown>> {
778+
const itemType = returnType.ofType;
779+
780+
if (isAsyncIterable(result)) {
781+
const iterator = result[Symbol.asyncIterator]();
782+
783+
return completeAsyncIteratorValue(
784+
exeContext,
785+
itemType,
786+
fieldNodes,
787+
info,
788+
path,
789+
iterator,
790+
);
791+
}
792+
718793
if (!isIterableObject(result)) {
719794
throw new GraphQLError(
720795
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -723,7 +798,6 @@ function completeListValue(
723798

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

0 commit comments

Comments
 (0)