Skip to content

Commit 46c601d

Browse files
committed
Support returning async iterables from resolver functions
Support returning async iterables from resolver functions
1 parent 5681b42 commit 46c601d

File tree

2 files changed

+229
-1
lines changed

2 files changed

+229
-1
lines changed

src/execution/__tests__/lists-test.ts

Lines changed: 146 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,146 @@ 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) {
77+
return execute({
78+
schema: buildSchema('type Query { listField: [String] }'),
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+
});
214+
69215
describe('Execute: Handles list nullability', () => {
70216
async function complete(args: { listField: unknown; as: string }) {
71217
const { listField, as } = args;

src/execution/execute.ts

Lines changed: 83 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';
@@ -695,6 +696,73 @@ function completeValue(
695696
);
696697
}
697698

699+
/**
700+
* Complete a async iterator value by completing the result and calling
701+
* recursively until all the results are completed.
702+
*/
703+
function completeAsyncIteratorValue(
704+
exeContext: ExecutionContext,
705+
itemType: GraphQLOutputType,
706+
fieldNodes: ReadonlyArray<FieldNode>,
707+
info: GraphQLResolveInfo,
708+
path: Path,
709+
iterator: AsyncIterator<unknown>,
710+
): Promise<ReadonlyArray<unknown>> {
711+
let containsPromise = false;
712+
return new Promise<ReadonlyArray<unknown>>((resolve) => {
713+
function next(index: number, completedResults: Array<unknown>) {
714+
const fieldPath = addPath(path, index, undefined);
715+
iterator.next().then(
716+
({ value, done }) => {
717+
if (done) {
718+
resolve(completedResults);
719+
return;
720+
}
721+
// TODO can the error checking logic be consolidated with completeListValue?
722+
try {
723+
const completedItem = completeValue(
724+
exeContext,
725+
itemType,
726+
fieldNodes,
727+
info,
728+
fieldPath,
729+
value,
730+
);
731+
if (isPromise(completedItem)) {
732+
containsPromise = true;
733+
}
734+
completedResults.push(completedItem);
735+
} catch (rawError) {
736+
completedResults.push(null);
737+
const error = locatedError(
738+
rawError,
739+
fieldNodes,
740+
pathToArray(fieldPath),
741+
);
742+
handleFieldError(error, itemType, exeContext);
743+
resolve(completedResults);
744+
}
745+
746+
next(index + 1, completedResults);
747+
},
748+
(rawError) => {
749+
completedResults.push(null);
750+
const error = locatedError(
751+
rawError,
752+
fieldNodes,
753+
pathToArray(fieldPath),
754+
);
755+
handleFieldError(error, itemType, exeContext);
756+
resolve(completedResults);
757+
},
758+
);
759+
}
760+
next(0, []);
761+
}).then((completedResults) =>
762+
containsPromise ? Promise.all(completedResults) : completedResults,
763+
);
764+
}
765+
698766
/**
699767
* Complete a list value by completing each item in the list with the
700768
* inner type
@@ -707,6 +775,21 @@ function completeListValue(
707775
path: Path,
708776
result: unknown,
709777
): 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+
710793
if (!isIterableObject(result)) {
711794
throw new GraphQLError(
712795
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -715,7 +798,6 @@ function completeListValue(
715798

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

0 commit comments

Comments
 (0)