Skip to content

Support returning async iterables from resolver functions #2757

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

Merged
merged 2 commits into from
Jun 23, 2022
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
24 changes: 24 additions & 0 deletions benchmark/list-asyncIterable-benchmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { execute } from 'graphql/execution/execute.js';
import { parse } from 'graphql/language/parser.js';
import { buildSchema } from 'graphql/utilities/buildASTSchema.js';

const schema = buildSchema('type Query { listField: [String] }');
const document = parse('{ listField }');

async function* listField() {
for (let index = 0; index < 1000; index++) {
yield index;
}
}

export const benchmark = {
name: 'Execute Async Iterable List Field',
count: 10,
async measure() {
await execute({
schema,
document,
rootValue: { listField },
});
},
};
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
75 changes: 74 additions & 1 deletion src/execution/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,65 @@ 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);
}
} 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 @@ -703,6 +762,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 @@ -711,7 +785,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