Skip to content

Commit 3b52ba3

Browse files
committed
Support returning async iterables from resolver functions (#2712)
Co-authored-by: Ivan Goncharov <[email protected]> support async benchmark tests add benchmark tests for list fields add test for error from completeValue in AsyncIterable resolver change execute implementation for async iterable resolvers correctly handle promises returned by completeValue in async iterable resovlers
1 parent 61c2b01 commit 3b52ba3

File tree

6 files changed

+293
-5
lines changed

6 files changed

+293
-5
lines changed

benchmark/benchmark.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,9 @@ function sampleModule(modulePath) {
346346
347347
clock(7, module.measure); // warm up
348348
global.gc();
349-
process.nextTick(() => {
349+
process.nextTick(async () => {
350350
const memBaseline = process.memoryUsage().heapUsed;
351-
const clocked = clock(module.count, module.measure);
351+
const clocked = await clock(module.count, module.measure);
352352
process.send({
353353
name: module.name,
354354
clocked: clocked / module.count,
@@ -357,10 +357,10 @@ function sampleModule(modulePath) {
357357
});
358358
359359
// Clocks the time taken to execute a test per cycle (secs).
360-
function clock(count, fn) {
360+
async function clock(count, fn) {
361361
const start = process.hrtime.bigint();
362362
for (let i = 0; i < count; ++i) {
363-
fn();
363+
await fn();
364364
}
365365
return Number(process.hrtime.bigint() - start);
366366
}

benchmark/list-async-benchmark.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use strict';
2+
3+
const { parse } = require('graphql/language/parser.js');
4+
const { execute } = require('graphql/execution/execute.js');
5+
const { buildSchema } = require('graphql/utilities/buildASTSchema.js');
6+
7+
const schema = buildSchema('type Query { listField: [String] }');
8+
const document = parse('{ listField }');
9+
10+
function listField() {
11+
const results = [];
12+
for (let index = 0; index < 100000; index++) {
13+
results.push(Promise.resolve(index));
14+
}
15+
return results;
16+
}
17+
18+
module.exports = {
19+
name: 'Execute Asynchronous List Field',
20+
count: 10,
21+
async measure() {
22+
await execute({
23+
schema,
24+
document,
25+
rootValue: { listField },
26+
});
27+
},
28+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use strict';
2+
3+
const { parse } = require('graphql/language/parser.js');
4+
const { execute } = require('graphql/execution/execute.js');
5+
const { buildSchema } = require('graphql/utilities/buildASTSchema.js');
6+
7+
const schema = buildSchema('type Query { listField: [String] }');
8+
const document = parse('{ listField }');
9+
10+
async function* listField() {
11+
for (let index = 0; index < 100000; index++) {
12+
yield index;
13+
}
14+
}
15+
16+
module.exports = {
17+
name: 'Execute Async Iterable List Field',
18+
count: 10,
19+
async measure() {
20+
await execute({
21+
schema,
22+
document,
23+
rootValue: { listField },
24+
});
25+
},
26+
};

benchmark/list-sync-benchmark.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use strict';
2+
3+
const { parse } = require('graphql/language/parser.js');
4+
const { execute } = require('graphql/execution/execute.js');
5+
const { buildSchema } = require('graphql/utilities/buildASTSchema.js');
6+
7+
const schema = buildSchema('type Query { listField: [String] }');
8+
const document = parse('{ listField }');
9+
10+
function listField() {
11+
const results = [];
12+
for (let index = 0; index < 100000; index++) {
13+
results.push(index);
14+
}
15+
return results;
16+
}
17+
18+
module.exports = {
19+
name: 'Execute Synchronous List Field',
20+
count: 10,
21+
async measure() {
22+
await execute({
23+
schema,
24+
document,
25+
rootValue: { listField },
26+
});
27+
},
28+
};

src/execution/__tests__/lists-test.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33

44
import { parse } from '../../language/parser';
5+
import { GraphQLList, GraphQLObjectType } from '../../type/definition';
6+
import { GraphQLString } from '../../type/scalars';
7+
import { GraphQLSchema } from '../../type/schema';
58

69
import { buildSchema } from '../../utilities/buildASTSchema';
710

@@ -64,6 +67,125 @@ describe('Execute: Accepts any iterable as list value', () => {
6467
});
6568
});
6669

70+
describe('Execute: Accepts async iterables as list value', () => {
71+
function complete(rootValue: mixed) {
72+
return execute({
73+
schema: buildSchema('type Query { listField: [String] }'),
74+
document: parse('{ listField }'),
75+
rootValue,
76+
});
77+
}
78+
79+
function completeObjectList(resolve) {
80+
const schema = new GraphQLSchema({
81+
query: new GraphQLObjectType({
82+
name: 'Query',
83+
fields: {
84+
listField: {
85+
resolve: async function* listField() {
86+
yield await { index: 0 };
87+
yield await { index: 1 };
88+
yield await { index: 2 };
89+
},
90+
type: new GraphQLList(
91+
new GraphQLObjectType({
92+
name: 'ObjectWrapper',
93+
fields: {
94+
index: {
95+
type: GraphQLString,
96+
resolve,
97+
},
98+
},
99+
}),
100+
),
101+
},
102+
},
103+
}),
104+
});
105+
return execute({
106+
schema,
107+
document: parse('{ listField { index } }'),
108+
});
109+
}
110+
111+
it('Accepts an AsyncGenerator function as a List value', async () => {
112+
async function* listField() {
113+
yield await 'two';
114+
yield await 4;
115+
yield await false;
116+
}
117+
118+
expect(await complete({ listField })).to.deep.equal({
119+
data: { listField: ['two', '4', 'false'] },
120+
});
121+
});
122+
123+
it('Handles an AsyncGenerator function that throws', async () => {
124+
async function* listField() {
125+
yield await 'two';
126+
yield await 4;
127+
throw new Error('bad');
128+
}
129+
130+
expect(await complete({ listField })).to.deep.equal({
131+
data: { listField: ['two', '4', null] },
132+
errors: [
133+
{
134+
message: 'bad',
135+
locations: [{ line: 1, column: 3 }],
136+
path: ['listField', 2],
137+
},
138+
],
139+
});
140+
});
141+
142+
it('Handles errors from `completeValue` in AsyncIterables', async () => {
143+
async function* listField() {
144+
yield await 'two';
145+
yield await {};
146+
}
147+
148+
expect(await complete({ listField })).to.deep.equal({
149+
data: { listField: ['two', null] },
150+
errors: [
151+
{
152+
message: 'String cannot represent value: {}',
153+
locations: [{ line: 1, column: 3 }],
154+
path: ['listField', 1],
155+
},
156+
],
157+
});
158+
});
159+
160+
it('Handles promises from `completeValue` in AsyncIterables', async () => {
161+
expect(
162+
await completeObjectList(({ index }) => Promise.resolve(index)),
163+
).to.deep.equal({
164+
data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] },
165+
});
166+
});
167+
168+
it('Handles rejected promises from `completeValue` in AsyncIterables', async () => {
169+
expect(
170+
await completeObjectList(({ index }) => {
171+
if (index === 2) {
172+
return Promise.reject(new Error('bad'));
173+
}
174+
return Promise.resolve(index);
175+
}),
176+
).to.deep.equal({
177+
data: { listField: [{ index: '0' }, { index: '1' }, { index: null }] },
178+
errors: [
179+
{
180+
message: 'bad',
181+
locations: [{ line: 1, column: 15 }],
182+
path: ['listField', 2, 'index'],
183+
},
184+
],
185+
});
186+
});
187+
});
188+
67189
describe('Execute: Handles list nullability', () => {
68190
async function complete(args: {| listField: mixed, as: string |}) {
69191
const { listField, as } = args;

src/execution/execute.js

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import arrayFrom from '../polyfills/arrayFrom';
2+
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';
23

34
import type { Path } from '../jsutils/Path';
45
import type { ObjMap } from '../jsutils/ObjMap';
@@ -8,6 +9,7 @@ import memoize3 from '../jsutils/memoize3';
89
import invariant from '../jsutils/invariant';
910
import devAssert from '../jsutils/devAssert';
1011
import isPromise from '../jsutils/isPromise';
12+
import isAsyncIterable from '../jsutils/isAsyncIterable';
1113
import isObjectLike from '../jsutils/isObjectLike';
1214
import isCollection from '../jsutils/isCollection';
1315
import promiseReduce from '../jsutils/promiseReduce';
@@ -855,6 +857,74 @@ function completeValue(
855857
);
856858
}
857859

860+
/**
861+
* Complete a async iterator value by completing the result and calling
862+
* recursively until all the results are completed.
863+
*/
864+
function completeAsyncIteratorValue(
865+
exeContext: ExecutionContext,
866+
itemType: GraphQLOutputType,
867+
fieldNodes: $ReadOnlyArray<FieldNode>,
868+
info: GraphQLResolveInfo,
869+
path: Path,
870+
iterator: AsyncIterator<mixed>,
871+
): Promise<$ReadOnlyArray<mixed>> {
872+
let containsPromise = false;
873+
return new Promise((resolve) => {
874+
function next(index, completedResults) {
875+
const fieldPath = addPath(path, index, undefined);
876+
iterator.next().then(
877+
({ value, done }) => {
878+
if (done) {
879+
resolve(completedResults);
880+
return;
881+
}
882+
// TODO can the error checking logic be consolidated with completeListValue?
883+
try {
884+
const completedItem = completeValue(
885+
exeContext,
886+
itemType,
887+
fieldNodes,
888+
info,
889+
fieldPath,
890+
value,
891+
);
892+
if (isPromise(completedItem)) {
893+
containsPromise = true;
894+
}
895+
completedResults.push(completedItem);
896+
} catch (rawError) {
897+
completedResults.push(null);
898+
const error = locatedError(
899+
rawError,
900+
fieldNodes,
901+
pathToArray(fieldPath),
902+
);
903+
handleFieldError(error, itemType, exeContext);
904+
resolve(completedResults);
905+
return;
906+
}
907+
908+
next(index + 1, completedResults);
909+
},
910+
(rawError) => {
911+
completedResults.push(null);
912+
const error = locatedError(
913+
rawError,
914+
fieldNodes,
915+
pathToArray(fieldPath),
916+
);
917+
handleFieldError(error, itemType, exeContext);
918+
resolve(completedResults);
919+
},
920+
);
921+
}
922+
next(0, []);
923+
}).then((completedResults) =>
924+
containsPromise ? Promise.all(completedResults) : completedResults,
925+
);
926+
}
927+
858928
/**
859929
* Complete a list value by completing each item in the list with the
860930
* inner type
@@ -867,6 +937,21 @@ function completeListValue(
867937
path: Path,
868938
result: mixed,
869939
): PromiseOrValue<$ReadOnlyArray<mixed>> {
940+
const itemType = returnType.ofType;
941+
942+
if (isAsyncIterable(result)) {
943+
const iterator = result[SYMBOL_ASYNC_ITERATOR]();
944+
945+
return completeAsyncIteratorValue(
946+
exeContext,
947+
itemType,
948+
fieldNodes,
949+
info,
950+
path,
951+
iterator,
952+
);
953+
}
954+
870955
if (!isCollection(result)) {
871956
throw new GraphQLError(
872957
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -875,7 +960,6 @@ function completeListValue(
875960

876961
// This is specified as a simple map, however we're optimizing the path
877962
// where the list contains no Promises by avoiding creating another Promise.
878-
const itemType = returnType.ofType;
879963
let containsPromise = false;
880964
const completedResults = arrayFrom(result, (item, index) => {
881965
// No need to modify the info object containing the path,

0 commit comments

Comments
 (0)