Skip to content

Commit a2707bb

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 bb58eb9 commit a2707bb

File tree

6 files changed

+292
-5
lines changed

6 files changed

+292
-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: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { memoize3 } from '../jsutils/memoize3';
66
import { invariant } from '../jsutils/invariant';
77
import { devAssert } from '../jsutils/devAssert';
88
import { isPromise } from '../jsutils/isPromise';
9+
import { isAsyncIterable } from '../jsutils/isAsyncIterable';
910
import { isObjectLike } from '../jsutils/isObjectLike';
1011
import { promiseReduce } from '../jsutils/promiseReduce';
1112
import { promiseForObject } from '../jsutils/promiseForObject';
@@ -809,6 +810,74 @@ function completeValue(
809810
);
810811
}
811812

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

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

0 commit comments

Comments
 (0)