Skip to content

Commit 665e256

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 797094b commit 665e256

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
@@ -347,9 +347,9 @@ function sampleModule(modulePath) {
347347
348348
clock(7, module.measure); // warm up
349349
global.gc();
350-
process.nextTick(() => {
350+
process.nextTick(async () => {
351351
const memBaseline = process.memoryUsage().heapUsed;
352-
const clocked = clock(module.count, module.measure);
352+
const clocked = await clock(module.count, module.measure);
353353
process.send({
354354
name: module.name,
355355
clocked: clocked / module.count,
@@ -358,10 +358,10 @@ function sampleModule(modulePath) {
358358
});
359359
360360
// Clocks the time taken to execute a test per cycle (secs).
361-
function clock(count, fn) {
361+
async function clock(count, fn) {
362362
const start = process.hrtime.bigint();
363363
for (let i = 0; i < count; ++i) {
364-
fn();
364+
await fn();
365365
}
366366
return Number(process.hrtime.bigint() - start);
367367
}

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';
@@ -811,6 +813,74 @@ function completeValue(
811813
);
812814
}
813815

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<mixed>,
827+
): Promise<$ReadOnlyArray<mixed>> {
828+
let containsPromise = false;
829+
return new Promise((resolve) => {
830+
function next(index, completedResults) {
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+
return;
862+
}
863+
864+
next(index + 1, completedResults);
865+
},
866+
(rawError) => {
867+
completedResults.push(null);
868+
const error = locatedError(
869+
rawError,
870+
fieldNodes,
871+
pathToArray(fieldPath),
872+
);
873+
handleFieldError(error, itemType, exeContext);
874+
resolve(completedResults);
875+
},
876+
);
877+
}
878+
next(0, []);
879+
}).then((completedResults) =>
880+
containsPromise ? Promise.all(completedResults) : completedResults,
881+
);
882+
}
883+
814884
/**
815885
* Complete a list value by completing each item in the list with the
816886
* inner type
@@ -823,6 +893,21 @@ function completeListValue(
823893
path: Path,
824894
result: mixed,
825895
): PromiseOrValue<$ReadOnlyArray<mixed>> {
896+
const itemType = returnType.ofType;
897+
898+
if (isAsyncIterable(result)) {
899+
const iterator = result[SYMBOL_ASYNC_ITERATOR]();
900+
901+
return completeAsyncIteratorValue(
902+
exeContext,
903+
itemType,
904+
fieldNodes,
905+
info,
906+
path,
907+
iterator,
908+
);
909+
}
910+
826911
if (!isCollection(result)) {
827912
throw new GraphQLError(
828913
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
@@ -831,7 +916,6 @@ function completeListValue(
831916

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

0 commit comments

Comments
 (0)