Skip to content

Commit 651fc3f

Browse files
committed
Fix crash in node when mixing sync/async resolvers (backport of graphql#3529)
1 parent ef6688d commit 651fc3f

File tree

3 files changed

+126
-14
lines changed

3 files changed

+126
-14
lines changed

src/__testUtils__/expectJSON.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect } from 'chai';
2+
3+
import isObjectLike from '../jsutils/isObjectLike';
4+
import mapValue from '../jsutils/mapValue';
5+
6+
/**
7+
* Deeply transforms an arbitrary value to a JSON-safe value by calling toJSON
8+
* on any nested value which defines it.
9+
*/
10+
function toJSONDeep(value) {
11+
if (!isObjectLike(value)) {
12+
return value;
13+
}
14+
15+
if (typeof value.toJSON === 'function') {
16+
return value.toJSON();
17+
}
18+
19+
if (Array.isArray(value)) {
20+
return value.map(toJSONDeep);
21+
}
22+
23+
return mapValue(value, toJSONDeep);
24+
}
25+
26+
export default function expectJSON(actual) {
27+
const actualJSON = toJSONDeep(actual);
28+
29+
return {
30+
toDeepEqual(expected) {
31+
const expectedJSON = toJSONDeep(expected);
32+
expect(actualJSON).to.deep.equal(expectedJSON);
33+
},
34+
toDeepNestedProperty(path, expected) {
35+
const expectedJSON = toJSONDeep(expected);
36+
expect(actualJSON).to.deep.nested.property(path, expectedJSON);
37+
},
38+
};
39+
}
40+
41+
export function expectToThrowJSON(fn) {
42+
function mapException() {
43+
try {
44+
return fn();
45+
} catch (error) {
46+
throw toJSONDeep(error);
47+
}
48+
}
49+
50+
return expect(mapException).to.throw();
51+
}

src/execution/__tests__/executor-test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { describe, it } from 'mocha';
33

44
import inspect from '../../jsutils/inspect';
55
import invariant from '../../jsutils/invariant';
6+
import expectJSON from '../../__testUtils__/expectJSON';
7+
import resolveOnNextTick from '../../__testUtils__/resolveOnNextTick';
68

79
import { Kind } from '../../language/kinds';
810
import { parse } from '../../language/parser';
@@ -646,6 +648,55 @@ describe('Execute: Handles basic execution tasks', () => {
646648
});
647649
});
648650

651+
it('handles sync errors combined with rejections', async () => {
652+
let isAsyncResolverCalled = false;
653+
654+
const schema = new GraphQLSchema({
655+
query: new GraphQLObjectType({
656+
name: 'Query',
657+
fields: {
658+
syncNullError: {
659+
type: new GraphQLNonNull(GraphQLString),
660+
resolve: () => null,
661+
},
662+
asyncNullError: {
663+
type: new GraphQLNonNull(GraphQLString),
664+
async resolve() {
665+
await resolveOnNextTick();
666+
await resolveOnNextTick();
667+
await resolveOnNextTick();
668+
isAsyncResolverCalled = true;
669+
return Promise.resolve(null);
670+
},
671+
},
672+
},
673+
}),
674+
});
675+
676+
// Order is important here, as the promise has to be created before the synchronous error is thrown
677+
const document = parse(`
678+
{
679+
asyncNullError
680+
syncNullError
681+
}
682+
`);
683+
684+
const result = await execute({ schema, document });
685+
686+
expect(isAsyncResolverCalled).to.equal(true);
687+
expectJSON(result).toDeepEqual({
688+
data: null,
689+
errors: [
690+
{
691+
message:
692+
'Cannot return null for non-nullable field Query.syncNullError.',
693+
locations: [{ line: 4, column: 9 }],
694+
path: ['syncNullError'],
695+
},
696+
],
697+
});
698+
});
699+
649700
it('Full response path is included for non-nullable fields', () => {
650701
const A = new GraphQLObjectType({
651702
name: 'A',

src/execution/execute.js

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -456,23 +456,33 @@ function executeFields(
456456
const results = Object.create(null);
457457
let containsPromise = false;
458458

459-
for (const responseName of Object.keys(fields)) {
460-
const fieldNodes = fields[responseName];
461-
const fieldPath = addPath(path, responseName, parentType.name);
462-
const result = resolveField(
463-
exeContext,
464-
parentType,
465-
sourceValue,
466-
fieldNodes,
467-
fieldPath,
468-
);
459+
try {
460+
for (const responseName of Object.keys(fields)) {
461+
const fieldNodes = fields[responseName];
462+
const fieldPath = addPath(path, responseName, parentType.name);
463+
const result = resolveField(
464+
exeContext,
465+
parentType,
466+
sourceValue,
467+
fieldNodes,
468+
fieldPath,
469+
);
469470

470-
if (result !== undefined) {
471-
results[responseName] = result;
472-
if (isPromise(result)) {
473-
containsPromise = true;
471+
if (result !== undefined) {
472+
results[responseName] = result;
473+
if (isPromise(result)) {
474+
containsPromise = true;
475+
}
474476
}
475477
}
478+
} catch (error) {
479+
if (containsPromise) {
480+
// Ensure that any promises returned by other fields are handled, as they may also reject.
481+
return promiseForObject(results).finally(() => {
482+
throw error;
483+
});
484+
}
485+
throw error;
476486
}
477487

478488
// If there are no promises, we can just return the object

0 commit comments

Comments
 (0)