Skip to content

Commit ee6cef7

Browse files
committed
Support returning async iterables from resolver functions
Support returning async iterables from resolver functions
1 parent 727c9de commit ee6cef7

File tree

2 files changed

+230
-1
lines changed

2 files changed

+230
-1
lines changed

src/execution/__tests__/lists-test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33

4+
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue';
5+
46
import { parse } from '../../language/parser';
7+
import type { GraphQLFieldResolver } from '../../type/definition';
8+
import { GraphQLList, GraphQLObjectType } from '../../type/definition';
9+
import { GraphQLString } from '../../type/scalars';
10+
import { GraphQLSchema } from '../../type/schema';
511

612
import { buildSchema } from '../../utilities/buildASTSchema';
713

14+
import type { ExecutionResult } from '../execute';
815
import { execute, executeSync } from '../execute';
916

1017
describe('Execute: Accepts any iterable as list value', () => {
@@ -64,6 +71,146 @@ describe('Execute: Accepts any iterable as list value', () => {
6471
});
6572
});
6673

74+
describe('Execute: Accepts async iterables as list value', () => {
75+
function complete(rootValue: unknown) {
76+
return execute({
77+
schema: buildSchema('type Query { listField: [String] }'),
78+
document: parse('{ listField }'),
79+
rootValue,
80+
});
81+
}
82+
83+
function completeObjectList(
84+
resolve: GraphQLFieldResolver<{ index: number }, unknown>,
85+
): PromiseOrValue<ExecutionResult> {
86+
const schema = new GraphQLSchema({
87+
query: new GraphQLObjectType({
88+
name: 'Query',
89+
fields: {
90+
listField: {
91+
resolve: async function* listField() {
92+
yield await Promise.resolve({ index: 0 });
93+
yield await Promise.resolve({ index: 1 });
94+
yield await Promise.resolve({ index: 2 });
95+
},
96+
type: new GraphQLList(
97+
new GraphQLObjectType({
98+
name: 'ObjectWrapper',
99+
fields: {
100+
index: {
101+
type: GraphQLString,
102+
resolve,
103+
},
104+
},
105+
}),
106+
),
107+
},
108+
},
109+
}),
110+
});
111+
return execute({
112+
schema,
113+
document: parse('{ listField { index } }'),
114+
});
115+
}
116+
117+
it('Accepts an AsyncGenerator function as a List value', async () => {
118+
async function* listField() {
119+
yield await Promise.resolve('two');
120+
yield await Promise.resolve(4);
121+
yield await Promise.resolve(false);
122+
}
123+
124+
expect(await complete({ listField })).to.deep.equal({
125+
data: { listField: ['two', '4', 'false'] },
126+
});
127+
});
128+
129+
it('Handles an AsyncGenerator function that throws', async () => {
130+
async function* listField() {
131+
yield await Promise.resolve('two');
132+
yield await Promise.resolve(4);
133+
throw new Error('bad');
134+
}
135+
136+
expect(await complete({ listField })).to.deep.equal({
137+
data: { listField: ['two', '4', null] },
138+
errors: [
139+
{
140+
message: 'bad',
141+
locations: [{ line: 1, column: 3 }],
142+
path: ['listField', 2],
143+
},
144+
],
145+
});
146+
});
147+
148+
it('Handles an AsyncGenerator function where an intermediate value triggers an error', async () => {
149+
async function* listField() {
150+
yield await Promise.resolve('two');
151+
yield await Promise.resolve({});
152+
yield await Promise.resolve(4);
153+
}
154+
155+
expect(await complete({ listField })).to.deep.equal({
156+
data: { listField: ['two', null, '4'] },
157+
errors: [
158+
{
159+
message: 'String cannot represent value: {}',
160+
locations: [{ line: 1, column: 3 }],
161+
path: ['listField', 1],
162+
},
163+
],
164+
});
165+
});
166+
167+
it('Handles errors from `completeValue` in AsyncIterables', async () => {
168+
async function* listField() {
169+
yield await Promise.resolve('two');
170+
yield await Promise.resolve({});
171+
}
172+
173+
expect(await complete({ listField })).to.deep.equal({
174+
data: { listField: ['two', null] },
175+
errors: [
176+
{
177+
message: 'String cannot represent value: {}',
178+
locations: [{ line: 1, column: 3 }],
179+
path: ['listField', 1],
180+
},
181+
],
182+
});
183+
});
184+
185+
it('Handles promises from `completeValue` in AsyncIterables', async () => {
186+
expect(
187+
await completeObjectList(({ index }) => Promise.resolve(index)),
188+
).to.deep.equal({
189+
data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] },
190+
});
191+
});
192+
193+
it('Handles rejected promises from `completeValue` in AsyncIterables', async () => {
194+
expect(
195+
await completeObjectList(({ index }) => {
196+
if (index === 2) {
197+
return Promise.reject(new Error('bad'));
198+
}
199+
return Promise.resolve(index);
200+
}),
201+
).to.deep.equal({
202+
data: { listField: [{ index: '0' }, { index: '1' }, { index: null }] },
203+
errors: [
204+
{
205+
message: 'bad',
206+
locations: [{ line: 1, column: 15 }],
207+
path: ['listField', 2, 'index'],
208+
},
209+
],
210+
});
211+
});
212+
});
213+
67214
describe('Execute: Handles list nullability', () => {
68215
async function complete(args: { listField: unknown; as: string }) {
69216
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/formatError';
1718
import { GraphQLError } from '../error/GraphQLError';
@@ -812,6 +813,73 @@ function completeValue(
812813
);
813814
}
814815

816+
/**
817+
* Complete a async iterator value by completing the result and calling
818+
* recursively until all the results are completed.
819+
*/
820+
function completeAsyncIteratorValue(
821+
exeContext: ExecutionContext,
822+
itemType: GraphQLOutputType,
823+
fieldNodes: ReadonlyArray<FieldNode>,
824+
info: GraphQLResolveInfo,
825+
path: Path,
826+
iterator: AsyncIterator<unknown>,
827+
): Promise<ReadonlyArray<unknown>> {
828+
let containsPromise = false;
829+
return new Promise<ReadonlyArray<unknown>>((resolve) => {
830+
function next(index: number, completedResults: Array<unknown>) {
831+
const fieldPath = addPath(path, index, undefined);
832+
iterator.next().then(
833+
({ value, done }) => {
834+
if (done) {
835+
resolve(completedResults);
836+
return;
837+
}
838+
// TODO can the error checking logic be consolidated with completeListValue?
839+
try {
840+
const completedItem = completeValue(
841+
exeContext,
842+
itemType,
843+
fieldNodes,
844+
info,
845+
fieldPath,
846+
value,
847+
);
848+
if (isPromise(completedItem)) {
849+
containsPromise = true;
850+
}
851+
completedResults.push(completedItem);
852+
} catch (rawError) {
853+
completedResults.push(null);
854+
const error = locatedError(
855+
rawError,
856+
fieldNodes,
857+
pathToArray(fieldPath),
858+
);
859+
handleFieldError(error, itemType, exeContext);
860+
resolve(completedResults);
861+
}
862+
863+
next(index + 1, completedResults);
864+
},
865+
(rawError) => {
866+
completedResults.push(null);
867+
const error = locatedError(
868+
rawError,
869+
fieldNodes,
870+
pathToArray(fieldPath),
871+
);
872+
handleFieldError(error, itemType, exeContext);
873+
resolve(completedResults);
874+
},
875+
);
876+
}
877+
next(0, []);
878+
}).then((completedResults) =>
879+
containsPromise ? Promise.all(completedResults) : completedResults,
880+
);
881+
}
882+
815883
/**
816884
* Complete a list value by completing each item in the list with the
817885
* inner type
@@ -824,6 +892,21 @@ function completeListValue(
824892
path: Path,
825893
result: unknown,
826894
): PromiseOrValue<ReadonlyArray<unknown>> {
895+
const itemType = returnType.ofType;
896+
897+
if (isAsyncIterable(result)) {
898+
const iterator = result[Symbol.asyncIterator]();
899+
900+
return completeAsyncIteratorValue(
901+
exeContext,
902+
itemType,
903+
fieldNodes,
904+
info,
905+
path,
906+
iterator,
907+
);
908+
}
909+
827910
if (!isIterableObject(result)) {
828911
throw new GraphQLError(
829912
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -832,7 +915,6 @@ function completeListValue(
832915

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

0 commit comments

Comments
 (0)