Skip to content

Commit

Permalink
Support returning async iterables from resolver functions
Browse files Browse the repository at this point in the history
Support returning async iterables from resolver functions
  • Loading branch information
robrichard committed Apr 4, 2022
1 parent 642cade commit 980732d
Show file tree
Hide file tree
Showing 2 changed files with 253 additions and 1 deletion.
177 changes: 177 additions & 0 deletions src/execution/__tests__/lists-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import { describe, it } from 'mocha';

import { expectJSON } from '../../__testUtils__/expectJSON';

import type { PromiseOrValue } from '../../jsutils/PromiseOrValue';

import { parse } from '../../language/parser';

import type { GraphQLFieldResolver } from '../../type/definition';
import { GraphQLList, GraphQLObjectType } from '../../type/definition';
import { GraphQLString } from '../../type/scalars';
import { GraphQLSchema } from '../../type/schema';

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

import type { ExecutionResult } from '../execute';
import { execute, executeSync } from '../execute';

describe('Execute: Accepts any iterable as list value', () => {
Expand Down Expand Up @@ -66,6 +74,175 @@ describe('Execute: Accepts any iterable as list value', () => {
});
});

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

function completeObjectList(
resolve: GraphQLFieldResolver<{ index: number }, unknown>,
): PromiseOrValue<ExecutionResult> {
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
listField: {
resolve: async function* listField() {
yield await Promise.resolve({ index: 0 });
yield await Promise.resolve({ index: 1 });
yield await Promise.resolve({ index: 2 });
},
type: new GraphQLList(
new GraphQLObjectType({
name: 'ObjectWrapper',
fields: {
index: {
type: GraphQLString,
resolve,
},
},
}),
),
},
},
}),
});
return execute({
schema,
document: parse('{ listField { index } }'),
});
}

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

expectJSON(await complete({ listField })).toDeepEqual({
data: { listField: ['two', '4', 'false'] },
});
});

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

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

it('Handles an AsyncGenerator function where an intermediate value triggers an error', async () => {
async function* listField() {
yield await Promise.resolve('two');
yield await Promise.resolve({});
yield await Promise.resolve(4);
}

expectJSON(await complete({ listField })).toDeepEqual({
data: { listField: ['two', null, '4'] },
errors: [
{
message: 'String cannot represent value: {}',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
],
});
});

it('Handles errors from `completeValue` in AsyncIterables', async () => {
async function* listField() {
yield await Promise.resolve('two');
yield await Promise.resolve({});
}

expectJSON(await complete({ listField })).toDeepEqual({
data: { listField: ['two', null] },
errors: [
{
message: 'String cannot represent value: {}',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
],
});
});

it('Handles promises from `completeValue` in AsyncIterables', async () => {
expectJSON(
await completeObjectList(({ index }) => Promise.resolve(index)),
).toDeepEqual({
data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] },
});
});

it('Handles rejected promises from `completeValue` in AsyncIterables', async () => {
expectJSON(
await completeObjectList(({ index }) => {
if (index === 2) {
return Promise.reject(new Error('bad'));
}
return Promise.resolve(index);
}),
).toDeepEqual({
data: { listField: [{ index: '0' }, { index: '1' }, { index: null }] },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 15 }],
path: ['listField', 2, 'index'],
},
],
});
});
it('Handles nulls yielded by async generator', async () => {
async function* listField() {
yield await Promise.resolve(1);
yield await Promise.resolve(null);
yield await Promise.resolve(2);
}
const errors = [
{
message: 'Cannot return null for non-nullable field Query.listField.',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
];

expect(await complete({ listField }, '[Int]')).to.deep.equal({
data: { listField: [1, null, 2] },
});
expect(await complete({ listField }, '[Int]!')).to.deep.equal({
data: { listField: [1, null, 2] },
});
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
data: { listField: null },
errors,
});
expectJSON(await complete({ listField }, '[Int!]!')).toDeepEqual({
data: null,
errors,
});
});
});

describe('Execute: Handles list nullability', () => {
async function complete(args: { listField: unknown; as: string }) {
const { listField, as } = args;
Expand Down
77 changes: 76 additions & 1 deletion src/execution/execute.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { devAssert } from '../jsutils/devAssert';
import { inspect } from '../jsutils/inspect';
import { invariant } from '../jsutils/invariant';
import { isAsyncIterable } from '../jsutils/isAsyncIterable';
import { isIterableObject } from '../jsutils/isIterableObject';
import { isObjectLike } from '../jsutils/isObjectLike';
import { isPromise } from '../jsutils/isPromise';
Expand Down Expand Up @@ -703,6 +704,66 @@ function completeValue(
);
}

/**
* Complete a async iterator value by completing the result and calling
* recursively until all the results are completed.
*/
async function completeAsyncIteratorValue(
exeContext: ExecutionContext,
itemType: GraphQLOutputType,
fieldNodes: ReadonlyArray<FieldNode>,
info: GraphQLResolveInfo,
path: Path,
iterator: AsyncIterator<unknown>,
): Promise<ReadonlyArray<unknown>> {
let containsPromise = false;
const completedResults = [];
let index = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const fieldPath = addPath(path, index, undefined);
try {
// eslint-disable-next-line no-await-in-loop
const { value, done } = await iterator.next();
if (done) {
break;
}

try {
// TODO can the error checking logic be consolidated with completeListValue?
const completedItem = completeValue(
exeContext,
itemType,
fieldNodes,
info,
fieldPath,
value,
);
if (isPromise(completedItem)) {
containsPromise = true;
}
completedResults.push(completedItem);
} catch (rawError) {
completedResults.push(null);
const error = locatedError(
rawError,
fieldNodes,
pathToArray(fieldPath),
);
handleFieldError(error, itemType, exeContext);
continue;
}
} catch (rawError) {
completedResults.push(null);
const error = locatedError(rawError, fieldNodes, pathToArray(fieldPath));
handleFieldError(error, itemType, exeContext);
break;
}
index += 1;
}
return containsPromise ? Promise.all(completedResults) : completedResults;
}

/**
* Complete a list value by completing each item in the list with the
* inner type
Expand All @@ -715,6 +776,21 @@ function completeListValue(
path: Path,
result: unknown,
): PromiseOrValue<ReadonlyArray<unknown>> {
const itemType = returnType.ofType;

if (isAsyncIterable(result)) {
const iterator = result[Symbol.asyncIterator]();

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

if (!isIterableObject(result)) {
throw new GraphQLError(
`Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`,
Expand All @@ -723,7 +799,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 = Array.from(result, (item, index) => {
// No need to modify the info object containing the path,
Expand Down

0 comments on commit 980732d

Please sign in to comment.