Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support returning async iterables from resolver functions #2712

Merged
merged 2 commits into from
Aug 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/execution/__tests__/lists-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,47 @@ describe('Execute: Accepts any iterable as list value', () => {
});
});

describe('Execute: Accepts async iterables as list value', () => {
function complete(rootValue: mixed) {
return execute({
schema: buildSchema('type Query { listField: [String] }'),
document: parse('{ listField }'),
rootValue,
});
}

it('Accepts an AsyncGenerator function as a List value', async () => {
async function* listField() {
yield await 'two';
yield await 4;
yield await false;
}

expect(await complete({ listField })).to.deep.equal({
data: { listField: ['two', '4', 'false'] },
});
});

it('Handles an AsyncGenerator function that throws', async () => {
async function* listField() {
yield await 'two';
yield await 4;
throw new Error('bad');
}

expect(await complete({ listField })).to.deep.equal({
data: { listField: ['two', '4', null] },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField', 2],
},
],
});
});
});

describe('Execute: Handles list nullability', () => {
async function complete({ listField, as }) {
const schema = buildSchema(`type Query { listField: ${as} }`);
Expand Down
62 changes: 61 additions & 1 deletion src/execution/execute.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import arrayFrom from '../polyfills/arrayFrom';
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';

import type { Path } from '../jsutils/Path';
import type { ObjMap } from '../jsutils/ObjMap';
Expand All @@ -8,6 +9,7 @@ import memoize3 from '../jsutils/memoize3';
import invariant from '../jsutils/invariant';
import devAssert from '../jsutils/devAssert';
import isPromise from '../jsutils/isPromise';
import isAsyncIterable from '../jsutils/isAsyncIterable';
import isObjectLike from '../jsutils/isObjectLike';
import isCollection from '../jsutils/isCollection';
import promiseReduce from '../jsutils/promiseReduce';
Expand Down Expand Up @@ -850,6 +852,48 @@ function completeValue(
);
}

/**
* Complete a async iterator value by completing the result and calling
* recursively until all the results are completed.
*/
function completeAsyncIteratorValue(
exeContext: ExecutionContext,
itemType: GraphQLOutputType,
fieldNodes: $ReadOnlyArray<FieldNode>,
info: GraphQLResolveInfo,
path: Path,
index: number,
completedResults: Array<mixed>,
iterator: AsyncIterator<mixed>,
): Promise<$ReadOnlyArray<mixed>> {
const fieldPath = addPath(path, index, undefined);
return iterator.next().then(
({ value, done }) => {
if (done) {
return completedResults;
}
completedResults.push(
completeValue(exeContext, itemType, fieldNodes, info, fieldPath, value),
);
return completeAsyncIteratorValue(
exeContext,
itemType,
fieldNodes,
info,
path,
index + 1,
completedResults,
iterator,
);
},
(error) => {
completedResults.push(null);
handleFieldError(error, fieldNodes, fieldPath, itemType, exeContext);
return completedResults;
},
);
}

/**
* Complete a list value by completing each item in the list with the
* inner type
Expand All @@ -862,6 +906,23 @@ function completeListValue(
path: Path,
result: mixed,
): PromiseOrValue<$ReadOnlyArray<mixed>> {
const itemType = returnType.ofType;

if (isAsyncIterable(result)) {
const iterator = result[SYMBOL_ASYNC_ITERATOR]();

return completeAsyncIteratorValue(
exeContext,
itemType,
fieldNodes,
info,
path,
0,
[],
iterator,
);
}

if (!isCollection(result)) {
throw new GraphQLError(
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
Expand All @@ -870,7 +931,6 @@ function completeListValue(

// This is specified as a simple map, however we're optimizing the path
// where the list contains no Promises by avoiding creating another Promise.
const itemType = returnType.ofType;
let containsPromise = false;
const completedResults = arrayFrom(result, (item, index) => {
// No need to modify the info object containing the path,
Expand Down
17 changes: 17 additions & 0 deletions src/jsutils/isAsyncIterable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';

/**
* Returns true if the provided object implements the AsyncIterator protocol via
* either implementing a `Symbol.asyncIterator` or `"@@asyncIterator"` method.
*/
declare function isAsyncIterable(value: mixed): boolean %checks(value instanceof
AsyncIterable);

// eslint-disable-next-line no-redeclare
export default function isAsyncIterable(maybeAsyncIterable) {
if (maybeAsyncIterable == null || typeof maybeAsyncIterable !== 'object') {
return false;
}

return typeof maybeAsyncIterable[SYMBOL_ASYNC_ITERATOR] === 'function';
}
21 changes: 3 additions & 18 deletions src/subscription/subscribe.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { SYMBOL_ASYNC_ITERATOR } from '../polyfills/symbols';

import inspect from '../jsutils/inspect';
import isAsyncIterable from '../jsutils/isAsyncIterable';
import { addPath, pathToArray } from '../jsutils/Path';

import { GraphQLError } from '../error/GraphQLError';
Expand Down Expand Up @@ -158,7 +157,7 @@ function subscribeImpl(
// Note: Flow can't refine isAsyncIterable, so explicit casts are used.
isAsyncIterable(resultOrStream)
? mapAsyncIterator(
((resultOrStream: any): AsyncIterable<mixed>),
resultOrStream,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IvanGoncharov since adding boolean %checks(value instanceof AsyncIterable) to isAsyncIterable I was able to remove the cast here, but I was not able to remove it from the other side of the ternary.

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ src/subscription/subscribe.js:166:9

Cannot call sourcePromise.then with function bound to onFulfill because mixed [1] is incompatible
with object type [2] in type argument Yield [3] of the return value of property @@asyncIterator of
the return value. [incompatible-call]

     src/subscription/subscribe.js
 [2] 117│ ): Promise<AsyncIterator<ExecutionResult> | ExecutionResult> {
        :
     163│           mapSourceToResponse,
     164│           reportGraphQLError,
     165│         )
     166│       : resultOrStream,
     167│   );
     168│ }
     169│
        :
 [1] 206│ ): Promise<AsyncIterable<mixed> | ExecutionResult> {

     /private/tmp/flow/flowlib_5bfb8b1/core.js
 [3] 562│ interface $AsyncIterator<+Yield,+Return,-Next> {

mapSourceToResponse,
reportGraphQLError,
)
Expand Down Expand Up @@ -289,24 +288,10 @@ function executeSubscription(
`Received: ${inspect(eventStream)}.`,
);
}

// Note: isAsyncIterable above ensures this will be correct.
return ((eventStream: any): AsyncIterable<mixed>);
return eventStream;
},
(error) => {
throw locatedError(error, fieldNodes, pathToArray(path));
},
);
}

/**
* Returns true if the provided object implements the AsyncIterator protocol via
* either implementing a `Symbol.asyncIterator` or `"@@asyncIterator"` method.
*/
function isAsyncIterable(maybeAsyncIterable: mixed): boolean {
if (maybeAsyncIterable == null || typeof maybeAsyncIterable !== 'object') {
return false;
}

return typeof maybeAsyncIterable[SYMBOL_ASYNC_ITERATOR] === 'function';
}