Skip to content

Commit d9c350f

Browse files
committed
Add support for @onerror
1 parent 72c9044 commit d9c350f

File tree

3 files changed

+161
-3
lines changed

3 files changed

+161
-3
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it } from 'mocha';
2+
3+
import { expectJSON } from '../../__testUtils__/expectJSON.js';
4+
5+
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
6+
7+
import { parse } from '../../language/parser.js';
8+
9+
import { buildSchema } from '../../utilities/buildASTSchema.js';
10+
11+
import { execute } from '../execute.js';
12+
import type { ExecutionResult } from '../types.js';
13+
14+
const syncError = new Error('bar');
15+
16+
const throwingData = {
17+
foo() {
18+
throw syncError;
19+
},
20+
};
21+
22+
const schema = buildSchema(`
23+
type Query {
24+
foo : Int!
25+
}
26+
27+
enum _ErrorAction { PROPAGATE, NULL }
28+
directive @onError(action: _ErrorAction) on QUERY | MUTATION | SUBSCRIPTION
29+
`);
30+
31+
function executeQuery(
32+
query: string,
33+
rootValue: unknown,
34+
): PromiseOrValue<ExecutionResult> {
35+
return execute({ schema, document: parse(query), rootValue });
36+
}
37+
38+
describe('Execute: handles errors', () => {
39+
it('with `@onError(action: NULL) returns null', async () => {
40+
const query = `
41+
query getFoo @onError(action: NULL) {
42+
foo
43+
}
44+
`;
45+
const result = await executeQuery(query, throwingData);
46+
expectJSON(result).toDeepEqual({
47+
data: { foo: null },
48+
errors: [
49+
{
50+
message: 'bar',
51+
path: ['foo'],
52+
locations: [{ line: 3, column: 9 }],
53+
},
54+
],
55+
});
56+
});
57+
it('with `@onError(action: PROPAGATE) propagates the error', async () => {
58+
const query = `
59+
query getFoo @onError(action: PROPAGATE) {
60+
foo
61+
}
62+
`;
63+
const result = await executeQuery(query, throwingData);
64+
expectJSON(result).toDeepEqual({
65+
data: null,
66+
errors: [
67+
{
68+
message: 'bar',
69+
path: ['foo'],
70+
locations: [{ line: 3, column: 9 }],
71+
},
72+
],
73+
});
74+
});
75+
it('by default propagates the error', async () => {
76+
const query = `
77+
query getFoo {
78+
foo
79+
}
80+
`;
81+
const result = await executeQuery(query, throwingData);
82+
expectJSON(result).toDeepEqual({
83+
data: null,
84+
errors: [
85+
{
86+
message: 'bar',
87+
path: ['foo'],
88+
locations: [{ line: 3, column: 9 }],
89+
},
90+
],
91+
});
92+
});
93+
});

src/execution/execute.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ import {
4444
isNonNullType,
4545
isObjectType,
4646
} from '../type/definition.js';
47-
import { GraphQLStreamDirective } from '../type/directives.js';
47+
import {
48+
ErrorAction,
49+
GraphQLOnErrorDirective,
50+
GraphQLStreamDirective,
51+
} from '../type/directives.js';
4852
import type { GraphQLSchema } from '../type/schema.js';
4953
import { assertValidSchema } from '../type/validate.js';
5054

@@ -170,6 +174,7 @@ export interface ExecutionContext {
170174
abortSignalListener: AbortSignalListener | undefined;
171175
completed: boolean;
172176
cancellableStreams: Set<CancellableStreamRecord> | undefined;
177+
propagateErrors: boolean;
173178
}
174179

175180
interface IncrementalContext {
@@ -314,6 +319,12 @@ export function executeQueryOrMutationOrSubscriptionEvent(
314319
return ensureSinglePayload(result);
315320
}
316321

322+
function propagateErrors(operation: OperationDefinitionNode): boolean {
323+
const value = getDirectiveValues(GraphQLOnErrorDirective, operation);
324+
325+
return value?.action !== ErrorAction.NULL;
326+
}
327+
317328
export function experimentalExecuteQueryOrMutationOrSubscriptionEvent(
318329
validatedExecutionArgs: ValidatedExecutionArgs,
319330
): PromiseOrValue<ExecutionResult | ExperimentalIncrementalExecutionResults> {
@@ -326,6 +337,7 @@ export function experimentalExecuteQueryOrMutationOrSubscriptionEvent(
326337
: undefined,
327338
completed: false,
328339
cancellableStreams: undefined,
340+
propagateErrors: propagateErrors(validatedExecutionArgs.operation),
329341
};
330342
try {
331343
const {
@@ -976,7 +988,7 @@ function handleFieldError(
976988

977989
// If the field type is non-nullable, then it is resolved without any
978990
// protection from errors, however it still properly locates the error.
979-
if (isNonNullType(returnType)) {
991+
if (exeContext.propagateErrors && isNonNullType(returnType)) {
980992
throw error;
981993
}
982994

src/type/directives.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ import { toObjMapWithSymbols } from '../jsutils/toObjMap.js';
99

1010
import type { DirectiveDefinitionNode } from '../language/ast.js';
1111
import { DirectiveLocation } from '../language/directiveLocation.js';
12+
import { Kind } from '../language/kinds.js';
1213

1314
import { assertName } from './assertName.js';
1415
import type {
1516
GraphQLArgumentConfig,
1617
GraphQLFieldNormalizedConfigArgumentMap,
1718
GraphQLSchemaElement,
1819
} from './definition.js';
19-
import { GraphQLArgument, GraphQLNonNull } from './definition.js';
20+
import {
21+
GraphQLArgument,
22+
GraphQLEnumType,
23+
GraphQLNonNull,
24+
} from './definition.js';
2025
import { GraphQLBoolean, GraphQLInt, GraphQLString } from './scalars.js';
2126

2227
/**
@@ -276,6 +281,54 @@ export const GraphQLOneOfDirective: GraphQLDirective = new GraphQLDirective({
276281
args: {},
277282
});
278283

284+
/**
285+
* Possible error handling actions.
286+
*/
287+
export const ErrorAction = {
288+
PROPAGATE: 'PROPAGATE' as const,
289+
NULL: 'NULL' as const,
290+
} as const;
291+
292+
// eslint-disable-next-line @typescript-eslint/no-redeclare
293+
export type ErrorAction = (typeof ErrorAction)[keyof typeof ErrorAction];
294+
295+
export const _ErrorAction = new GraphQLEnumType({
296+
name: '_ErrorAction',
297+
description: 'Possible error handling actions.',
298+
values: {
299+
PROPAGATE: {
300+
value: ErrorAction.PROPAGATE,
301+
description:
302+
'Non-nullable positions that error cause the error to propagate to the nearest nullable ancestor position. The error is added to the "errors" list.',
303+
},
304+
NULL: {
305+
value: ErrorAction.NULL,
306+
description:
307+
'Positions that error are replaced with a `null` and an error is added to the "errors list.',
308+
},
309+
},
310+
});
311+
312+
/**
313+
* Controls how the executor handles errors.
314+
*/
315+
export const GraphQLOnErrorDirective = new GraphQLDirective({
316+
name: 'onError',
317+
description: 'Controls how the executor handles errors.',
318+
locations: [
319+
DirectiveLocation.QUERY,
320+
DirectiveLocation.MUTATION,
321+
DirectiveLocation.SUBSCRIPTION,
322+
],
323+
args: {
324+
action: {
325+
type: new GraphQLNonNull(_ErrorAction),
326+
description: 'The action to execute when a field error is encountered.',
327+
default: { literal: { kind: Kind.ENUM, value: 'PROPAGATE' } },
328+
},
329+
},
330+
});
331+
279332
/**
280333
* The full list of specified directives.
281334
*/

0 commit comments

Comments
 (0)