Skip to content

Commit

Permalink
Allow list field resolvers to return async iterables (#38)
Browse files Browse the repository at this point in the history
* support async benchmark tests

* Add benchmarks for sync and async list fields

* Support returning async iterables from resolver functions

Support returning async iterables from resolver functions

* add benchmark tests for async iterable list fields

* add changeset

Co-authored-by: Rob Richard <rob@1stdibs.com>
  • Loading branch information
yaacovCR and robrichard authored Oct 26, 2021
1 parent 8ad6a6c commit 8df89b5
Show file tree
Hide file tree
Showing 4 changed files with 271 additions and 3 deletions.
7 changes: 7 additions & 0 deletions .changeset/few-poets-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'graphql-executor': patch
---

Allow list field resolvers to return async iterables

https://github.com/graphql/graphql-js/pull/2757
26 changes: 26 additions & 0 deletions benchmark/list-asyncIterable-benchmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

const { parse } = require('graphql/language/parser.js');
const { execute } = require('graphql/execution/execute.js');
const { buildSchema } = require('graphql/utilities/buildASTSchema.js');

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

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

module.exports = {
name: 'Execute Async Iterable List Field',
count: 10,
async measure() {
await execute({
schema,
document,
rootValue: { listField },
});
},
};
152 changes: 151 additions & 1 deletion src/execution/__tests__/lists-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';

import { buildSchema, parse } from 'graphql';
import type { ExecutionResult, GraphQLFieldResolver } from 'graphql';
import {
GraphQLList,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
buildSchema,
parse,
} from 'graphql';

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

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

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

describe('Execute: Accepts async iterables as list value', () => {
function complete(rootValue: unknown) {
return execute({
schema: buildSchema('type Query { listField: [String] }'),
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);
}

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 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 () => {
expect(
await completeObjectList(({ index }) => Promise.resolve(index)),
).to.deep.equal({
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'],
},
],
});
});
});

describe('Execute: Handles list nullability', () => {
async function complete(args: { listField: unknown; as: string }) {
const { listField, as } = args;
Expand Down
89 changes: 87 additions & 2 deletions src/execution/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,21 @@ export class Executor {
path: Path,
result: unknown,
): PromiseOrValue<ReadonlyArray<unknown>> {
const itemType = returnType.ofType;

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

return this.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 @@ -751,8 +766,6 @@ export class Executor {

// 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;

const promises: Array<Promise<void>> = [];
const completedResults = Array.from(result, (item, index) => {
// No need to modify the info object containing the path,
Expand Down Expand Up @@ -817,6 +830,78 @@ export class Executor {
return resolveAfterAll(completedResults, promises);
}

/**
* Complete a async iterator value by completing the result and calling
* recursively until all the results are completed.
*/
completeAsyncIteratorValue(
exeContext: ExecutionContext,
itemType: GraphQLOutputType,
fieldNodes: ReadonlyArray<FieldNode>,
info: GraphQLResolveInfo,
path: Path,
iterator: AsyncIterator<unknown>,
): Promise<ReadonlyArray<unknown>> {
const promises: Array<Promise<void>> = [];
return new Promise<ReadonlyArray<unknown>>((resolve) => {
const next = (index: number, completedResults: Array<unknown>) => {
const fieldPath = addPath(path, index, undefined);
iterator.next().then(
({ value, done }) => {
if (done) {
resolve(completedResults);
return;
}
// TODO can the error checking logic be consolidated with completeListValue?
try {
const completedItem = this.completeValue(
exeContext,
itemType,
fieldNodes,
info,
fieldPath,
value,
);
completedResults.push(completedItem);
if (isPromise(completedItem)) {
const promise = completedItem.then((resolved) => {
completedResults[index] = resolved;
});
promises.push(promise);
}
} catch (rawError) {
completedResults.push(null);
const error = locatedError(
rawError,
fieldNodes,
pathToArray(fieldPath),
);
this.handleFieldError(error, itemType, exeContext);
resolve(completedResults);
}

next(index + 1, completedResults);
},
(rawError) => {
completedResults.push(null);
const error = locatedError(
rawError,
fieldNodes,
pathToArray(fieldPath),
);
this.handleFieldError(error, itemType, exeContext);
resolve(completedResults);
},
);
};
next(0, []);
}).then((completedResults) =>
promises.length
? resolveAfterAll(completedResults, promises)
: completedResults,
);
}

/**
* Complete a Scalar or Enum by serializing to a valid value, returning
* null if serialization is not possible.
Expand Down

0 comments on commit 8df89b5

Please sign in to comment.