import { describe, it } from 'node:test';
import { expect } from 'chai';
import { expectJSON } from '../../__testUtils__/expectJSON.ts';
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.ts';
import { spyOnMethod } from '../../__testUtils__/spyOn.ts';
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts';
import { parse } from '../../language/parser.ts';
import type { GraphQLFieldResolver } from '../../type/definition.ts';
import {
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
} from '../../type/definition.ts';
import { GraphQLString } from '../../type/scalars.ts';
import { GraphQLSchema } from '../../type/schema.ts';
import { buildSchema } from '../../utilities/buildASTSchema.ts';
import { execute, executeSync } from '../execute.ts';
import type { ExecutionResult } from '../Executor.ts';
function delayedReject(message: string): Promise<never> {
return (async () => {
await resolveOnNextTick();
await resolveOnNextTick();
throw new Error(message);
})();
}
describe('Execute: Accepts any iterable as list value', () => {
function complete(rootValue: unknown) {
return executeSync({
schema: buildSchema('type Query { listField: [String] }'),
document: parse('{ listField }'),
rootValue,
});
}
it('Accepts a Set as a List value', () => {
const listField = new Set(['apple', 'banana', 'apple', 'coconut']);
expect(complete({ listField })).to.deep.equal({
data: { listField: ['apple', 'banana', 'coconut'] },
});
});
it('Accepts a Generator function as a List value', () => {
function* listField() {
yield 'one';
yield 2;
yield true;
}
expect(complete({ listField })).to.deep.equal({
data: { listField: ['one', '2', 'true'] },
});
});
it('Accepts function arguments as a List value', () => {
function getArgs(..._args: ReadonlyArray<string>) {
return arguments;
}
const listField = getArgs('one', 'two');
expect(complete({ listField })).to.deep.equal({
data: { listField: ['one', 'two'] },
});
});
it('Does not accept (Iterable) String-literal as a List value', () => {
const listField = 'Singular';
expectJSON(complete({ listField })).toDeepEqual({
data: { listField: null },
errors: [
{
message:
'Expected Iterable, but did not find one for field "Query.listField".',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
],
});
});
it('Does not call iterator `return` when iteration throws', () => {
let nextCalls = 0;
const listField = {
[Symbol.iterator]() {
return this;
},
next() {
nextCalls++;
if (nextCalls === 1) {
throw new Error('bad');
}
return { done: true, value: undefined };
},
return() {
throw new Error('return bad');
},
};
const returnSpy = spyOnMethod(listField, 'return');
expectJSON(complete({ listField })).toDeepEqual({
data: { listField: null },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
],
});
expect(nextCalls).to.equal(2);
expect(returnSpy.callCount).to.equal(0);
});
});
describe('Execute: Handles abrupt completion in synchronous iterables', () => {
function complete(rootValue: unknown, as: string = '[String]') {
return execute({
schema: buildSchema(`type Query { listField: ${as} }`),
document: parse('{ listField }'),
rootValue,
});
}
it('drains the iterator when `next` throws', async () => {
let nextCalls = 0;
const listField: IterableIterator<string> = {
[Symbol.iterator](): IterableIterator<string> {
return this;
},
next(): IteratorResult<string> {
nextCalls++;
if (nextCalls === 1) {
return { done: false, value: 'ok' };
}
if (nextCalls === 2) {
throw new Error('bad');
}
return { done: true, value: undefined };
},
return(): IteratorResult<string> {
return { done: true, value: undefined };
},
};
const returnSpy = spyOnMethod(listField, 'return');
expectJSON(await complete({ listField })).toDeepEqual({
data: { listField: null },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
],
});
expect(nextCalls).to.equal(3);
expect(returnSpy.callCount).to.equal(0);
});
it('drains the iterator when a null bubbles up from a non-null item', async () => {
const values = [1, null, 2];
let index = 0;
const listField: IterableIterator<number | null> = {
[Symbol.iterator](): IterableIterator<number | null> {
return this;
},
next(): IteratorResult<number | null> {
const value = values[index++];
if (value === undefined) {
return { done: true, value: undefined };
}
return { done: false, value };
},
return(): IteratorResult<number | null> {
return { done: true, value: undefined };
},
};
const returnSpy = spyOnMethod(listField, 'return');
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
data: { listField: null },
errors: [
{
message: 'Cannot return null for non-nullable field Query.listField.',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
],
});
expect(index).to.equal(4);
expect(returnSpy.callCount).to.equal(0);
});
it('handles iterator errors with later pending promises without calling `return`', async () => {
let unhandledRejection: unknown = null;
const unhandledRejectionListener = (reason: unknown) => {
unhandledRejection = reason;
};
process.on('unhandledRejection', unhandledRejectionListener);
let nextCalls = 0;
const laterPromise = delayedReject('later bad');
const listField: IterableIterator<number | Promise<never>> = {
[Symbol.iterator](): IterableIterator<number | Promise<never>> {
return this;
},
next(): IteratorResult<number | Promise<never>> {
nextCalls++;
if (nextCalls === 1) {
return { done: false, value: 1 };
}
if (nextCalls === 2) {
throw new Error('bad');
}
if (nextCalls === 3) {
return { done: false, value: laterPromise };
}
return { done: true, value: undefined };
},
return(): IteratorResult<number | Promise<never>> {
throw new Error('ignored return error');
},
};
const returnSpy = spyOnMethod(listField, 'return');
expectJSON(await complete({ listField })).toDeepEqual({
data: { listField: null },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
],
});
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
process.removeListener('unhandledRejection', unhandledRejectionListener);
expect(nextCalls).to.equal(4);
expect(returnSpy.callCount).to.equal(0);
expect(unhandledRejection).to.equal(null);
});
it('handles sync errors with later pending promises without calling `return`', async () => {
let unhandledRejection: unknown = null;
const unhandledRejectionListener = (reason: unknown) => {
unhandledRejection = reason;
};
process.on('unhandledRejection', unhandledRejectionListener);
let index = 0;
const values = [
delayedReject('first bad'),
null,
delayedReject('third bad'),
];
const listField: IterableIterator<Promise<string> | null> = {
[Symbol.iterator](): IterableIterator<Promise<string> | null> {
return this;
},
next(): IteratorResult<Promise<string> | null> {
const value = values[index++];
if (value === undefined) {
return { done: true, value: undefined };
}
return { done: false, value };
},
return(): IteratorResult<Promise<string> | null> {
throw new Error('ignored return error');
},
};
const returnSpy = spyOnMethod(listField, 'return');
expectJSON(await complete({ listField }, '[String!]!')).toDeepEqual({
data: null,
errors: [
{
message: 'Cannot return null for non-nullable field Query.listField.',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
],
});
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
process.removeListener('unhandledRejection', unhandledRejectionListener);
expect(returnSpy.callCount).to.equal(0);
expect(index).to.equal(4);
expect(unhandledRejection).to.equal(null);
});
});
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>,
nonNullable = false,
): PromiseOrValue<ExecutionResult> {
const ObjectWrapperType = new GraphQLObjectType({
name: 'ObjectWrapper',
fields: {
index: {
type: new GraphQLNonNull(GraphQLString),
resolve,
},
},
});
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(
nonNullable
? new GraphQLNonNull(ObjectWrapperType)
: ObjectWrapperType,
),
},
},
}),
});
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: null },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
],
});
});
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' }, null] },
errors: [
{
message: 'bad',
locations: [{ line: 1, column: 15 }],
path: ['listField', 2, 'index'],
},
],
});
});
it('handles mixture of sync and async errors in AsyncIterables', async () => {
let unhandledRejection: unknown = null;
const unhandledRejectionListener = (reason: unknown) => {
unhandledRejection = reason;
};
process.on('unhandledRejection', unhandledRejectionListener);
expectJSON(
await completeObjectList(({ index }) => {
if (index === 0) {
return delayedReject('bad');
}
throw new Error('also bad');
}, true),
).toDeepEqual({
data: { listField: null },
errors: [
{
message: 'also bad',
locations: [{ line: 1, column: 15 }],
path: ['listField', 1, 'index'],
},
],
});
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
process.removeListener('unhandledRejection', unhandledRejectionListener);
expect(unhandledRejection).to.equal(null);
});
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,
});
});
it('Returns async iterable when list nulls', async () => {
const values = [1, null, 2];
let i = 0;
const listField = {
[Symbol.asyncIterator]() {
return this;
},
next() {
return Promise.resolve({ value: values[i++], done: false });
},
return() {
return Promise.resolve({ value: undefined, done: true });
},
};
const returnSpy = spyOnMethod(listField, 'return');
const errors = [
{
message: 'Cannot return null for non-nullable field Query.listField.',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
];
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
data: { listField: null },
errors,
});
expect(returnSpy.callCount).to.equal(1);
});
it('Ignores error on return method when async iterator nulls', async () => {
const values = [1, null, 2];
let i = 0;
const listField = {
[Symbol.asyncIterator]: () => ({
next: () => Promise.resolve({ value: values[i++], done: false }),
return: () => Promise.reject(new Error('ignored return error')),
}),
};
const errors = [
{
message: 'Cannot return null for non-nullable field Query.listField.',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
];
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
data: { listField: null },
errors,
});
});
});
describe('Execute: Handles list nullability', () => {
async function complete(args: { listField: unknown; as: string }) {
const { listField, as } = args;
const schema = buildSchema(`type Query { listField: ${as} }`);
const document = parse('{ listField }');
const result = await executeQuery(listField);
expectJSON(await executeQuery(promisify(listField))).toDeepEqual(result);
if (Array.isArray(listField)) {
const listOfPromises = listField.map(promisify);
expectJSON(await executeQuery(listOfPromises)).toDeepEqual(result);
expectJSON(await executeQuery(promisify(listOfPromises))).toDeepEqual(
result,
);
}
return result;
function executeQuery(listValue: unknown) {
return execute({ schema, document, rootValue: { listField: listValue } });
}
function promisify(value: unknown): Promise<unknown> {
return value instanceof Error
? Promise.reject(value)
: Promise.resolve(value);
}
}
it('Contains values', async () => {
const listField = [1, 2];
expect(await complete({ listField, as: '[Int]' })).to.deep.equal({
data: { listField: [1, 2] },
});
expect(await complete({ listField, as: '[Int]!' })).to.deep.equal({
data: { listField: [1, 2] },
});
expect(await complete({ listField, as: '[Int!]' })).to.deep.equal({
data: { listField: [1, 2] },
});
expect(await complete({ listField, as: '[Int!]!' })).to.deep.equal({
data: { listField: [1, 2] },
});
});
it('Contains null', async () => {
const listField = [1, null, 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, as: '[Int]' })).to.deep.equal({
data: { listField: [1, null, 2] },
});
expect(await complete({ listField, as: '[Int]!' })).to.deep.equal({
data: { listField: [1, null, 2] },
});
expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({
data: { listField: null },
errors,
});
expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({
data: null,
errors,
});
});
it('Returns null', async () => {
const listField = null;
const errors = [
{
message: 'Cannot return null for non-nullable field Query.listField.',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
];
expect(await complete({ listField, as: '[Int]' })).to.deep.equal({
data: { listField: null },
});
expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({
data: null,
errors,
});
expect(await complete({ listField, as: '[Int!]' })).to.deep.equal({
data: { listField: null },
});
expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({
data: null,
errors,
});
});
it('Contains error', async () => {
const listField = [1, new Error('bad'), 2];
const errors = [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField', 1],
},
];
expectJSON(await complete({ listField, as: '[Int]' })).toDeepEqual({
data: { listField: [1, null, 2] },
errors,
});
expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({
data: { listField: [1, null, 2] },
errors,
});
expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({
data: { listField: null },
errors,
});
expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({
data: null,
errors,
});
});
it('Results in error', async () => {
const listField = new Error('bad');
const errors = [
{
message: 'bad',
locations: [{ line: 1, column: 3 }],
path: ['listField'],
},
];
expectJSON(await complete({ listField, as: '[Int]' })).toDeepEqual({
data: { listField: null },
errors,
});
expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({
data: null,
errors,
});
expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({
data: { listField: null },
errors,
});
expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({
data: null,
errors,
});
});
});