From 4d5b38b0d72fe52580e62b05b85aee6bc311e4af Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 22:45:37 +0100 Subject: [PATCH 01/12] `onError` implementation --- src/__testUtils__/expectJSON.ts | 17 ++++-- src/error/GraphQLError.ts | 24 ++++++++- src/error/GraphQLErrorBehavior.ts | 27 ++++++++++ src/error/__tests__/GraphQLError-test.ts | 8 ++- src/error/__tests__/locatedError-test.ts | 7 ++- src/error/index.ts | 2 + src/error/locatedError.ts | 44 +++++++++++++-- src/execution/ExecutionArgs.ts | 11 +++- src/execution/Executor.ts | 53 ++++++++++++++----- src/execution/__tests__/executor-test.ts | 45 +++++++++++++++- src/execution/buildResolveInfo.ts | 14 ++++- src/execution/execute.ts | 27 +++++++--- .../incremental/IncrementalExecutor.ts | 12 +++-- src/execution/index.ts | 6 ++- src/index.ts | 10 +++- src/jsutils/Path.ts | 53 +++++++++++++++---- src/jsutils/__tests__/Path-test.ts | 11 +++- src/type/definition.ts | 3 ++ 18 files changed, 323 insertions(+), 51 deletions(-) create mode 100644 src/error/GraphQLErrorBehavior.ts diff --git a/src/__testUtils__/expectJSON.ts b/src/__testUtils__/expectJSON.ts index 4a62de2df4..2a69e4ca1a 100644 --- a/src/__testUtils__/expectJSON.ts +++ b/src/__testUtils__/expectJSON.ts @@ -3,6 +3,17 @@ import { expect } from 'chai'; import { isObjectLike } from '../jsutils/isObjectLike.ts'; import { mapValue } from '../jsutils/mapValue.ts'; +function withoutPathNonNull(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(withoutPathNonNull); + } + if (!isObjectLike(value)) { + return value; + } + const { pathNonNull: _pathNonNull, ...rest } = value; + return mapValue(rest, withoutPathNonNull); +} + /** * Deeply transforms an arbitrary value to a JSON-safe value by calling toJSON * on any nested value which defines it. @@ -30,18 +41,18 @@ export function expectJSON(actual: unknown): { expected: unknown, ) => ReturnType; } { - const actualJSON = toJSONDeep(actual); + const actualJSON = withoutPathNonNull(toJSONDeep(actual)); return { toDeepEqual(expected: unknown): ReturnType { - const expectedJSON = toJSONDeep(expected); + const expectedJSON = withoutPathNonNull(toJSONDeep(expected)); return expect(actualJSON).to.deep.equal(expectedJSON); }, toDeepNestedProperty( path: string, expected: unknown, ): ReturnType { - const expectedJSON = toJSONDeep(expected); + const expectedJSON = withoutPathNonNull(toJSONDeep(expected)); return expect(actualJSON).to.deep.nested.property(path, expectedJSON); }, }; diff --git a/src/error/GraphQLError.ts b/src/error/GraphQLError.ts index 07c83dcb0d..2c5eb1f509 100644 --- a/src/error/GraphQLError.ts +++ b/src/error/GraphQLError.ts @@ -46,6 +46,8 @@ export interface GraphQLErrorOptions { positions?: Maybe>; /** Response path where this error occurred during execution. */ path?: Maybe>; + /** Nullability for each position in the response path. */ + pathNonNull?: Maybe>; /** * Original error that caused this GraphQLError, if one exists. * Deprecated in favor of `cause` to better align with JavaScript standards. @@ -85,6 +87,9 @@ export class GraphQLError extends Error { */ readonly path: ReadonlyArray | undefined; + /** Nullability for each position in the response path. @experimental */ + readonly pathNonNull: ReadonlyArray | undefined; + /** An array of GraphQL AST Nodes corresponding to this error. */ readonly nodes: ReadonlyArray | undefined; @@ -156,8 +161,16 @@ export class GraphQLError extends Error { * ``` */ constructor(message: string, options: GraphQLErrorOptions = {}) { - const { nodes, source, positions, path, originalError, cause, extensions } = - options; + const { + nodes, + source, + positions, + path, + pathNonNull, + originalError, + cause, + extensions, + } = options; const hasCause = 'cause' in options; const errorCause = hasCause ? cause : originalError; @@ -167,6 +180,7 @@ export class GraphQLError extends Error { this.name = 'GraphQLError'; this.path = path ?? undefined; + this.pathNonNull = pathNonNull ?? undefined; const underlyingError: typeof originalError = originalError ?? (cause instanceof Error ? cause : undefined); this.originalError = underlyingError; @@ -309,6 +323,10 @@ export class GraphQLError extends Error { formattedError.path = this.path; } + if (this.pathNonNull != null) { + formattedError.pathNonNull = this.pathNonNull; + } + if (this.extensions != null && Object.keys(this.extensions).length > 0) { formattedError.extensions = this.extensions; } @@ -343,6 +361,8 @@ export interface GraphQLFormattedError { * identify whether a null result is intentional or caused by a runtime error. */ readonly path?: ReadonlyArray; + /** Nullability for each position in `path`. @experimental */ + readonly pathNonNull?: ReadonlyArray; /** * Reserved for implementors to extend the protocol however they see fit, * and hence there are no additional restrictions on its contents. diff --git a/src/error/GraphQLErrorBehavior.ts b/src/error/GraphQLErrorBehavior.ts new file mode 100644 index 0000000000..9818c6936c --- /dev/null +++ b/src/error/GraphQLErrorBehavior.ts @@ -0,0 +1,27 @@ +/** + * Specifies how execution errors should be handled: + * + * - `PROPAGATE`: errors in non-null positions propagate to the closest nullable position. + * - `NULL`: errors resolve to null without propagation. + * - `ABORT`: errors propagate as far as possible, typically to the operation root. + * @experimental + * @category Errors + */ +export type GraphQLErrorBehavior = 'NULL' | 'PROPAGATE' | 'ABORT'; + +/** + * True if the given value is a GraphQL error behavior. + * @param onError - The value to check. + * @returns True when the value is a supported error behavior. + * @experimental + * @example + * ```ts + * isErrorBehavior('PROPAGATE'); // true + * isErrorBehavior('THROW'); // false + * ``` + */ +export function isErrorBehavior( + onError: unknown, +): onError is GraphQLErrorBehavior { + return onError === 'NULL' || onError === 'PROPAGATE' || onError === 'ABORT'; +} diff --git a/src/error/__tests__/GraphQLError-test.ts b/src/error/__tests__/GraphQLError-test.ts index 24e521c242..698f281909 100644 --- a/src/error/__tests__/GraphQLError-test.ts +++ b/src/error/__tests__/GraphQLError-test.ts @@ -51,6 +51,7 @@ describe('GraphQLError', () => { source, positions: [1, 2, 3], path: ['a', 'b', 'c'], + pathNonNull: [false, true, false], cause: new Error('test'), originalError: new Error('test'), extensions: { foo: 'bar' }, @@ -60,6 +61,7 @@ describe('GraphQLError', () => { 'message', 'locations', 'path', + 'pathNonNull', 'extensions', ]); }); @@ -427,11 +429,15 @@ describe('toString', () => { describe('toJSON', () => { it('includes path', () => { - const error = new GraphQLError('msg', { path: ['path', 3, 'to', 'field'] }); + const error = new GraphQLError('msg', { + path: ['path', 3, 'to', 'field'], + pathNonNull: [false, true, false, true], + }); expect(error.toJSON()).to.deep.equal({ message: 'msg', path: ['path', 3, 'to', 'field'], + pathNonNull: [false, true, false, true], }); }); diff --git a/src/error/__tests__/locatedError-test.ts b/src/error/__tests__/locatedError-test.ts index e79a644bc8..fd6214e289 100644 --- a/src/error/__tests__/locatedError-test.ts +++ b/src/error/__tests__/locatedError-test.ts @@ -7,7 +7,10 @@ import { locatedError } from '../locatedError.ts'; describe('locatedError', () => { it('passes GraphQLError through', () => { - const e = new GraphQLError('msg', { path: ['path', 3, 'to', 'field'] }); + const e = new GraphQLError('msg', { + path: ['path', 3, 'to', 'field'], + pathNonNull: [false, true, false, true], + }); expect(locatedError(e, [], [])).to.deep.equal(e); }); @@ -30,6 +33,8 @@ describe('locatedError', () => { // @ts-expect-error e.path = []; // @ts-expect-error + e.pathNonNull = []; + // @ts-expect-error e.nodes = []; // @ts-expect-error e.source = null; diff --git a/src/error/index.ts b/src/error/index.ts index d8db7f5f17..5e7c89c404 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -16,3 +16,5 @@ export type { export { syntaxError } from './syntaxError.ts'; export { locatedError } from './locatedError.ts'; +export type { GraphQLErrorBehavior } from './GraphQLErrorBehavior.ts'; +export { isErrorBehavior } from './GraphQLErrorBehavior.ts'; diff --git a/src/error/locatedError.ts b/src/error/locatedError.ts index 883d2623ef..cb61212415 100644 --- a/src/error/locatedError.ts +++ b/src/error/locatedError.ts @@ -1,6 +1,7 @@ /** @category Errors */ import type { Maybe } from '../jsutils/Maybe.ts'; +import type { PathDigest } from '../jsutils/Path.ts'; import { toError } from '../jsutils/toError.ts'; import type { ASTNode } from '../language/ast.ts'; @@ -13,7 +14,7 @@ import { GraphQLError } from './GraphQLError.ts'; * document responsible for the original Error. * @param rawOriginalError - The original error value to wrap. * @param nodes - The AST nodes associated with the error. - * @param path - The response path associated with the error. + * @param digest - The response path digest associated with the error. * @returns The GraphQL error. * @example * ```ts @@ -22,17 +23,45 @@ import { GraphQLError } from './GraphQLError.ts'; * * const document = parse('{ viewer { name } }'); * const fieldNode = document.definitions[0].selectionSet.selections[0]; - * const error = locatedError(new Error('Resolver failed'), fieldNode, ['viewer']); + * const error = locatedError(new Error('Resolver failed'), fieldNode, { + * path: ['viewer'], + * pathNonNull: [false], + * }); * * error.message; // => 'Resolver failed' * error.locations; // => [{ line: 1, column: 3 }] * error.path; // => ['viewer'] * ``` */ +export function locatedError( + rawOriginalError: unknown, + nodes: ASTNode | ReadonlyArray | undefined | null, + digest: PathDigest, +): GraphQLError; +/** + * Given an arbitrary value, presumably thrown while attempting to execute a + * GraphQL operation, produce a new GraphQLError aware of the location in the + * document responsible for the original Error. + * @param rawOriginalError - The original error value to wrap. + * @param nodes - The AST nodes associated with the error. + * @param path - The response path associated with the error. + * @returns The GraphQL error. + * @example + * ```ts + * locatedError(new Error('Resolver failed'), undefined, ['viewer']); + * ``` + * @deprecated Pass a digest rather than a path. + */ export function locatedError( rawOriginalError: unknown, nodes: ASTNode | ReadonlyArray | undefined | null, path?: Maybe>, +): GraphQLError; +/** @internal */ +export function locatedError( + rawOriginalError: unknown, + nodes: ASTNode | ReadonlyArray | undefined | null, + digestOrPath?: Maybe>, ): GraphQLError { const originalError = toError(rawOriginalError); @@ -41,15 +70,22 @@ export function locatedError( return originalError; } + const digest: Partial = + digestOrPath == null + ? {} + : Array.isArray(digestOrPath) + ? { path: digestOrPath as ReadonlyArray } + : (digestOrPath as PathDigest); return new GraphQLError(originalError.message, { nodes: (originalError as GraphQLError).nodes ?? nodes, source: (originalError as GraphQLError).source, positions: (originalError as GraphQLError).positions, - path, + path: digest.path, + pathNonNull: digest.pathNonNull, originalError, }); } function isLocatedGraphQLError(error: any): error is GraphQLError { - return Array.isArray(error.path); + return Array.isArray(error.path) && Array.isArray(error.pathNonNull); } diff --git a/src/execution/ExecutionArgs.ts b/src/execution/ExecutionArgs.ts index 8428b24e80..7cfef6d0b5 100644 --- a/src/execution/ExecutionArgs.ts +++ b/src/execution/ExecutionArgs.ts @@ -3,6 +3,8 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { ObjMap } from '../jsutils/ObjMap.ts'; +import type { GraphQLErrorBehavior } from '../error/GraphQLErrorBehavior.ts'; + import type { DocumentNode, FragmentDefinitionNode, @@ -39,6 +41,11 @@ export interface ExecutionArgs { typeResolver?: Maybe>; /** Resolver used for the root subscription field. */ subscribeFieldResolver?: Maybe>; + /** + * Controls execution error handling. + * @experimental + */ + onError?: GraphQLErrorBehavior; /** Whether suggestion text should be omitted from request errors. */ hideSuggestions?: Maybe; /** AbortSignal used to cancel execution. */ @@ -94,8 +101,8 @@ export interface ValidatedExecutionArgs { subscribeFieldResolver: GraphQLFieldResolver; /** Whether suggestion text should be omitted from execution errors. */ hideSuggestions: boolean; - /** Whether execution should use error propagation. */ - errorPropagation: boolean; + /** How execution errors should be handled. */ + onError: GraphQLErrorBehavior; /** External signal that may abort execution. */ externalAbortSignal: AbortSignal | undefined; /** Whether incremental execution may begin eligible work early. */ diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 88163c712f..48d0216d14 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -9,7 +9,7 @@ import { memoize2 } from '../jsutils/memoize2.ts'; import { memoize3 } from '../jsutils/memoize3.ts'; import type { ObjMap } from '../jsutils/ObjMap.ts'; import type { Path } from '../jsutils/Path.ts'; -import { addPath, pathToArray } from '../jsutils/Path.ts'; +import { addPath, pathToArray, pathToDigest } from '../jsutils/Path.ts'; import { promiseForObject } from '../jsutils/promiseForObject.ts'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue.ts'; import { promiseReduce } from '../jsutils/promiseReduce.ts'; @@ -496,7 +496,16 @@ export class Executor< if (this.aborted) { throw new Error('Aborted!'); } - const fieldPath = addPath(path, responseName, parentType.name); + const fieldDef = this.validatedExecutionArgs.schema.getField( + parentType, + fieldDetailsList[0].node.name.value, + ); + const fieldPath = addPath( + path, + responseName, + parentType.name, + fieldDef != null && isNonNullType(fieldDef.type), + ); const result = this.executeField( parentType, sourceValue, @@ -545,7 +554,16 @@ export class Executor< try { for (const [responseName, fieldDetailsList] of groupedFieldSet) { - const fieldPath = addPath(path, responseName, parentType.name); + const fieldDef = this.validatedExecutionArgs.schema.getField( + parentType, + fieldDetailsList[0].node.name.value, + ); + const fieldPath = addPath( + path, + responseName, + parentType.name, + fieldDef != null && isNonNullType(fieldDef.type), + ); const result = this.executeField( parentType, sourceValue, @@ -722,15 +740,14 @@ export class Executor< const error = locatedError( rawError, toNodes(fieldDetailsList), - pathToArray(path), + pathToDigest(path), ); - // If the field type is non-nullable, then it is resolved without any - // protection from errors, however it still properly locates the error. - if ( - this.validatedExecutionArgs.errorPropagation && - isNonNullType(returnType) - ) { + const { onError } = this.validatedExecutionArgs; + if (onError === 'ABORT') { + throw error; + } + if (onError === 'PROPAGATE' && isNonNullType(returnType)) { throw error; } @@ -921,7 +938,12 @@ export class Executor< ) { break; } - const itemPath = addPath(path, index, undefined); + const itemPath = addPath( + path, + index, + undefined, + isNonNullType(itemType), + ); try { // eslint-disable-next-line no-await-in-loop iteration = await asyncIterator.next(); @@ -929,7 +951,7 @@ export class Executor< throw locatedError( rawError, toNodes(fieldDetailsList), - pathToArray(path), + pathToDigest(path), ); } if (this.aborted || iteration.done) { @@ -1079,7 +1101,12 @@ export class Executor< // No need to modify the info object containing the path, // since from here on it is not ever accessed by resolver functions. - const itemPath = addPath(path, index, undefined); + const itemPath = addPath( + path, + index, + undefined, + isNonNullType(itemType), + ); if ( this.completeMaybePromisedListItemValue( diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index ba8368af43..58d3ecc9e7 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -64,6 +64,40 @@ function executeSync(args: ExecutionArgs): ExecutionResult { } describe('Execute: Handles basic execution tasks', () => { + it('supports onError modes and reports path nullability', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + fail: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('failure'); + }, + }, + }, + }), + }); + const document = parse('{ fail }'); + + const nullResult = executeSync({ schema, document, onError: 'NULL' }); + expect(nullResult.data).to.deep.equal({ fail: null }); + expect(nullResult.errors?.[0].pathNonNull).to.deep.equal([true]); + + const abortResult = executeSync({ schema, document, onError: 'ABORT' }); + expect(abortResult.data).to.equal(null); + + const invalidResult = executeSync({ + schema, + document, + // @ts-expect-error Invalid values are reported as request errors. + onError: 'INVALID', + }); + expect(invalidResult.errors?.[0].message).to.equal( + 'Unsupported `onError` value; supported values are `NULL`, `PROPAGATE` and `ABORT`.', + ); + }); + it('executes arbitrary code', async () => { const data = { a: () => 'Apple', @@ -258,6 +292,7 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'onError', 'getAbortSignal', 'getAsyncHelpers', ); @@ -279,7 +314,12 @@ describe('Execute: Handles basic execution tasks', () => { const field = operation.selectionSet.selections[0]; expect(resolvedInfo).to.deep.include({ fieldNodes: [field], - path: { prev: undefined, key: 'result', typename: 'Test' }, + path: { + prev: undefined, + key: 'result', + typename: 'Test', + nonNull: false, + }, variableValues: { sources: { var: { @@ -366,12 +406,15 @@ describe('Execute: Handles basic execution tasks', () => { expect(path).to.deep.equal({ key: 'l2', typename: 'SomeObject', + nonNull: false, prev: { key: 0, typename: undefined, + nonNull: true, prev: { key: 'l1', typename: 'SomeQuery', + nonNull: true, prev: undefined, }, }, diff --git a/src/execution/buildResolveInfo.ts b/src/execution/buildResolveInfo.ts index 5e43297d62..8c5cde3b3d 100644 --- a/src/execution/buildResolveInfo.ts +++ b/src/execution/buildResolveInfo.ts @@ -1,6 +1,8 @@ import type { ObjMap } from '../jsutils/ObjMap.ts'; import type { Path } from '../jsutils/Path.ts'; +import type { GraphQLErrorBehavior } from '../error/GraphQLErrorBehavior.ts'; + import type { FieldNode, FragmentDefinitionNode, @@ -24,6 +26,7 @@ export interface BuildResolveInfoExecutionArgs { rootValue: unknown; operation: OperationDefinitionNode; variableValues: VariableValues; + onError: GraphQLErrorBehavior; } /** @internal */ @@ -37,8 +40,14 @@ export function buildResolveInfo( getAbortSignal: () => AbortSignal | undefined, getAsyncHelpers: () => GraphQLResolveInfoHelpers, ): GraphQLResolveInfo { - const { schema, fragmentDefinitions, rootValue, operation, variableValues } = - validatedExecutionArgs; + const { + schema, + fragmentDefinitions, + rootValue, + operation, + variableValues, + onError, + } = validatedExecutionArgs; // The resolve function's optional fourth argument is a collection of // information about the current execution state. return { @@ -52,6 +61,7 @@ export function buildResolveInfo( rootValue, operation, variableValues, + onError, getAbortSignal, getAsyncHelpers, }; diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 4dea28d5c4..52e66b6bfc 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -5,11 +5,12 @@ import { isAsyncIterable } from '../jsutils/isAsyncIterable.ts'; import { isObjectLike } from '../jsutils/isObjectLike.ts'; import { isPromise, isPromiseLike } from '../jsutils/isPromise.ts'; import type { ObjMap } from '../jsutils/ObjMap.ts'; -import { addPath, pathToArray } from '../jsutils/Path.ts'; +import { addPath, pathToDigest } from '../jsutils/Path.ts'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue.ts'; import { ensureGraphQLError } from '../error/ensureGraphQLError.ts'; import { GraphQLError } from '../error/GraphQLError.ts'; +import { isErrorBehavior } from '../error/GraphQLErrorBehavior.ts'; import { locatedError } from '../error/locatedError.ts'; import type { @@ -25,7 +26,7 @@ import type { GraphQLFieldResolver, GraphQLTypeResolver, } from '../type/index.ts'; -import { assertValidSchema } from '../type/index.ts'; +import { assertValidSchema, isNonNullType } from '../type/index.ts'; import { getOperationAST } from '../utilities/getOperationAST.ts'; @@ -744,12 +745,21 @@ export function validateExecutionArgs( abortSignal: externalAbortSignal, enableEarlyExecution, hooks, + onError, options, } = args; // If the schema used for execution is invalid, throw an error. assertValidSchema(schema); + if (onError != null && !isErrorBehavior(onError)) { + return [ + new GraphQLError( + 'Unsupported `onError` value; supported values are `NULL`, `PROPAGATE` and `ABORT`.', + ), + ]; + } + let operation: OperationDefinitionNode | undefined; const fragmentDefinitions: ObjMap = Object.create(null); @@ -863,7 +873,7 @@ export function validateExecutionArgs( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, hideSuggestions, - errorPropagation, + onError: onError ?? (errorPropagation ? 'PROPAGATE' : 'NULL'), externalAbortSignal: externalAbortSignal ?? undefined, enableEarlyExecution: enableEarlyExecution === true, hooks: hooks ?? undefined, @@ -1124,7 +1134,12 @@ function executeSubscription( const sharedExecutionContext = createSharedExecutionContext(externalAbortSignal); - const path = addPath(undefined, responseName, rootType.name); + const path = addPath( + undefined, + responseName, + rootType.name, + isNonNullType(fieldDef.type), + ); const info = buildResolveInfo( validatedExecutionArgs, fieldDef, @@ -1170,13 +1185,13 @@ function executeSubscription( throw locatedError( error, toNodes(fieldDetailsList), - pathToArray(path), + pathToDigest(path), ); }); } return assertEventStream(result); } catch (error) { - throw locatedError(error, fieldNodes, pathToArray(path)); + throw locatedError(error, fieldNodes, pathToDigest(path)); } } diff --git a/src/execution/incremental/IncrementalExecutor.ts b/src/execution/incremental/IncrementalExecutor.ts index b4510fdd75..7401463d87 100644 --- a/src/execution/incremental/IncrementalExecutor.ts +++ b/src/execution/incremental/IncrementalExecutor.ts @@ -7,7 +7,7 @@ import { memoize1 } from '../../jsutils/memoize1.ts'; import { memoize2 } from '../../jsutils/memoize2.ts'; import type { ObjMap } from '../../jsutils/ObjMap.ts'; import type { Path } from '../../jsutils/Path.ts'; -import { addPath, pathToArray } from '../../jsutils/Path.ts'; +import { addPath, pathToArray, pathToDigest } from '../../jsutils/Path.ts'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; import type { SetMap } from '../../jsutils/SetMap.ts'; @@ -25,6 +25,7 @@ import type { GraphQLOutputType, GraphQLResolveInfo, } from '../../type/definition.ts'; +import { isNonNullType } from '../../type/definition.ts'; import type { DeferUsage, @@ -883,7 +884,7 @@ export class IncrementalExecutor< throw locatedError( rawError, toNodes(fieldDetailsList), - pathToArray(streamPath), + pathToDigest(streamPath), ); } @@ -898,7 +899,12 @@ export class IncrementalExecutor< return; } - const itemPath = addPath(streamPath, index, undefined); + const itemPath = addPath( + streamPath, + index, + undefined, + isNonNullType(itemType), + ); const executor = createSubExecutor(); diff --git a/src/execution/index.ts b/src/execution/index.ts index 7a791c995d..e16bd7814d 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -5,7 +5,11 @@ * @packageDocumentation */ -export { pathToArray as responsePathAsArray } from '../jsutils/Path.ts'; +export type { PathDigest } from '../jsutils/Path.ts'; +export { + pathToArray as responsePathAsArray, + pathToDigest as getResponsePathDigest, +} from '../jsutils/Path.ts'; export { createSourceEventStream, diff --git a/src/index.ts b/src/index.ts index 219478ad42..8ed10acc4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -381,6 +381,7 @@ export { defaultFieldResolver, defaultTypeResolver, responsePathAsArray, + getResponsePathDigest, getArgumentValues, getVariableValues, getDirectiveValues, @@ -399,6 +400,7 @@ export type { VariableValues, ValidatedExecutionArgs, ValidatedSubscriptionArgs, + PathDigest, ExecutionResult, ExperimentalIncrementalExecutionResults, InitialIncrementalExecutionResult, @@ -484,13 +486,19 @@ export { export type { ValidationOptions, ValidationRule } from './validation/index.ts'; // Create, format, and print GraphQL errors. -export { GraphQLError, syntaxError, locatedError } from './error/index.ts'; +export { + GraphQLError, + syntaxError, + locatedError, + isErrorBehavior, +} from './error/index.ts'; export type { GraphQLErrorOptions, GraphQLFormattedError, GraphQLErrorExtensions, GraphQLFormattedErrorExtensions, + GraphQLErrorBehavior, } from './error/index.ts'; // Utilities for operating on GraphQL type schema and parsed sources. diff --git a/src/jsutils/Path.ts b/src/jsutils/Path.ts index e32ccf609e..53bada69b8 100644 --- a/src/jsutils/Path.ts +++ b/src/jsutils/Path.ts @@ -10,19 +10,60 @@ export interface Path { readonly key: string | number; /** The runtime object type name associated with this path segment, if known. */ readonly typename: string | undefined; + /** Whether this response path segment resolves through a non-null type. */ + readonly nonNull: boolean; +} + +/** + * A flattened response path and the nullability metadata for each segment. + * @experimental + */ +export interface PathDigest { + /** Response path keys from root to leaf. */ + readonly path: ReadonlyArray; + /** Whether each response path segment resolves through a non-null type. */ + readonly pathNonNull: ReadonlyArray; } /** * Given a Path and a key, return a new Path containing the new key. - * * @internal */ export function addPath( prev: Readonly | undefined, key: string | number, typename: string | undefined, + nonNull = false, ): Path { - return { prev, key, typename }; + return { prev, key, typename, nonNull }; +} + +/** + * Given a Path, return its keys and nullability metadata. + * @param pathLinkedList - The linked response path to flatten. + * @returns The flattened path and nullability metadata. + * @experimental + * @example + * ```ts + * const path = addPath(undefined, 'viewer', 'Query', false); + * pathToDigest(path); + * // { path: ['viewer'], pathNonNull: [false] } + * ``` + */ +export function pathToDigest( + pathLinkedList: Maybe>, +): PathDigest { + const path: Array = []; + const pathNonNull: Array = []; + let curr = pathLinkedList; + while (curr) { + path.push(curr.key); + pathNonNull.push(curr.nonNull); + curr = curr.prev; + } + path.reverse(); + pathNonNull.reverse(); + return { path, pathNonNull }; } /** @@ -54,11 +95,5 @@ export function addPath( export function pathToArray( path: Maybe>, ): Array { - const flattened = []; - let curr = path; - while (curr) { - flattened.push(curr.key); - curr = curr.prev; - } - return flattened.reverse(); + return [...pathToDigest(path).path]; } diff --git a/src/jsutils/__tests__/Path-test.ts b/src/jsutils/__tests__/Path-test.ts index d058a7f9a0..2d6eaf5158 100644 --- a/src/jsutils/__tests__/Path-test.ts +++ b/src/jsutils/__tests__/Path-test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'node:test'; import { expect } from 'chai'; -import { addPath, pathToArray } from '../Path.ts'; +import { addPath, pathToArray, pathToDigest } from '../Path.ts'; describe('Path', () => { it('can create a Path', () => { @@ -12,17 +12,19 @@ describe('Path', () => { prev: undefined, key: 1, typename: 'First', + nonNull: false, }); }); it('can add a new key to an existing Path', () => { const first = addPath(undefined, 1, 'First'); - const second = addPath(first, 'two', 'Second'); + const second = addPath(first, 'two', 'Second', true); expect(second).to.deep.equal({ prev: first, key: 'two', typename: 'Second', + nonNull: true, }); }); @@ -33,5 +35,10 @@ describe('Path', () => { const path = pathToArray(second); expect(path).to.deep.equal([0, 'one', 2]); + + expect(pathToDigest(second)).to.deep.equal({ + path: [0, 'one', 2], + pathNonNull: [false, false, false], + }); }); }); diff --git a/src/type/definition.ts b/src/type/definition.ts index 3c0269a028..2cf5f0cc16 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -16,6 +16,7 @@ import { suggestionList } from '../jsutils/suggestionList.ts'; import { toObjMapWithSymbols } from '../jsutils/toObjMap.ts'; import { GraphQLError } from '../error/GraphQLError.ts'; +import type { GraphQLErrorBehavior } from '../error/GraphQLErrorBehavior.ts'; import type { ConstValueNode, @@ -2871,6 +2872,8 @@ export interface GraphQLResolveInfo { * code that needs runtime variable values should read `variableValues.coerced`. */ readonly variableValues: VariableValues; + /** How execution errors are handled for this operation. @experimental */ + readonly onError: GraphQLErrorBehavior; /** Returns the AbortSignal supplied for this execution, if any. */ readonly getAbortSignal: () => AbortSignal | undefined; /** Returns helper functions for tracking asynchronous resolver work. */ From ebd8b4d24b64fd6f2e5adddb7b7d174e0731915a Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 22:58:05 +0100 Subject: [PATCH 02/12] Stop cheating, codex --- src/__testUtils__/expectJSON.ts | 17 +- src/__tests__/starWarsQuery-test.ts | 5 + src/error/GraphQLErrorBehavior.ts | 4 +- src/error/__tests__/GraphQLError-test.ts | 7 + src/error/__tests__/locatedError-test.ts | 10 + src/execution/ExecutionArgs.ts | 6 +- src/execution/__tests__/abstract-test.ts | 6 + src/execution/__tests__/cancellation-test.ts | 4 + .../__tests__/errorPropagation-test.ts | 2 + src/execution/__tests__/executor-test.ts | 178 ++++++++++++++++++ src/execution/__tests__/lists-test.ts | 56 ++++-- src/execution/__tests__/mutations-test.ts | 2 + src/execution/__tests__/nonnull-test.ts | 36 ++++ src/execution/__tests__/oneof-test.ts | 5 + src/execution/__tests__/subscribe-test.ts | 6 + src/execution/__tests__/variables-test.ts | 7 + .../incremental/__tests__/defer-test.ts | 20 ++ .../incremental/__tests__/stream-test.ts | 26 +++ .../__tests__/legacy-defer-test.ts | 20 ++ .../__tests__/legacy-stream-test.ts | 25 +++ src/type/__tests__/enumType-test.ts | 2 + 21 files changed, 409 insertions(+), 35 deletions(-) diff --git a/src/__testUtils__/expectJSON.ts b/src/__testUtils__/expectJSON.ts index 2a69e4ca1a..4a62de2df4 100644 --- a/src/__testUtils__/expectJSON.ts +++ b/src/__testUtils__/expectJSON.ts @@ -3,17 +3,6 @@ import { expect } from 'chai'; import { isObjectLike } from '../jsutils/isObjectLike.ts'; import { mapValue } from '../jsutils/mapValue.ts'; -function withoutPathNonNull(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(withoutPathNonNull); - } - if (!isObjectLike(value)) { - return value; - } - const { pathNonNull: _pathNonNull, ...rest } = value; - return mapValue(rest, withoutPathNonNull); -} - /** * Deeply transforms an arbitrary value to a JSON-safe value by calling toJSON * on any nested value which defines it. @@ -41,18 +30,18 @@ export function expectJSON(actual: unknown): { expected: unknown, ) => ReturnType; } { - const actualJSON = withoutPathNonNull(toJSONDeep(actual)); + const actualJSON = toJSONDeep(actual); return { toDeepEqual(expected: unknown): ReturnType { - const expectedJSON = withoutPathNonNull(toJSONDeep(expected)); + const expectedJSON = toJSONDeep(expected); return expect(actualJSON).to.deep.equal(expectedJSON); }, toDeepNestedProperty( path: string, expected: unknown, ): ReturnType { - const expectedJSON = withoutPathNonNull(toJSONDeep(expected)); + const expectedJSON = toJSONDeep(expected); return expect(actualJSON).to.deep.nested.property(path, expectedJSON); }, }; diff --git a/src/__tests__/starWarsQuery-test.ts b/src/__tests__/starWarsQuery-test.ts index fcb6827dd5..ac452efe6f 100644 --- a/src/__tests__/starWarsQuery-test.ts +++ b/src/__tests__/starWarsQuery-test.ts @@ -407,6 +407,7 @@ describe('Star Wars Query Tests', () => { { message: 'secretBackstory is secret.', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, false], path: ['hero', 'secretBackstory'], }, ], @@ -451,16 +452,19 @@ describe('Star Wars Query Tests', () => { { message: 'secretBackstory is secret.', locations: [{ line: 7, column: 15 }], + pathNonNull: [false, false, false, false], path: ['hero', 'friends', 0, 'secretBackstory'], }, { message: 'secretBackstory is secret.', locations: [{ line: 7, column: 15 }], + pathNonNull: [false, false, false, false], path: ['hero', 'friends', 1, 'secretBackstory'], }, { message: 'secretBackstory is secret.', locations: [{ line: 7, column: 15 }], + pathNonNull: [false, false, false, false], path: ['hero', 'friends', 2, 'secretBackstory'], }, ], @@ -489,6 +493,7 @@ describe('Star Wars Query Tests', () => { { message: 'secretBackstory is secret.', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, false], path: ['mainHero', 'story'], }, ], diff --git a/src/error/GraphQLErrorBehavior.ts b/src/error/GraphQLErrorBehavior.ts index 9818c6936c..ca160775b7 100644 --- a/src/error/GraphQLErrorBehavior.ts +++ b/src/error/GraphQLErrorBehavior.ts @@ -3,7 +3,9 @@ * * - `PROPAGATE`: errors in non-null positions propagate to the closest nullable position. * - `NULL`: errors resolve to null without propagation. - * - `ABORT`: errors propagate as far as possible, typically to the operation root. + * - `ABORT`: errors propagate as far as possible, typically to the operation + * root. With incremental delivery, propagation can stop at an incremental + * delivery boundary. * @experimental * @category Errors */ diff --git a/src/error/__tests__/GraphQLError-test.ts b/src/error/__tests__/GraphQLError-test.ts index 698f281909..d7bf5b5565 100644 --- a/src/error/__tests__/GraphQLError-test.ts +++ b/src/error/__tests__/GraphQLError-test.ts @@ -330,10 +330,12 @@ describe('GraphQLError', () => { `); const path = ['path', 2, 'field']; + const pathNonNull = [false, true, false]; const extensions = { foo: 'bar' }; const eFull = new GraphQLError('msg', { nodes: fieldNode, path, + pathNonNull, extensions, }); @@ -353,6 +355,11 @@ describe('GraphQLError', () => { 2, "field" ], + "pathNonNull": [ + false, + true, + false + ], "extensions": { "foo": "bar" } diff --git a/src/error/__tests__/locatedError-test.ts b/src/error/__tests__/locatedError-test.ts index fd6214e289..7bb06f2fec 100644 --- a/src/error/__tests__/locatedError-test.ts +++ b/src/error/__tests__/locatedError-test.ts @@ -42,7 +42,11 @@ describe('locatedError', () => { e.positions = []; e.name = 'GraphQLError'; + expect(locatedError(e, [], { path: [], pathNonNull: [] })).to.deep.equal(e); + // Test legacy: expect(locatedError(e, [], [])).to.deep.equal(e); + // Test legacy optional: + expect(locatedError(e, [])).to.deep.equal(e); }); it('does not pass through elasticsearch-like errors', () => { @@ -50,6 +54,12 @@ describe('locatedError', () => { // @ts-expect-error e.path = '/something/feed/_search'; + expect( + locatedError(e, [], { path: [], pathNonNull: [] }), + ).to.not.deep.equal(e); + // Test legacy: expect(locatedError(e, [], [])).to.not.deep.equal(e); + // Test legacy optional: + expect(locatedError(e, [])).to.not.deep.equal(e); }); }); diff --git a/src/execution/ExecutionArgs.ts b/src/execution/ExecutionArgs.ts index 7cfef6d0b5..729965779d 100644 --- a/src/execution/ExecutionArgs.ts +++ b/src/execution/ExecutionArgs.ts @@ -42,7 +42,11 @@ export interface ExecutionArgs { /** Resolver used for the root subscription field. */ subscribeFieldResolver?: Maybe>; /** - * Controls execution error handling. + * Set to `"NULL"` to disable error propagation. Set to `"ABORT"` to have + * errors propagate as far as possible, typically to the operation root. + * With incremental delivery, propagation can stop at an incremental delivery + * boundary. + * @defaultValue `"PROPAGATE"` * @experimental */ onError?: GraphQLErrorBehavior; diff --git a/src/execution/__tests__/abstract-test.ts b/src/execution/__tests__/abstract-test.ts index f6c1bb586a..798d90757a 100644 --- a/src/execution/__tests__/abstract-test.ts +++ b/src/execution/__tests__/abstract-test.ts @@ -220,11 +220,13 @@ describe('Execute: Handles execution of abstract types', () => { message: 'We are testing this error', locations: [{ line: 3, column: 9 }], path: ['pets', 0], + pathNonNull: [false, false], }, { message: 'We are testing this error', locations: [{ line: 3, column: 9 }], path: ['pets', 1], + pathNonNull: [false, false], }, ], }); @@ -279,6 +281,7 @@ describe('Execute: Handles execution of abstract types', () => { 'Abstract type "Pet" must resolve to an Object type at runtime for field "Query.pet". Either the "Pet" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.', locations: [{ line: 3, column: 9 }], path: ['pet'], + pathNonNull: [false], }, ], }); @@ -428,11 +431,13 @@ describe('Execute: Handles execution of abstract types', () => { message: 'We are testing this error', locations: [{ line: 3, column: 9 }], path: ['pets', 0], + pathNonNull: [false, false], }, { message: 'We are testing this error', locations: [{ line: 3, column: 9 }], path: ['pets', 1], + pathNonNull: [false, false], }, ], }); @@ -607,6 +612,7 @@ describe('Execute: Handles execution of abstract types', () => { message, locations: [{ line: 3, column: 9 }], path: ['pet'], + pathNonNull: [false], }, ], }); diff --git a/src/execution/__tests__/cancellation-test.ts b/src/execution/__tests__/cancellation-test.ts index 2f77b0a089..54aea81d96 100644 --- a/src/execution/__tests__/cancellation-test.ts +++ b/src/execution/__tests__/cancellation-test.ts @@ -227,6 +227,7 @@ describe('Execute: Cancellation', () => { errors: [ { message: 'Aborted!', + pathNonNull: [false], path: ['blocker'], locations: [{ line: 3, column: 9 }], }, @@ -736,6 +737,7 @@ describe('Execute: Cancellation', () => { { message: 'boom', locations: [{ line: 1, column: 12 }], + pathNonNull: [false, true], path: ['parent', 'boom'], }, ], @@ -813,6 +815,7 @@ describe('Execute: Cancellation', () => { { message: 'boom', locations: [{ line: 1, column: 12 }], + pathNonNull: [false, true], path: ['parent', 'boom'], }, ], @@ -930,6 +933,7 @@ describe('Execute: Cancellation', () => { errors: [ { message: 'This operation was aborted', + pathNonNull: [false], path: ['foo'], locations: [{ line: 3, column: 9 }], }, diff --git a/src/execution/__tests__/errorPropagation-test.ts b/src/execution/__tests__/errorPropagation-test.ts index def2b9d45c..242bc40b7a 100644 --- a/src/execution/__tests__/errorPropagation-test.ts +++ b/src/execution/__tests__/errorPropagation-test.ts @@ -47,6 +47,7 @@ describe('Execute: handles errors', () => { errors: [ { message: 'bar', + pathNonNull: [true], path: ['foo'], locations: [{ line: 3, column: 9 }], }, @@ -65,6 +66,7 @@ describe('Execute: handles errors', () => { errors: [ { message: 'bar', + pathNonNull: [true], path: ['foo'], locations: [{ line: 3, column: 9 }], }, diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 58d3ecc9e7..bad4237eb6 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -598,61 +598,73 @@ describe('Execute: Handles basic execution tasks', () => { message: 'Error getting syncError', locations: [{ line: 4, column: 9 }], path: ['syncError'], + pathNonNull: [false], }, { message: 'Unexpected error value: "Error getting syncRawError"', locations: [{ line: 5, column: 9 }], path: ['syncRawError'], + pathNonNull: [false], }, { message: 'Error getting syncReturnError', locations: [{ line: 6, column: 9 }], path: ['syncReturnError'], + pathNonNull: [false], }, { message: 'Error getting syncReturnErrorList1', locations: [{ line: 7, column: 9 }], path: ['syncReturnErrorList', 1], + pathNonNull: [false, false], }, { message: 'Error getting syncReturnErrorList3', locations: [{ line: 7, column: 9 }], path: ['syncReturnErrorList', 3], + pathNonNull: [false, false], }, { message: 'Error getting asyncReject', locations: [{ line: 9, column: 9 }], path: ['asyncReject'], + pathNonNull: [false], }, { message: 'Unexpected error value: "Error getting asyncRawReject"', locations: [{ line: 10, column: 9 }], path: ['asyncRawReject'], + pathNonNull: [false], }, { message: 'Unexpected error value: undefined', locations: [{ line: 11, column: 9 }], path: ['asyncEmptyReject'], + pathNonNull: [false], }, { message: 'Error getting asyncError', locations: [{ line: 12, column: 9 }], path: ['asyncError'], + pathNonNull: [false], }, { message: 'Unexpected error value: "Error getting asyncRawError"', locations: [{ line: 13, column: 9 }], path: ['asyncRawError'], + pathNonNull: [false], }, { message: 'Error getting asyncReturnError', locations: [{ line: 14, column: 9 }], path: ['asyncReturnError'], + pathNonNull: [false], }, { message: 'Error getting asyncReturnErrorWithExtensions', locations: [{ line: 15, column: 9 }], path: ['asyncReturnErrorWithExtensions'], + pathNonNull: [false], extensions: { foo: 'bar' }, }, ], @@ -698,6 +710,7 @@ describe('Execute: Handles basic execution tasks', () => { locations: [{ column: 9, line: 3 }], message: 'Oops', path: ['foods'], + pathNonNull: [false], }, ], }); @@ -746,6 +759,7 @@ describe('Execute: Handles basic execution tasks', () => { 'Cannot return null for non-nullable field Query.syncNullError.', locations: [{ line: 4, column: 9 }], path: ['syncNullError'], + pathNonNull: [true], }, ], }); @@ -796,12 +810,14 @@ describe('Execute: Handles basic execution tasks', () => { { message: 'Oops', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['asyncError'], }, { message: 'Cannot return null for non-nullable field Query.asyncNonNullError.', locations: [{ line: 4, column: 9 }], + pathNonNull: [true], path: ['asyncNonNullError'], }, ], @@ -866,6 +882,166 @@ describe('Execute: Handles basic execution tasks', () => { message: 'Catch me if you can', locations: [{ line: 7, column: 17 }], path: ['nullableA', 'aliasedA', 'nonNullA', 'anotherA', 'throws'], + pathNonNull: [false, false, true, true, true], + }, + ], + }); + }); + + it('Full response path is included for non-nullable fields with onError:NULL', () => { + const A: GraphQLObjectType = new GraphQLObjectType({ + name: 'A', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + nonNullA: { + type: new GraphQLNonNull(A), + resolve: () => ({}), + }, + throws: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('Catch me if you can'); + }, + }, + }), + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + }), + }), + }); + + const document = parse(` + query { + nullableA { + aliasedA: nullableA { + nonNullA { + anotherA: nonNullA { + throws + } + } + } + } + } + `); + + const result = executeSync({ schema, document, onError: 'NULL' }); + expectJSON(result).toDeepEqual({ + data: { + nullableA: { + aliasedA: { + nonNullA: { + anotherA: { + throws: null, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Catch me if you can', + locations: [{ line: 7, column: 17 }], + path: ['nullableA', 'aliasedA', 'nonNullA', 'anotherA', 'throws'], + pathNonNull: [false, false, true, true, true], + }, + ], + }); + }); + + it('Full response path is included for non-nullable fields with onError:ABORT', () => { + const A: GraphQLObjectType = new GraphQLObjectType({ + name: 'A', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + nonNullA: { + type: new GraphQLNonNull(A), + resolve: () => ({}), + }, + throws: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('Catch me if you can'); + }, + }, + }), + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + }), + }), + }); + + const document = parse(` + query { + nullableA { + aliasedA: nullableA { + nonNullA { + anotherA: nonNullA { + throws + } + } + } + } + } + `); + + const result = executeSync({ schema, document, onError: 'ABORT' }); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Catch me if you can', + locations: [{ line: 7, column: 17 }], + path: ['nullableA', 'aliasedA', 'nonNullA', 'anotherA', 'throws'], + pathNonNull: [false, false, true, true, true], + }, + ], + }); + }); + + it('raises request error with invalid onError', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + a: { + type: GraphQLInt, + }, + }), + }), + }); + + const document = parse('{ a }'); + const result = executeSync({ + schema, + document, + // @ts-expect-error + onError: 'DANCE', + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Unsupported `onError` value; supported values are `NULL`, `PROPAGATE` and `ABORT`.', }, ], }); @@ -1321,6 +1497,7 @@ describe('Execute: Handles basic execution tasks', () => { 'Expected value of type "SpecialType" but got: { value: "bar" }.', locations: [{ line: 1, column: 3 }], path: ['specials', 1], + pathNonNull: [false, false], }, ], }); @@ -1361,6 +1538,7 @@ describe('Execute: Handles basic execution tasks', () => { message: 'Expected `CustomScalar.coerceOutputValue("CUSTOM_VALUE")` to return non-nullable value, returned: undefined', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['customScalar'], }, ], diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index 4db47eba9c..f139b32cd9 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -83,6 +83,7 @@ describe('Execute: Accepts any iterable as list value', () => { 'Expected Iterable, but did not find one for field "Query.listField".', locations: [{ line: 1, column: 3 }], path: ['listField'], + pathNonNull: [false], }, ], }); @@ -113,6 +114,7 @@ describe('Execute: Accepts any iterable as list value', () => { { message: 'bad', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['listField'], }, ], @@ -160,6 +162,7 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { { message: 'bad', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['listField'], }, ], @@ -195,6 +198,7 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], + pathNonNull: [false, true], path: ['listField', 1], }, ], @@ -242,6 +246,7 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { { message: 'bad', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['listField'], }, ], @@ -295,6 +300,7 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], + pathNonNull: [true, true], path: ['listField', 1], }, ], @@ -385,6 +391,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'bad', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['listField'], }, ], @@ -404,6 +411,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'String cannot represent value: {}', locations: [{ line: 1, column: 3 }], + pathNonNull: [false, false], path: ['listField', 1], }, ], @@ -422,6 +430,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'String cannot represent value: {}', locations: [{ line: 1, column: 3 }], + pathNonNull: [false, false], path: ['listField', 1], }, ], @@ -450,6 +459,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'bad', locations: [{ line: 1, column: 15 }], + pathNonNull: [false, false, true], path: ['listField', 2, 'index'], }, ], @@ -477,6 +487,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'also bad', locations: [{ line: 1, column: 15 }], + pathNonNull: [false, true, true], path: ['listField', 1, 'index'], }, ], @@ -498,10 +509,11 @@ describe('Execute: Accepts async iterables as list value', () => { yield await Promise.resolve(null); yield await Promise.resolve(2); } - const errors = [ + const errors = (pathNonNull: ReadonlyArray) => [ { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], + pathNonNull, path: ['listField', 1], }, ]; @@ -514,11 +526,11 @@ describe('Execute: Accepts async iterables as list value', () => { }); expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ data: { listField: null }, - errors, + errors: errors([false, true]), }); expectJSON(await complete({ listField }, '[Int!]!')).toDeepEqual({ data: null, - errors, + errors: errors([true, true]), }); }); @@ -541,6 +553,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], + pathNonNull: [false, true], path: ['listField', 1], }, ]; @@ -565,6 +578,7 @@ describe('Execute: Accepts async iterables as list value', () => { { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], + pathNonNull: [false, true], path: ['listField', 1], }, ]; @@ -627,11 +641,12 @@ describe('Execute: Handles list nullability', () => { it('Contains null', async () => { const listField = [1, null, 2]; - const errors = [ + const errors = (pathNonNull: ReadonlyArray) => [ { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], path: ['listField', 1], + pathNonNull, }, ]; @@ -643,21 +658,22 @@ describe('Execute: Handles list nullability', () => { }); expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({ data: { listField: null }, - errors, + errors: errors([false, true]), }); expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ data: null, - errors, + errors: errors([true, true]), }); }); it('Returns null', async () => { const listField = null; - const errors = [ + const errors = (pathNonNull: ReadonlyArray) => [ { message: 'Cannot return null for non-nullable field Query.listField.', locations: [{ line: 1, column: 3 }], path: ['listField'], + pathNonNull, }, ]; @@ -666,70 +682,72 @@ describe('Execute: Handles list nullability', () => { }); expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({ data: null, - errors, + errors: errors([true]), }); expect(await complete({ listField, as: '[Int!]' })).to.deep.equal({ data: { listField: null }, }); expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ data: null, - errors, + errors: errors([true]), }); }); it('Contains error', async () => { const listField = [1, new Error('bad'), 2]; - const errors = [ + const errors = (pathNonNull: ReadonlyArray) => [ { message: 'bad', locations: [{ line: 1, column: 3 }], path: ['listField', 1], + pathNonNull, }, ]; expectJSON(await complete({ listField, as: '[Int]' })).toDeepEqual({ data: { listField: [1, null, 2] }, - errors, + errors: errors([false, false]), }); expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({ data: { listField: [1, null, 2] }, - errors, + errors: errors([true, false]), }); expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({ data: { listField: null }, - errors, + errors: errors([false, true]), }); expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ data: null, - errors, + errors: errors([true, true]), }); }); it('Results in error', async () => { const listField = new Error('bad'); - const errors = [ + const errors = (pathNonNull: ReadonlyArray) => [ { message: 'bad', locations: [{ line: 1, column: 3 }], path: ['listField'], + pathNonNull, }, ]; expectJSON(await complete({ listField, as: '[Int]' })).toDeepEqual({ data: { listField: null }, - errors, + errors: errors([false]), }); expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({ data: null, - errors, + errors: errors([true]), }); expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({ data: { listField: null }, - errors, + errors: errors([false]), }); expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ data: null, - errors, + errors: errors([true]), }); }); }); diff --git a/src/execution/__tests__/mutations-test.ts b/src/execution/__tests__/mutations-test.ts index fc0e7bd331..c25d97d5ed 100644 --- a/src/execution/__tests__/mutations-test.ts +++ b/src/execution/__tests__/mutations-test.ts @@ -196,11 +196,13 @@ describe('Execute: Handles mutation execution ordering', () => { message: 'Cannot change the number', locations: [{ line: 9, column: 9 }], path: ['third'], + pathNonNull: [false], }, { message: 'Cannot change the number', locations: [{ line: 18, column: 9 }], path: ['sixth'], + pathNonNull: [false], }, ], }); diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 088b365dda..2dcb42f829 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -165,6 +165,7 @@ describe('Execute: handles non-nullable types', () => { { message: syncError.message, path: ['sync'], + pathNonNull: [false], locations: [{ line: 3, column: 9 }], }, ], @@ -190,6 +191,7 @@ describe('Execute: handles non-nullable types', () => { message: 'Cannot return null for non-nullable field DataType.syncNonNull.', path: ['syncNest', 'syncNonNull'], + pathNonNull: [false, true], locations: [{ line: 4, column: 11 }], }, ], @@ -204,6 +206,7 @@ describe('Execute: handles non-nullable types', () => { { message: syncNonNullError.message, path: ['syncNest', 'syncNonNull'], + pathNonNull: [false, true], locations: [{ line: 4, column: 11 }], }, ], @@ -255,61 +258,73 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: syncError.message, + pathNonNull: [false, false], path: ['syncNest', 'sync'], locations: [{ line: 4, column: 11 }], }, { message: syncError.message, + pathNonNull: [false, false, false], path: ['syncNest', 'syncNest', 'sync'], locations: [{ line: 6, column: 22 }], }, { message: promiseError.message, + pathNonNull: [false, false], path: ['syncNest', 'promise'], locations: [{ line: 5, column: 11 }], }, { message: promiseError.message, + pathNonNull: [false, false, false], path: ['syncNest', 'syncNest', 'promise'], locations: [{ line: 6, column: 27 }], }, { message: syncError.message, + pathNonNull: [false, false, false], path: ['syncNest', 'promiseNest', 'sync'], locations: [{ line: 7, column: 25 }], }, { message: syncError.message, + pathNonNull: [false, false], path: ['promiseNest', 'sync'], locations: [{ line: 10, column: 11 }], }, { message: syncError.message, + pathNonNull: [false, false, false], path: ['promiseNest', 'syncNest', 'sync'], locations: [{ line: 12, column: 22 }], }, { message: promiseError.message, + pathNonNull: [false, false, false], path: ['syncNest', 'promiseNest', 'promise'], locations: [{ line: 7, column: 30 }], }, { message: promiseError.message, + pathNonNull: [false, false], path: ['promiseNest', 'promise'], locations: [{ line: 11, column: 11 }], }, { message: promiseError.message, + pathNonNull: [false, false, false], path: ['promiseNest', 'syncNest', 'promise'], locations: [{ line: 12, column: 27 }], }, { message: syncError.message, + pathNonNull: [false, false, false], path: ['promiseNest', 'promiseNest', 'sync'], locations: [{ line: 13, column: 25 }], }, { message: promiseError.message, + pathNonNull: [false, false, false], path: ['promiseNest', 'promiseNest', 'promise'], locations: [{ line: 13, column: 30 }], }, @@ -390,6 +405,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'syncNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 8, column: 19 }], }, { @@ -403,6 +419,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'syncNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 19, column: 19 }], }, { @@ -416,6 +433,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'promiseNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 30, column: 19 }], }, { @@ -429,6 +447,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'promiseNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 41, column: 19 }], }, ], @@ -450,6 +469,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'syncNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 8, column: 19 }], }, { @@ -462,6 +482,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'syncNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 19, column: 19 }], }, { @@ -474,6 +495,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'promiseNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 30, column: 19 }], }, { @@ -486,6 +508,7 @@ describe('Execute: handles non-nullable types', () => { 'promiseNonNullNest', 'promiseNonNull', ], + pathNonNull: [false, true, true, true, true, true], locations: [{ line: 41, column: 19 }], }, ], @@ -509,6 +532,7 @@ describe('Execute: handles non-nullable types', () => { message: 'Cannot return null for non-nullable field DataType.syncNonNull.', path: ['syncNonNull'], + pathNonNull: [true], locations: [{ line: 3, column: 9 }], }, ], @@ -523,6 +547,7 @@ describe('Execute: handles non-nullable types', () => { { message: syncNonNullError.message, path: ['syncNonNull'], + pathNonNull: [true], locations: [{ line: 3, column: 9 }], }, ], @@ -550,11 +575,13 @@ describe('Execute: handles non-nullable types', () => { { message: syncNonNullError.message, path: ['promiseNest', 'syncNonNull'], + pathNonNull: [false, true], locations: [{ line: 7, column: 13 }], }, { message: syncNonNullError.message, path: ['promiseNonNullNest', 'syncNonNull'], + pathNonNull: [true, true], locations: [{ line: 4, column: 13 }], }, ], @@ -580,6 +607,7 @@ describe('Execute: handles non-nullable types', () => { { message: syncNonNullError.message, path: ['promiseNonNullNest', 'syncNonNull'], + pathNonNull: [true, true], locations: [{ line: 4, column: 13 }], }, ], @@ -616,11 +644,13 @@ describe('Execute: handles non-nullable types', () => { { message: syncNonNullError.message, path: ['syncNest', 'promiseNest', 'syncNonNull'], + pathNonNull: [false, false, true], locations: [{ line: 8, column: 15 }], }, { message: syncNonNullError.message, path: ['syncNest', 'promiseNonNullNest', 'syncNonNull'], + pathNonNull: [false, true, true], locations: [{ line: 5, column: 15 }], }, ], @@ -652,6 +682,7 @@ describe('Execute: handles non-nullable types', () => { { message: syncNonNullError.message, path: ['syncNest', 'promiseNonNullNest', 'syncNonNull'], + pathNonNull: [false, true, true], locations: [{ line: 5, column: 15 }], }, ], @@ -702,6 +733,7 @@ describe('Execute: handles non-nullable types', () => { errors: [ { message: syncNonNullError.message, + pathNonNull: [false, true], path: ['syncNest', 'syncNonNull'], locations: [{ line: 4, column: 13 }], }, @@ -806,6 +838,7 @@ describe('Execute: handles non-nullable types', () => { message: 'Argument "Query.withNonNullArg(cannotBeNull:)" of required type "String!" was not provided.', locations: [{ line: 3, column: 13 }], + pathNonNull: [false], path: ['withNonNullArg'], }, ], @@ -833,6 +866,7 @@ describe('Execute: handles non-nullable types', () => { message: 'Argument "Query.withNonNullArg(cannotBeNull:)" has invalid value: Expected value of non-null type "String!" not to be null.', locations: [{ line: 3, column: 42 }], + pathNonNull: [false], path: ['withNonNullArg'], }, ], @@ -863,6 +897,7 @@ describe('Execute: handles non-nullable types', () => { message: 'Argument "Query.withNonNullArg(cannotBeNull:)" has invalid value: Expected variable "$testVar" provided to type "String!" to provide a runtime value.', locations: [{ line: 3, column: 42 }], + pathNonNull: [false], path: ['withNonNullArg'], }, ], @@ -891,6 +926,7 @@ describe('Execute: handles non-nullable types', () => { message: 'Argument "Query.withNonNullArg(cannotBeNull:)" has invalid value: Expected variable "$testVar" provided to non-null type "String!" not to be null.', locations: [{ line: 3, column: 43 }], + pathNonNull: [false], path: ['withNonNullArg'], }, ], diff --git a/src/execution/__tests__/oneof-test.ts b/src/execution/__tests__/oneof-test.ts index 892c599b19..ffee9dfea9 100644 --- a/src/execution/__tests__/oneof-test.ts +++ b/src/execution/__tests__/oneof-test.ts @@ -225,6 +225,7 @@ describe('Execute: Handles OneOf Input Objects', () => { message: 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.', locations: [{ line: 3, column: 23 }], + pathNonNull: [false], path: ['test'], }, ], @@ -253,6 +254,7 @@ describe('Execute: Handles OneOf Input Objects', () => { message: 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.', locations: [{ line: 3, column: 23 }], + pathNonNull: [false], path: ['test'], }, ], @@ -279,6 +281,7 @@ describe('Execute: Handles OneOf Input Objects', () => { message: 'Argument "Query.test(input:)" has invalid value: Expected variable "$b" provided to field "b" for OneOf Input Object type "TestInputObject" to provide a runtime value.', locations: [{ line: 3, column: 23 }], + pathNonNull: [false], path: ['test'], }, ], @@ -310,6 +313,7 @@ describe('Execute: Handles OneOf Input Objects', () => { message: 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.', locations: [{ line: 6, column: 23 }], + pathNonNull: [false], path: ['test'], }, ], @@ -341,6 +345,7 @@ describe('Execute: Handles OneOf Input Objects', () => { message: 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.', locations: [{ line: 6, column: 23 }], + pathNonNull: [false], path: ['test'], }, ], diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index 25751d1136..1b7c34b5c0 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -553,6 +553,7 @@ describe('Subscription Initialization Phase', () => { message: 'Subscription field must return Async Iterable. Received: "test".', locations: [{ line: 1, column: 16 }], + pathNonNull: [false], path: ['foo'], }, ], @@ -574,6 +575,7 @@ describe('Subscription Initialization Phase', () => { message: 'test error', locations: [{ line: 1, column: 16 }], path: ['foo'], + pathNonNull: [false], }, ], }; @@ -841,6 +843,7 @@ describe('Subscription Publish Phase', () => { message: '`@defer` directive not supported on subscription operations. Disable `@defer` by setting the `if` argument to `false`.', locations: [{ line: 8, column: 7 }], + pathNonNull: [false], path: ['importantEmail'], }, ], @@ -904,6 +907,7 @@ describe('Subscription Publish Phase', () => { message: '`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.', locations: [{ line: 18, column: 13 }], + pathNonNull: [false, false, false], path: ['importantEmail', 'inbox', 'emails'], }, ], @@ -935,6 +939,7 @@ describe('Subscription Publish Phase', () => { message: '`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.', locations: [{ line: 18, column: 13 }], + pathNonNull: [false, false, false], path: ['importantEmail', 'inbox', 'emails'], }, ], @@ -1241,6 +1246,7 @@ describe('Subscription Publish Phase', () => { message: 'Never leave.', locations: [{ line: 1, column: 16 }], path: ['newMessage'], + pathNonNull: [false], }, ], }, diff --git a/src/execution/__tests__/variables-test.ts b/src/execution/__tests__/variables-test.ts index bc2a2660e9..c150d3532d 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -336,6 +336,7 @@ describe('Execute: Handles inputs', () => { { message: 'Argument "TestType.fieldWithObjectInput(input:)" has invalid value: Expected value of type "TestInputObject" to be an object, found: ["foo", "bar", "baz"].', + pathNonNull: [false], path: ['fieldWithObjectInput'], locations: [{ line: 3, column: 41 }], }, @@ -372,6 +373,7 @@ describe('Execute: Handles inputs', () => { { message: 'Argument "TestType.fieldWithObjectInput(input:)" has invalid value at .e: FaultyScalarErrorMessage', + pathNonNull: [false], path: ['fieldWithObjectInput'], locations: [{ line: 3, column: 13 }], extensions: { code: 'FaultyScalarErrorExtensionCode' }, @@ -958,6 +960,7 @@ describe('Execute: Handles inputs', () => { message: 'Argument "TestType.fieldWithNonNullableStringInput(input:)" of required type "String!" was not provided.', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['fieldWithNonNullableStringInput'], }, ], @@ -1006,6 +1009,7 @@ describe('Execute: Handles inputs', () => { message: 'Argument "TestType.fieldWithNonNullableStringInput(input:)" has invalid value: Expected variable "$foo" provided to type "String!" to provide a runtime value.', locations: [{ line: 3, column: 50 }], + pathNonNull: [false], path: ['fieldWithNonNullableStringInput'], }, ], @@ -1346,6 +1350,7 @@ describe('Execute: Handles inputs', () => { message: 'Argument "TestType.fieldWithDefaultArgumentValue(input:)" has invalid value: String cannot represent a non string value: WRONG_TYPE', locations: [{ line: 3, column: 48 }], + pathNonNull: [false], path: ['fieldWithDefaultArgumentValue'], }, ], @@ -1401,6 +1406,7 @@ describe('Execute: Handles inputs', () => { message: 'Argument "TestTypeWithInvalidDefaultArgumentValue.fieldWithInvalidDefaultArgumentValue(input:)" has invalid default value: String cannot represent a non string value: 123', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['fieldWithInvalidDefaultArgumentValue'], }, ], @@ -1427,6 +1433,7 @@ describe('Execute: Handles inputs', () => { message: 'Argument "TestTypeWithInvalidNestedDefaultArgumentValue.fieldWithInvalidNestedDefaultArgumentValue(input:)" has invalid default value: Expected value of type "String" to be valid, found: 123.', locations: [{ line: 1, column: 3 }], + pathNonNull: [false], path: ['fieldWithInvalidNestedDefaultArgumentValue'], }, ], diff --git a/src/execution/incremental/__tests__/defer-test.ts b/src/execution/incremental/__tests__/defer-test.ts index 4b9676398c..b8bc35d443 100644 --- a/src/execution/incremental/__tests__/defer-test.ts +++ b/src/execution/incremental/__tests__/defer-test.ts @@ -571,6 +571,7 @@ describe('Execute: defer directive', () => { { message: 'bad', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, false], path: ['hero', 'name'], }, ], @@ -1729,6 +1730,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 8, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -1802,6 +1804,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 17, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -1864,6 +1867,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field a.nonNullErrorField.', locations: [{ line: 6, column: 13 }], + pathNonNull: [false, true], path: ['a', 'nonNullErrorField'], }, ], @@ -1952,6 +1956,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 8, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -2009,6 +2014,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 7, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'someError'], }, ], @@ -2020,6 +2026,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 16, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'anotherError'], }, ], @@ -2098,6 +2105,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 19, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'someC', 'someError'], }, ], @@ -2186,6 +2194,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 8, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -2226,6 +2235,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2264,6 +2274,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2306,6 +2317,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field a.nonNullErrorField.', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, true], path: ['a', 'nonNullErrorField'], }, ], @@ -2402,6 +2414,7 @@ describe('Execute: defer directive', () => { { message: 'boom', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['parent', 'boom'], }, ], @@ -2481,6 +2494,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2566,6 +2580,7 @@ describe('Execute: defer directive', () => { { message: 'boom', locations: [{ line: 8, column: 13 }], + pathNonNull: [false, true], path: ['parent', 'boom'], }, ], @@ -2837,6 +2852,7 @@ describe('Execute: defer directive', () => { { message: 'bad', locations: [{ line: 9, column: 9 }], + pathNonNull: [false, false], path: ['hero', 'name'], }, ], @@ -2880,6 +2896,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 9, column: 9 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2918,6 +2935,7 @@ describe('Execute: defer directive', () => { column: 11, }, ], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2959,6 +2977,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 9, column: 9 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -3110,6 +3129,7 @@ describe('Execute: defer directive', () => { message: 'Cannot return null for non-nullable field Friend.nonNullName.', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, false, false, true], path: ['hero', 'friends', 0, 'nonNullName'], }, ], diff --git a/src/execution/incremental/__tests__/stream-test.ts b/src/execution/incremental/__tests__/stream-test.ts index 5a1d1009f9..c266b0aaeb 100644 --- a/src/execution/incremental/__tests__/stream-test.ts +++ b/src/execution/incremental/__tests__/stream-test.ts @@ -292,6 +292,7 @@ describe('Execute: stream directive', () => { column: 3, }, ], + pathNonNull: [false], path: ['scalarList'], }, ], @@ -697,6 +698,7 @@ describe('Execute: stream directive', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, false], path: ['friendList', 1], }, ], @@ -753,6 +755,7 @@ describe('Execute: stream directive', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, false], path: ['friendList', 1], }, ], @@ -922,6 +925,7 @@ describe('Execute: stream directive', () => { { message: 'initialCount must be a positive integer', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['friendList'], }, ], @@ -1115,6 +1119,7 @@ describe('Execute: stream directive', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['friendList'], }, ], @@ -1154,6 +1159,7 @@ describe('Execute: stream directive', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['friendList'], }, ], @@ -1192,6 +1198,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, true], path: ['nonNullFriendList', 1], }, ], @@ -1239,6 +1246,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, true], path: ['nonNullFriendList', 1], }, ], @@ -1307,6 +1315,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, true], path: ['nonNullFriendList', 1], }, ], @@ -1345,6 +1354,7 @@ describe('Execute: stream directive', () => { { message: 'String cannot represent value: {}', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, false], path: ['scalarList', 1], }, ], @@ -1389,6 +1399,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1440,6 +1451,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1541,6 +1553,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1603,6 +1616,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1643,6 +1657,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1686,6 +1701,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1737,6 +1753,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1800,6 +1817,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1873,6 +1891,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1908,6 +1927,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['nestedObject', 'nonNullScalarField'], }, ], @@ -1941,6 +1961,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, true], path: ['nestedObject', 'nonNullScalarField'], }, ], @@ -1993,6 +2014,7 @@ describe('Execute: stream directive', () => { { message: 'Oops', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, false], path: ['otherNestedObject', 'scalarField'], }, ], @@ -2055,6 +2077,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, true], path: ['nestedObject', 'nonNullScalarField'], }, ], @@ -2110,6 +2133,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', locations: [{ line: 6, column: 15 }], + pathNonNull: [false, false, true], path: [ 'nestedObject', 'deeperNestedObject', @@ -2161,6 +2185,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field Friend.nonNullName.', locations: [{ line: 4, column: 9 }], + pathNonNull: [false, false, true], path: ['friendList', 0, 'nonNullName'], }, ], @@ -2256,6 +2281,7 @@ describe('Execute: stream directive', () => { message: 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', locations: [{ line: 6, column: 15 }], + pathNonNull: [false, false, true], path: [ 'nestedObject', 'deeperNestedObject', diff --git a/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts b/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts index 3a8872aef4..d3c7e5ee04 100644 --- a/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts +++ b/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts @@ -559,6 +559,7 @@ describe('Execute: defer directive (legacy)', () => { { message: 'bad', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, false], path: ['hero', 'name'], }, ], @@ -1661,6 +1662,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 8, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -1718,6 +1720,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 17, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -1781,6 +1784,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field a.nonNullErrorField.', locations: [{ line: 6, column: 13 }], + pathNonNull: [false, true], path: ['a', 'nonNullErrorField'], }, ], @@ -1853,6 +1857,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 8, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -1910,6 +1915,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 7, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'someError'], }, ], @@ -1922,6 +1928,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 16, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'anotherError'], }, ], @@ -1967,6 +1974,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 7, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'someError'], }, ], @@ -2045,6 +2053,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field c.nonNullErrorField.', locations: [{ line: 8, column: 17 }], + pathNonNull: [false, false, false, true], path: ['a', 'b', 'c', 'nonNullErrorField'], }, ], @@ -2085,6 +2094,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2124,6 +2134,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2177,6 +2188,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field a.nonNullErrorField.', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, true], path: ['a', 'nonNullErrorField'], }, ], @@ -2271,6 +2283,7 @@ describe('Execute: defer directive (legacy)', () => { { message: 'boom', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['parent', 'boom'], }, ], @@ -2347,6 +2360,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2430,6 +2444,7 @@ describe('Execute: defer directive (legacy)', () => { { message: 'boom', locations: [{ line: 8, column: 13 }], + pathNonNull: [false, true], path: ['parent', 'boom'], }, ], @@ -2761,6 +2776,7 @@ describe('Execute: defer directive (legacy)', () => { { message: 'bad', locations: [{ line: 9, column: 9 }], + pathNonNull: [false, false], path: ['hero', 'name'], }, ], @@ -2804,6 +2820,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 9, column: 9 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2842,6 +2859,7 @@ describe('Execute: defer directive (legacy)', () => { column: 11, }, ], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -2883,6 +2901,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field Hero.nonNullName.', locations: [{ line: 9, column: 9 }], + pathNonNull: [false, true], path: ['hero', 'nonNullName'], }, ], @@ -3020,6 +3039,7 @@ describe('Execute: defer directive (legacy)', () => { message: 'Cannot return null for non-nullable field Friend.nonNullName.', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, false, false, true], path: ['hero', 'friends', 0, 'nonNullName'], }, ], diff --git a/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts b/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts index 12a31a9a8d..ba45532493 100644 --- a/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts +++ b/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts @@ -253,6 +253,7 @@ describe('Execute: stream directive (legacy)', () => { column: 3, }, ], + pathNonNull: [false], path: ['scalarList'], }, ], @@ -641,6 +642,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, false], path: ['friendList', 1], }, ], @@ -693,6 +695,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, false], path: ['friendList', 1], }, ], @@ -814,6 +817,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'initialCount must be a positive integer', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['friendList'], }, ], @@ -1001,6 +1005,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['friendList'], }, ], @@ -1040,6 +1045,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'bad', locations: [{ line: 3, column: 9 }], + pathNonNull: [false], path: ['friendList'], }, ], @@ -1078,6 +1084,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, true], path: ['nonNullFriendList', 1], }, ], @@ -1125,6 +1132,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field Query.nonNullFriendList.', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, true], path: ['nonNullFriendList', 1], }, ], @@ -1158,6 +1166,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'String cannot represent value: {}', locations: [{ line: 3, column: 9 }], + pathNonNull: [false, false], path: ['scalarList', 1], }, ], @@ -1200,6 +1209,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1249,6 +1259,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1349,6 +1360,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1411,6 +1423,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1451,6 +1464,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1492,6 +1506,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, false, true], path: ['friendList', 1, 'nonNullName'], }, ], @@ -1543,6 +1558,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1606,6 +1622,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1679,6 +1696,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true, true], path: ['nonNullFriendList', 1, 'nonNullName'], }, ], @@ -1714,6 +1732,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', locations: [{ line: 4, column: 11 }], + pathNonNull: [false, true], path: ['nestedObject', 'nonNullScalarField'], }, ], @@ -1747,6 +1766,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, true], path: ['nestedObject', 'nonNullScalarField'], }, ], @@ -1794,6 +1814,7 @@ describe('Execute: stream directive (legacy)', () => { { message: 'Oops', locations: [{ line: 5, column: 13 }], + pathNonNull: [false, false], path: ['otherNestedObject', 'scalarField'], }, ], @@ -1856,6 +1877,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', locations: [{ line: 7, column: 11 }], + pathNonNull: [false, true], path: ['nestedObject', 'nonNullScalarField'], }, ], @@ -1909,6 +1931,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', locations: [{ line: 6, column: 15 }], + pathNonNull: [false, false, true], path: [ 'nestedObject', 'deeperNestedObject', @@ -1958,6 +1981,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field Friend.nonNullName.', locations: [{ line: 4, column: 9 }], + pathNonNull: [false, false, true], path: ['friendList', 0, 'nonNullName'], }, ], @@ -2051,6 +2075,7 @@ describe('Execute: stream directive (legacy)', () => { message: 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', locations: [{ line: 6, column: 15 }], + pathNonNull: [false, false, true], path: [ 'nestedObject', 'deeperNestedObject', diff --git a/src/type/__tests__/enumType-test.ts b/src/type/__tests__/enumType-test.ts index 7c4d0e13ca..d548510768 100644 --- a/src/type/__tests__/enumType-test.ts +++ b/src/type/__tests__/enumType-test.ts @@ -250,6 +250,7 @@ describe('Type System: Enum Values', () => { message: 'Enum "Color" cannot represent value: "GREEN"', locations: [{ line: 1, column: 3 }], path: ['colorEnum'], + pathNonNull: [false], }, ], }); @@ -450,6 +451,7 @@ describe('Type System: Enum Values', () => { 'Enum "Complex" cannot represent value: { someRandomValue: 123 }', locations: [{ line: 6, column: 9 }], path: ['bad'], + pathNonNull: [false], }, ], }); From 661c26b3778d0eb5c2cda23a11f7f91c8fb62389 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:11:52 +0100 Subject: [PATCH 03/12] Handle strict properties --- src/error/locatedError.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/error/locatedError.ts b/src/error/locatedError.ts index cb61212415..d8f1ed14c1 100644 --- a/src/error/locatedError.ts +++ b/src/error/locatedError.ts @@ -8,6 +8,8 @@ import type { ASTNode } from '../language/ast.ts'; import { GraphQLError } from './GraphQLError.ts'; +type Optional = { [K in keyof T]: T[K] | undefined }; + /** * Given an arbitrary value, presumably thrown while attempting to execute a * GraphQL operation, produce a new GraphQLError aware of the location in the @@ -70,12 +72,12 @@ export function locatedError( return originalError; } - const digest: Partial = + const digest: Optional = digestOrPath == null - ? {} - : Array.isArray(digestOrPath) - ? { path: digestOrPath as ReadonlyArray } - : (digestOrPath as PathDigest); + ? { path: undefined, pathNonNull: undefined } + : 'length' in digestOrPath + ? { path: digestOrPath, pathNonNull: undefined } + : digestOrPath; return new GraphQLError(originalError.message, { nodes: (originalError as GraphQLError).nodes ?? nodes, source: (originalError as GraphQLError).source, From 449e49d4f980cf63aca3c67d43ce956115882e09 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:12:24 +0100 Subject: [PATCH 04/12] Destructure --- src/error/locatedError.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/error/locatedError.ts b/src/error/locatedError.ts index d8f1ed14c1..888567fd2f 100644 --- a/src/error/locatedError.ts +++ b/src/error/locatedError.ts @@ -78,12 +78,13 @@ export function locatedError( : 'length' in digestOrPath ? { path: digestOrPath, pathNonNull: undefined } : digestOrPath; + const { path, pathNonNull } = digest; return new GraphQLError(originalError.message, { nodes: (originalError as GraphQLError).nodes ?? nodes, source: (originalError as GraphQLError).source, positions: (originalError as GraphQLError).positions, - path: digest.path, - pathNonNull: digest.pathNonNull, + path, + pathNonNull, originalError, }); } From ba360ca26abe0ccf9a3465b98d0d0caeac3d3e39 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:12:58 +0100 Subject: [PATCH 05/12] No need for intermediate variable --- src/error/locatedError.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/error/locatedError.ts b/src/error/locatedError.ts index 888567fd2f..7fa702d196 100644 --- a/src/error/locatedError.ts +++ b/src/error/locatedError.ts @@ -72,13 +72,12 @@ export function locatedError( return originalError; } - const digest: Optional = + const { path, pathNonNull }: Optional = digestOrPath == null ? { path: undefined, pathNonNull: undefined } : 'length' in digestOrPath ? { path: digestOrPath, pathNonNull: undefined } : digestOrPath; - const { path, pathNonNull } = digest; return new GraphQLError(originalError.message, { nodes: (originalError as GraphQLError).nodes ?? nodes, source: (originalError as GraphQLError).source, From 7ca89ca8406e935c3060ebd365fc11d842edfb38 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:23:05 +0100 Subject: [PATCH 06/12] Fix parts that codex ported badly --- src/execution/Executor.ts | 48 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 48d0216d14..5da2338a29 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -24,6 +24,7 @@ import { OperationTypeNode } from '../language/ast.ts'; import type { GraphQLAbstractType, + GraphQLField, GraphQLLeafType, GraphQLList, GraphQLObjectType, @@ -75,6 +76,7 @@ import { returnIteratorCatchingErrors } from './returnIteratorCatchingErrors.ts' import { getArgumentValues } from './values.ts'; /* eslint-disable max-params */ +/* eslint-disable @typescript-eslint/no-empty-object-type */ // This file contains a lot of such errors but we plan to refactor it anyway // so just disable it for entire file. @@ -500,23 +502,24 @@ export class Executor< parentType, fieldDetailsList[0].node.name.value, ); + if (fieldDef == null) { + return results; + } const fieldPath = addPath( path, responseName, parentType.name, - fieldDef != null && isNonNullType(fieldDef.type), + isNonNullType(fieldDef.type), ); const result = this.executeField( parentType, sourceValue, fieldDetailsList, fieldPath, + fieldDef, positionContext, tracingChannel, ); - if (result === undefined) { - return results; - } if (isPromise(result)) { return result.then((resolved) => { results[responseName] = resolved; @@ -558,26 +561,28 @@ export class Executor< parentType, fieldDetailsList[0].node.name.value, ); + if (fieldDef == null) { + continue; + } const fieldPath = addPath( path, responseName, parentType.name, - fieldDef != null && isNonNullType(fieldDef.type), + isNonNullType(fieldDef.type), ); const result = this.executeField( parentType, sourceValue, fieldDetailsList, fieldPath, + fieldDef, positionContext, tracingChannel, ); - if (result !== undefined) { - results[responseName] = result; - if (isPromise(result)) { - containsPromise = true; - } + results[responseName] = result; + if (isPromise(result)) { + containsPromise = true; } } } catch (error) { @@ -613,20 +618,15 @@ export class Executor< source: unknown, fieldDetailsList: FieldDetailsList, path: Path, + fieldDef: GraphQLField, positionContext: TPositionContext | undefined, tracingChannel: MinimalTracingChannel | undefined, - ): PromiseOrValue { + ): PromiseOrValue<{} | null> { const validatedExecutionArgs = this.validatedExecutionArgs; - const { schema, contextValue, variableValues, hideSuggestions } = + const { contextValue, variableValues, hideSuggestions } = validatedExecutionArgs; const firstFieldDetails = fieldDetailsList[0]; const firstNode = firstFieldDetails.node; - const fieldName = firstNode.name.value; - const fieldDef = schema.getField(parentType, fieldName); - if (!fieldDef) { - return; - } - const returnType = fieldDef.type; let resolveFn = fieldDef.resolve ?? validatedExecutionArgs.fieldResolver; @@ -786,7 +786,7 @@ export class Executor< path: Path, result: unknown, positionContext: TPositionContext | undefined, - ): PromiseOrValue { + ): PromiseOrValue<{} | null> { // If result is an Error, throw a located error. if (result instanceof Error) { throw result; @@ -873,7 +873,7 @@ export class Executor< path: Path, result: PromiseLike, positionContext: TPositionContext | undefined, - ): Promise { + ): Promise<{} | null> { try { const resolved = await result; if (this.aborted) { @@ -889,7 +889,7 @@ export class Executor< ); if (isPromise(completed)) { - completed = await completed; + completed = (await completed) as {} | null; } return completed; } catch (rawError) { @@ -1233,7 +1233,7 @@ export class Executor< info: GraphQLResolveInfo, itemPath: Path, positionContext: TPositionContext | undefined, - ): Promise { + ): Promise<{} | null> { try { const resolved = await item; if (this.aborted) { @@ -1248,7 +1248,7 @@ export class Executor< positionContext, ); if (isPromise(completed)) { - completed = await completed; + completed = (await completed) as {} | null; } return completed; } catch (rawError) { @@ -1263,7 +1263,7 @@ export class Executor< * * @internal */ - completeLeafValue(returnType: GraphQLLeafType, result: unknown): unknown { + completeLeafValue(returnType: GraphQLLeafType, result: unknown): {} { const coerced = returnType.coerceOutputValue(result); if (coerced == null) { throw new Error( From 6e82bb8200425188ddf99b3bbeb29cba9c89e7b1 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:25:50 +0100 Subject: [PATCH 07/12] Neater --- src/execution/Executor.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 5da2338a29..87a9fafe51 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -498,9 +498,11 @@ export class Executor< if (this.aborted) { throw new Error('Aborted!'); } + const firstFieldDetails = fieldDetailsList[0]; + const firstNode = firstFieldDetails.node; const fieldDef = this.validatedExecutionArgs.schema.getField( parentType, - fieldDetailsList[0].node.name.value, + firstNode.name.value, ); if (fieldDef == null) { return results; @@ -557,9 +559,11 @@ export class Executor< try { for (const [responseName, fieldDetailsList] of groupedFieldSet) { + const firstFieldDetails = fieldDetailsList[0]; + const firstNode = firstFieldDetails.node; const fieldDef = this.validatedExecutionArgs.schema.getField( parentType, - fieldDetailsList[0].node.name.value, + firstNode.name.value, ); if (fieldDef == null) { continue; From c7959e705473829752e725588e2640758c884f92 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:29:36 +0100 Subject: [PATCH 08/12] Remove unnecessary cast --- src/execution/Executor.ts | 4 ++-- src/jsutils/isPromise.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 87a9fafe51..651bf5001d 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -893,7 +893,7 @@ export class Executor< ); if (isPromise(completed)) { - completed = (await completed) as {} | null; + completed = await completed; } return completed; } catch (rawError) { @@ -1252,7 +1252,7 @@ export class Executor< positionContext, ); if (isPromise(completed)) { - completed = (await completed) as {} | null; + completed = await completed; } return completed; } catch (rawError) { diff --git a/src/jsutils/isPromise.ts b/src/jsutils/isPromise.ts index bfeaca298b..49c6a2f75e 100644 --- a/src/jsutils/isPromise.ts +++ b/src/jsutils/isPromise.ts @@ -1,5 +1,5 @@ /** @internal */ -export function isPromise(value: unknown): value is Promise { +export function isPromise(value: T | Promise): value is Promise { return value instanceof Promise; } From 9da264ae3e1abb96306dbbf55fc21392be18d074 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:31:14 +0100 Subject: [PATCH 09/12] Compute once --- src/execution/Executor.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 651bf5001d..416482d0c0 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -927,6 +927,7 @@ export class Executor< const asyncIterator = items[Symbol.asyncIterator](); let index = 0; let iteration; + const itemTypeIsNonNull = isNonNullType(itemType); try { while (true) { if ( @@ -942,12 +943,7 @@ export class Executor< ) { break; } - const itemPath = addPath( - path, - index, - undefined, - isNonNullType(itemType), - ); + const itemPath = addPath(path, index, undefined, itemTypeIsNonNull); try { // eslint-disable-next-line no-await-in-loop iteration = await asyncIterator.next(); From 81eca0d9b52a6a7e011cb782aeaa69cb40b0710f Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:32:19 +0100 Subject: [PATCH 10/12] Another early compute and cache --- src/execution/Executor.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 416482d0c0..0a1419046e 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -1077,6 +1077,7 @@ export class Executor< const completedResults: Array = []; let index = 0; const iterator = items[Symbol.iterator](); + const itemTypeIsNonNull = isNonNullType(itemType); try { while (true) { if ( @@ -1101,12 +1102,7 @@ export class Executor< // No need to modify the info object containing the path, // since from here on it is not ever accessed by resolver functions. - const itemPath = addPath( - path, - index, - undefined, - isNonNullType(itemType), - ); + const itemPath = addPath(path, index, undefined, itemTypeIsNonNull); if ( this.completeMaybePromisedListItemValue( From db52508252758ac1c8d3dd7948f2a5917fd827cf Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:44:59 +0100 Subject: [PATCH 11/12] Explicit nonNull for path --- src/execution/incremental/IncrementalExecutor.ts | 2 ++ .../BranchingIncrementalPublisher.ts | 4 +++- src/jsutils/Path.ts | 2 +- src/jsutils/__tests__/Path-test.ts | 10 +++++----- src/utilities/validateInputValue.ts | 12 ++++++------ 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/execution/incremental/IncrementalExecutor.ts b/src/execution/incremental/IncrementalExecutor.ts index 7401463d87..5460d78e99 100644 --- a/src/execution/incremental/IncrementalExecutor.ts +++ b/src/execution/incremental/IncrementalExecutor.ts @@ -358,6 +358,7 @@ export interface ItemStream extends Stream< ItemStream > { path: Path; + itemTypeNonNull: boolean; label: string | undefined; initialCount: number; } @@ -818,6 +819,7 @@ export class IncrementalExecutor< const itemStream: ItemStream = { label: streamUsage.label, path, + itemTypeNonNull: isNonNullType(itemType), queue, initialCount: index, }; diff --git a/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts b/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts index c02e0d2b27..37ae6e0521 100644 --- a/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts +++ b/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts @@ -182,7 +182,9 @@ export class BranchingIncrementalPublisher { buildIncrementalResult( { items, - path: pathToArray(addPath(stream.path, index, undefined)), + path: pathToArray( + addPath(stream.path, index, undefined, stream.itemTypeNonNull), + ), }, stream.label, errors.length > 0 ? errors : undefined, diff --git a/src/jsutils/Path.ts b/src/jsutils/Path.ts index 53bada69b8..3dfbb34f6d 100644 --- a/src/jsutils/Path.ts +++ b/src/jsutils/Path.ts @@ -33,7 +33,7 @@ export function addPath( prev: Readonly | undefined, key: string | number, typename: string | undefined, - nonNull = false, + nonNull: boolean, ): Path { return { prev, key, typename, nonNull }; } diff --git a/src/jsutils/__tests__/Path-test.ts b/src/jsutils/__tests__/Path-test.ts index 2d6eaf5158..7708b7d29c 100644 --- a/src/jsutils/__tests__/Path-test.ts +++ b/src/jsutils/__tests__/Path-test.ts @@ -6,7 +6,7 @@ import { addPath, pathToArray, pathToDigest } from '../Path.ts'; describe('Path', () => { it('can create a Path', () => { - const first = addPath(undefined, 1, 'First'); + const first = addPath(undefined, 1, 'First', false); expect(first).to.deep.equal({ prev: undefined, @@ -17,7 +17,7 @@ describe('Path', () => { }); it('can add a new key to an existing Path', () => { - const first = addPath(undefined, 1, 'First'); + const first = addPath(undefined, 1, 'First', false); const second = addPath(first, 'two', 'Second', true); expect(second).to.deep.equal({ @@ -29,9 +29,9 @@ describe('Path', () => { }); it('can convert a Path to an array of its keys', () => { - const root = addPath(undefined, 0, 'Root'); - const first = addPath(root, 'one', 'First'); - const second = addPath(first, 2, 'Second'); + const root = addPath(undefined, 0, 'Root', false); + const first = addPath(root, 'one', 'First', false); + const second = addPath(first, 2, 'Second', false); const path = pathToArray(second); expect(path).to.deep.equal([0, 'one', 2]); diff --git a/src/utilities/validateInputValue.ts b/src/utilities/validateInputValue.ts index 322c4666ab..8b5c416db1 100644 --- a/src/utilities/validateInputValue.ts +++ b/src/utilities/validateInputValue.ts @@ -159,7 +159,7 @@ function validateInputValueImpl( type.ofType, onError, hideSuggestions, - addPath(path, index++, undefined), + addPath(path, index++, undefined, false), ); } } @@ -195,7 +195,7 @@ function validateInputValueImpl( field.type, onError, hideSuggestions, - addPath(path, field.name, type.name), + addPath(path, field.name, type.name, false), ); } } @@ -237,7 +237,7 @@ function validateInputValueImpl( reportInvalidValue( onError, getOneOfInputObjectErrorMessage(type), - addPath(path, field, type.name), + addPath(path, field, type.name, false), ); } } @@ -467,7 +467,7 @@ function validateInputLiteralImpl( itemNode, type.ofType, hideSuggestions, - addPath(path, index++, undefined), + addPath(path, index++, undefined, false), ); } } @@ -535,7 +535,7 @@ function validateInputLiteralImpl( fieldValueNode, field.type, hideSuggestions, - addPath(path, field.name, type.name), + addPath(path, field.name, type.name, false), ); } } @@ -581,7 +581,7 @@ function validateInputLiteralImpl( context.onError, getOneOfInputObjectErrorMessage(type), valueNode, - addPath(path, fieldName, undefined), + addPath(path, fieldName, undefined, false), ); } } From 72b7485d3a5b6efebec3c2453a06ad2a58469706 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 13 Aug 2026 23:47:14 +0100 Subject: [PATCH 12/12] On second thoughts, a stream boundary is always nullable... --- src/execution/incremental/IncrementalExecutor.ts | 2 -- .../legacyIncremental/BranchingIncrementalPublisher.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/execution/incremental/IncrementalExecutor.ts b/src/execution/incremental/IncrementalExecutor.ts index 5460d78e99..7401463d87 100644 --- a/src/execution/incremental/IncrementalExecutor.ts +++ b/src/execution/incremental/IncrementalExecutor.ts @@ -358,7 +358,6 @@ export interface ItemStream extends Stream< ItemStream > { path: Path; - itemTypeNonNull: boolean; label: string | undefined; initialCount: number; } @@ -819,7 +818,6 @@ export class IncrementalExecutor< const itemStream: ItemStream = { label: streamUsage.label, path, - itemTypeNonNull: isNonNullType(itemType), queue, initialCount: index, }; diff --git a/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts b/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts index 37ae6e0521..6d8ed9a6ce 100644 --- a/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts +++ b/src/execution/legacyIncremental/BranchingIncrementalPublisher.ts @@ -183,7 +183,7 @@ export class BranchingIncrementalPublisher { { items, path: pathToArray( - addPath(stream.path, index, undefined, stream.itemTypeNonNull), + addPath(stream.path, index, undefined, false), ), }, stream.label,