Skip to content

Commit 39a4c55

Browse files
committed
Support returning async iterables from resolver functions
1 parent 6012d28 commit 39a4c55

File tree

6 files changed

+194
-18
lines changed

6 files changed

+194
-18
lines changed

src/__tests__/starWarsIntrospection-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe('Star Wars Introspection Tests', () => {
3636
{ name: 'Character' },
3737
{ name: 'String' },
3838
{ name: 'Episode' },
39+
{ name: 'Int' },
3940
{ name: 'Droid' },
4041
{ name: 'Query' },
4142
{ name: 'Boolean' },

src/__tests__/starWarsQuery-test.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,47 @@ describe('Star Wars Query Tests', () => {
8686
});
8787
});
8888

89+
describe('Async Fields', () => {
90+
it('Allows us to query lists that are resolved by async iterators', async () => {
91+
const source = `
92+
query AsyncIterableQuery {
93+
human(id: "1003") {
94+
friendsAsync {
95+
id
96+
name
97+
}
98+
}
99+
}
100+
`;
101+
102+
const result = await graphql({ schema, source });
103+
expect(result).to.deep.equal({
104+
data: {
105+
human: {
106+
friendsAsync: [
107+
{
108+
id: '1000',
109+
name: 'Luke Skywalker',
110+
},
111+
{
112+
id: '1002',
113+
name: 'Han Solo',
114+
},
115+
{
116+
id: '2000',
117+
name: 'C-3PO',
118+
},
119+
{
120+
id: '2001',
121+
name: 'R2-D2',
122+
},
123+
],
124+
},
125+
},
126+
});
127+
});
128+
});
129+
89130
describe('Nested Queries', () => {
90131
it('Allows us to query for the friends of friends of R2-D2', async () => {
91132
const source = `
@@ -515,5 +556,48 @@ describe('Star Wars Query Tests', () => {
515556
],
516557
});
517558
});
559+
560+
it('Correctly reports errors raised in an async iterator', async () => {
561+
const source = `
562+
query HumanFriendsQuery {
563+
human(id: "1003") {
564+
friendsAsync(errorIndex: 2) {
565+
id
566+
name
567+
}
568+
}
569+
}
570+
`;
571+
572+
const result = await graphql({ schema, source });
573+
expect(result).to.deep.equal({
574+
errors: [
575+
{
576+
message: 'uh oh',
577+
locations: [
578+
{
579+
line: 4,
580+
column: 13,
581+
},
582+
],
583+
path: ['human', 'friendsAsync', 2],
584+
},
585+
],
586+
data: {
587+
human: {
588+
friendsAsync: [
589+
{
590+
id: '1000',
591+
name: 'Luke Skywalker',
592+
},
593+
{
594+
id: '1002',
595+
name: 'Han Solo',
596+
},
597+
],
598+
},
599+
},
600+
});
601+
});
518602
});
519603
});

src/__tests__/starWarsSchema.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import invariant from '../jsutils/invariant';
44

55
import { GraphQLSchema } from '../type/schema';
6-
import { GraphQLString } from '../type/scalars';
6+
import { GraphQLString, GraphQLInt } from '../type/scalars';
77
import {
88
GraphQLList,
99
GraphQLNonNull,
@@ -170,6 +170,29 @@ const humanType = new GraphQLObjectType({
170170
'The friends of the human, or an empty list if they have none.',
171171
resolve: (human) => getFriends(human),
172172
},
173+
friendsAsync: {
174+
type: GraphQLList(characterInterface),
175+
description:
176+
'The friends of the droid, or an empty list if they have none. Returns an AsyncIterable',
177+
args: {
178+
errorIndex: { type: GraphQLInt },
179+
},
180+
async *resolve(droid, { errorIndex }) {
181+
const friends = getFriends(droid);
182+
let i = 0;
183+
for (const friend of friends) {
184+
// eslint-disable-next-line no-await-in-loop
185+
await new Promise((r) => setTimeout(r, 1));
186+
if (i === errorIndex) {
187+
throw new Error('uh oh');
188+
}
189+
yield friend;
190+
i++;
191+
}
192+
// close iterator asynchronously
193+
await new Promise((r) => setTimeout(r, 1));
194+
},
195+
},
173196
appearsIn: {
174197
type: GraphQLList(episodeEnum),
175198
description: 'Which movies they appear in.',

src/execution/execute.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @flow strict
22

33
import arrayFrom from '../polyfills/arrayFrom';
4+
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';
45

56
import type { Path } from '../jsutils/Path';
67
import type { ObjMap } from '../jsutils/ObjMap';
@@ -10,6 +11,7 @@ import memoize3 from '../jsutils/memoize3';
1011
import invariant from '../jsutils/invariant';
1112
import devAssert from '../jsutils/devAssert';
1213
import isPromise from '../jsutils/isPromise';
14+
import isAsyncIterable from '../jsutils/isAsyncIterable';
1315
import isObjectLike from '../jsutils/isObjectLike';
1416
import isCollection from '../jsutils/isCollection';
1517
import promiseReduce from '../jsutils/promiseReduce';
@@ -916,6 +918,56 @@ function completeValue(
916918
);
917919
}
918920

921+
/**
922+
* Complete a async iterable value by completing each item in the list with
923+
* the inner type
924+
*/
925+
926+
function completeAsyncIterableValue(
927+
exeContext: ExecutionContext,
928+
returnType: GraphQLList<GraphQLOutputType>,
929+
fieldNodes: $ReadOnlyArray<FieldNode>,
930+
info: GraphQLResolveInfo,
931+
path: Path,
932+
result: AsyncIterable<mixed>,
933+
): Promise<$ReadOnlyArray<mixed>> {
934+
// $FlowFixMe
935+
const iteratorMethod = result[SYMBOL_ASYNC_ITERATOR];
936+
const iterator = iteratorMethod.call(result);
937+
938+
const completedResults = [];
939+
let index = 0;
940+
941+
const itemType = returnType.ofType;
942+
943+
const handleNext = () => {
944+
const fieldPath = addPath(path, index);
945+
return iterator.next().then(
946+
({ value, done }) => {
947+
if (done) {
948+
return;
949+
}
950+
completedResults.push(
951+
completeValue(
952+
exeContext,
953+
itemType,
954+
fieldNodes,
955+
info,
956+
fieldPath,
957+
value,
958+
),
959+
);
960+
index++;
961+
return handleNext();
962+
},
963+
(error) =>
964+
handleFieldError(error, fieldNodes, fieldPath, itemType, exeContext),
965+
);
966+
};
967+
968+
return handleNext().then(() => completedResults);
969+
}
970+
919971
/**
920972
* Complete a list value by completing each item in the list with the
921973
* inner type
@@ -928,6 +980,17 @@ function completeListValue(
928980
path: Path,
929981
result: mixed,
930982
): PromiseOrValue<$ReadOnlyArray<mixed>> {
983+
if (isAsyncIterable(result)) {
984+
return completeAsyncIterableValue(
985+
exeContext,
986+
returnType,
987+
fieldNodes,
988+
info,
989+
path,
990+
result,
991+
);
992+
}
993+
931994
if (!isCollection(result)) {
932995
throw new GraphQLError(
933996
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,

src/jsutils/isAsyncIterable.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// @flow strict
2+
3+
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';
4+
5+
/**
6+
* Returns true if the provided object implements the AsyncIterator protocol via
7+
* either implementing a `Symbol.asyncIterator` or `"@@asyncIterator"` method.
8+
*/
9+
declare function isAsyncIterable(value: mixed): boolean %checks(value instanceof
10+
AsyncIterable);
11+
12+
// eslint-disable-next-line no-redeclare
13+
export default function isAsyncIterable(maybeAsyncIterable) {
14+
if (maybeAsyncIterable == null || typeof maybeAsyncIterable !== 'object') {
15+
return false;
16+
}
17+
18+
return typeof maybeAsyncIterable[SYMBOL_ASYNC_ITERATOR] === 'function';
19+
}

src/subscription/subscribe.js

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
// @flow strict
22

3-
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';
4-
53
import inspect from '../jsutils/inspect';
4+
import isAsyncIterable from '../jsutils/isAsyncIterable';
65
import { addPath, pathToArray } from '../jsutils/Path';
76

87
import { GraphQLError } from '../error/GraphQLError';
@@ -160,7 +159,7 @@ function subscribeImpl(
160159
// Note: Flow can't refine isAsyncIterable, so explicit casts are used.
161160
isAsyncIterable(resultOrStream)
162161
? mapAsyncIterator(
163-
((resultOrStream: any): AsyncIterable<mixed>),
162+
resultOrStream,
164163
mapSourceToResponse,
165164
reportGraphQLError,
166165
)
@@ -280,8 +279,7 @@ export function createSourceEventStream(
280279

281280
// Assert field returned an event stream, otherwise yield an error.
282281
if (isAsyncIterable(eventStream)) {
283-
// Note: isAsyncIterable above ensures this will be correct.
284-
return ((eventStream: any): AsyncIterable<mixed>);
282+
return eventStream;
285283
}
286284

287285
throw new Error(
@@ -298,15 +296,3 @@ export function createSourceEventStream(
298296
: Promise.reject(error);
299297
}
300298
}
301-
302-
/**
303-
* Returns true if the provided object implements the AsyncIterator protocol via
304-
* either implementing a `Symbol.asyncIterator` or `"@@asyncIterator"` method.
305-
*/
306-
function isAsyncIterable(maybeAsyncIterable: mixed): boolean {
307-
if (maybeAsyncIterable == null || typeof maybeAsyncIterable !== 'object') {
308-
return false;
309-
}
310-
311-
return typeof maybeAsyncIterable[SYMBOL_ASYNC_ITERATOR] === 'function';
312-
}

0 commit comments

Comments
 (0)