Skip to content

Commit 0a2eeaf

Browse files
committed
fix(errors): preserve merged paths
This is not the optimal way to address #1047 but is the most backwards compatible.
1 parent e60a814 commit 0a2eeaf

File tree

7 files changed

+207
-41
lines changed

7 files changed

+207
-41
lines changed

packages/delegate/src/createMergedResolver.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { GraphQLError } from 'graphql';
22

3-
import { IFieldResolver, getErrors, setErrors, relocatedError, ERROR_SYMBOL } from '@graphql-tools/utils';
3+
import {
4+
IFieldResolver,
5+
getErrors,
6+
setErrors,
7+
relocatedError,
8+
ERROR_SYMBOL,
9+
extendedError,
10+
} from '@graphql-tools/utils';
411

512
import { OBJECT_SUBSCHEMA_SYMBOL } from './symbols';
613

@@ -54,7 +61,15 @@ function dehoistResult(parent: any, delimeter = '__gqltf__'): any {
5461
const path = error.path.slice();
5562
const pathSegment = path.shift();
5663
const expandedPathSegment: Array<string | number> = (pathSegment as string).split(delimeter);
57-
return relocatedError(error, expandedPathSegment.concat(path));
64+
const newPath = expandedPathSegment.concat(path);
65+
const graphQLToolsMergedPath = error.extensions.graphQLToolsMergedPath;
66+
let newGraphQLToolsMergedPath = graphQLToolsMergedPath.slice();
67+
newGraphQLToolsMergedPath.pop();
68+
newGraphQLToolsMergedPath = newGraphQLToolsMergedPath.concat(newPath);
69+
return extendedError(relocatedError(error, newPath), {
70+
...error.extensions,
71+
graphQLToolsMergedPath: newGraphQLToolsMergedPath,
72+
});
5873
}
5974

6075
return error;

packages/delegate/src/results/handleNull.ts

+17-25
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,28 @@
11
import { GraphQLError } from 'graphql';
22

33
import AggregateError from '@ardatan/aggregate-error';
4-
import { getErrorsByPathSegment, relocatedError } from '@graphql-tools/utils';
4+
5+
import { relocatedError, unextendedError } from '@graphql-tools/utils';
56

67
export function handleNull(errors: ReadonlyArray<GraphQLError>) {
78
if (errors.length) {
8-
if (errors.some(error => !error.path || error.path.length < 2)) {
9-
if (errors.length > 1) {
10-
const combinedError = new AggregateError(errors);
11-
return combinedError;
12-
}
13-
const error = errors[0];
14-
return error.originalError || relocatedError(error, null);
15-
} else if (errors.some(error => typeof error.path[1] === 'string')) {
16-
const childErrors = getErrorsByPathSegment(errors);
17-
18-
const result = {};
19-
Object.keys(childErrors).forEach(pathSegment => {
20-
result[pathSegment] = handleNull(childErrors[pathSegment]);
21-
});
22-
23-
return result;
9+
const graphQLToolsMergedPath = errors[0].extensions.graphQLToolsMergedPath;
10+
const unannotatedErrors = errors.map(error => unextendedError(error, 'graphQLToolsMergedPath'));
11+
12+
if (unannotatedErrors.length > 1) {
13+
const combinedError = new AggregateError(unannotatedErrors);
14+
return new GraphQLError(
15+
combinedError.message,
16+
undefined,
17+
undefined,
18+
undefined,
19+
graphQLToolsMergedPath,
20+
combinedError
21+
);
2422
}
2523

26-
const childErrors = getErrorsByPathSegment(errors);
27-
28-
const result: Array<any> = [];
29-
Object.keys(childErrors).forEach(pathSegment => {
30-
result.push(handleNull(childErrors[pathSegment]));
31-
});
32-
33-
return result;
24+
const error = unannotatedErrors[0];
25+
return relocatedError(error, graphQLToolsMergedPath);
3426
}
3527

3628
return null;

packages/delegate/src/results/handleResult.ts

+20-3
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
isListType,
77
GraphQLError,
88
GraphQLSchema,
9+
responsePathAsArray,
910
} from 'graphql';
1011

1112
import { SubschemaConfig } from '../types';
1213

1314
import { handleNull } from './handleNull';
1415
import { handleObject } from './handleObject';
1516
import { handleList } from './handleList';
17+
import { extendedError } from '@graphql-tools/utils';
1618

1719
export function handleResult(
1820
result: any,
@@ -23,17 +25,32 @@ export function handleResult(
2325
returnType = info.returnType,
2426
skipTypeMerging?: boolean
2527
): any {
28+
const annotatedErrors = errors.map(error => {
29+
if (error.extensions?.graphQLToolsMergedPath == null) {
30+
return extendedError(error, {
31+
...error.extensions,
32+
graphQLToolsMergedPath:
33+
info == null
34+
? error.path
35+
: error.path != null
36+
? [...responsePathAsArray(info.path), ...error.path.slice(1)]
37+
: responsePathAsArray(info.path),
38+
});
39+
}
40+
return error;
41+
});
42+
2643
const type = getNullableType(returnType);
2744

2845
if (result == null) {
29-
return handleNull(errors);
46+
return handleNull(annotatedErrors);
3047
}
3148

3249
if (isLeafType(type)) {
3350
return type.parseValue(result);
3451
} else if (isCompositeType(type)) {
35-
return handleObject(type, result, errors, subschema, context, info, skipTypeMerging);
52+
return handleObject(type, result, annotatedErrors, subschema, context, info, skipTypeMerging);
3653
} else if (isListType(type)) {
37-
return handleList(type, result, errors, subschema, context, info, skipTypeMerging);
54+
return handleList(type, result, annotatedErrors, subschema, context, info, skipTypeMerging);
3855
}
3956
}

packages/delegate/src/results/mergeFields.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -170,21 +170,21 @@ export function mergeFields(
170170
}
171171

172172
let containsPromises = false;
173-
const maybePromises: Promise<any> | any = [];
173+
const resultMap: Map<Promise<any> | any, SelectionSetNode> = new Map();
174174
delegationMap.forEach((selectionSet: SelectionSetNode, s: SubschemaConfig) => {
175175
const maybePromise = s.merge[typeName].resolve(object, context, info, s, selectionSet);
176-
maybePromises.push(maybePromise);
177-
if (!containsPromises && isPromise(maybePromise)) {
176+
resultMap.set(maybePromise, selectionSet);
177+
if (isPromise(maybePromise)) {
178178
containsPromises = true;
179179
}
180180
});
181181

182182
return containsPromises
183-
? Promise.all(maybePromises).then(results =>
183+
? Promise.all(resultMap.keys()).then(results =>
184184
mergeFields(
185185
mergedTypeInfo,
186186
typeName,
187-
mergeProxiedResults(object, ...results),
187+
mergeProxiedResults(info, object, results, Array.from(resultMap.values())),
188188
unproxiableFieldNodes,
189189
combineSubschemas(sourceSubschemaOrSourceSubschemas, proxiableSubschemas),
190190
nonProxiableSubschemas,
@@ -195,7 +195,7 @@ export function mergeFields(
195195
: mergeFields(
196196
mergedTypeInfo,
197197
typeName,
198-
mergeProxiedResults(object, ...maybePromises),
198+
mergeProxiedResults(info, object, Array.from(resultMap.keys()), Array.from(resultMap.values())),
199199
unproxiableFieldNodes,
200200
combineSubschemas(sourceSubschemaOrSourceSubschemas, proxiableSubschemas),
201201
nonProxiableSubschemas,

packages/delegate/src/results/mergeProxiedResults.ts

+55-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,54 @@
1-
import { mergeDeep, ERROR_SYMBOL } from '@graphql-tools/utils';
1+
import { GraphQLError, GraphQLResolveInfo, responsePathAsArray, SelectionSetNode, GraphQLObjectType } from 'graphql';
2+
3+
import {
4+
mergeDeep,
5+
ERROR_SYMBOL,
6+
extendedError,
7+
collectFields,
8+
GraphQLExecutionContext,
9+
relocatedError,
10+
} from '@graphql-tools/utils';
211

312
import { SubschemaConfig } from '../types';
413
import { OBJECT_SUBSCHEMA_SYMBOL, FIELD_SUBSCHEMA_MAP_SYMBOL } from '../symbols';
514

6-
export function mergeProxiedResults(target: any, ...sources: Array<any>): any {
7-
const results = sources.filter(source => !(source instanceof Error));
15+
export function mergeProxiedResults(
16+
info: GraphQLResolveInfo,
17+
target: any,
18+
sources: Array<any>,
19+
selectionSets: Array<SelectionSetNode>
20+
): any {
21+
const results: Array<any> = [];
22+
let errors: Array<GraphQLError> = [];
23+
24+
const path = responsePathAsArray(info.path);
25+
26+
sources.forEach((source, index) => {
27+
if (source instanceof GraphQLError) {
28+
const selectionSet = selectionSets[index];
29+
const fieldNodes = collectFields(
30+
{
31+
schema: info.schema,
32+
variableValues: {},
33+
fragments: {},
34+
} as GraphQLExecutionContext,
35+
info.schema.getType(target.__typename) as GraphQLObjectType,
36+
selectionSet,
37+
Object.create(null),
38+
Object.create(null)
39+
);
40+
const nullResult = {};
41+
Object.keys(fieldNodes).forEach(responseKey => {
42+
errors.push(relocatedError(source, [responseKey]));
43+
nullResult[responseKey] = null;
44+
});
45+
results.push(nullResult);
46+
} else {
47+
errors = errors.concat(source[ERROR_SYMBOL]);
48+
results.push(source);
49+
}
50+
});
51+
852
const fieldSubschemaMap = results.reduce((acc: Record<any, SubschemaConfig>, source: any) => {
953
const subschema = source[OBJECT_SUBSCHEMA_SYMBOL];
1054
Object.keys(source).forEach(key => {
@@ -18,8 +62,14 @@ export function mergeProxiedResults(target: any, ...sources: Array<any>): any {
1862
? Object.assign({}, target[FIELD_SUBSCHEMA_MAP_SYMBOL], fieldSubschemaMap)
1963
: fieldSubschemaMap;
2064

21-
const errors = sources.map((source: any) => (source instanceof Error ? source : source[ERROR_SYMBOL]));
22-
result[ERROR_SYMBOL] = target[ERROR_SYMBOL].concat(...errors);
65+
const annotatedErrors = errors.map(error => {
66+
return extendedError(error, {
67+
...error.extensions,
68+
graphQLToolsMergedPath: error.path != null ? [...path, ...error.path] : responsePathAsArray(info.path),
69+
});
70+
});
71+
72+
result[ERROR_SYMBOL] = target[ERROR_SYMBOL].concat(annotatedErrors);
2373

2474
return result;
2575
}

packages/stitch/tests/errors.test.ts

+35
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('passes along errors for missing fields on list', () => {
3838
const originalResult = await graphql(schema, query);
3939
const stitchedResult = await graphql(stitchedSchema, query);
4040
expect(stitchedResult).toEqual(originalResult);
41+
expect(stitchedResult.errors[0].path).toEqual(originalResult.errors[0].path);
4142
});
4243

4344
test('even if nullable', async () => {
@@ -72,6 +73,7 @@ describe('passes along errors for missing fields on list', () => {
7273
const originalResult = await graphql(schema, query);
7374
const stitchedResult = await graphql(stitchedSchema, query);
7475
expect(stitchedResult).toEqual(originalResult);
76+
expect(stitchedResult.errors[0].path).toEqual(originalResult.errors[0].path);
7577
});
7678
});
7779

@@ -108,6 +110,7 @@ describe('passes along errors when list field errors', () => {
108110
const originalResult = await graphql(schema, query);
109111
const stitchedResult = await graphql(stitchedSchema, query);
110112
expect(stitchedResult).toEqual(originalResult);
113+
expect(stitchedResult.errors[0].path).toEqual(originalResult.errors[0].path);
111114
});
112115

113116
test('even if nullable', async () => {
@@ -142,6 +145,38 @@ describe('passes along errors when list field errors', () => {
142145
const originalResult = await graphql(schema, query);
143146
const stitchedResult = await graphql(stitchedSchema, query);
144147
expect(stitchedResult).toEqual(originalResult);
148+
expect(stitchedResult.errors[0].path).toEqual(originalResult.errors[0].path);
149+
});
150+
151+
describe('passes along correct error when there are two non-null fields', () => {
152+
test('should work', async () => {
153+
const schema = makeExecutableSchema({
154+
typeDefs: `
155+
type Query {
156+
getBoth: Both
157+
}
158+
type Both {
159+
mandatoryField1: String!
160+
mandatoryField2: String!
161+
}
162+
`,
163+
resolvers: {
164+
Query: {
165+
getBoth: () => ({ mandatoryField1: 'test' }),
166+
},
167+
},
168+
});
169+
170+
const stitchedSchema = stitchSchemas({
171+
subschemas: [schema],
172+
});
173+
174+
const query = '{ getBoth { mandatoryField1 mandatoryField2 } }';
175+
const originalResult = await graphql(schema, query);
176+
const stitchedResult = await graphql(stitchedSchema, query);
177+
expect(stitchedResult).toEqual(originalResult);
178+
expect(stitchedResult.errors[0].path).toEqual(originalResult.errors[0].path);
179+
});
145180
});
146181
});
147182

packages/utils/src/errors.ts

+57
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,63 @@ export function relocatedError(originalError: GraphQLError, path?: ReadonlyArray
1414
);
1515
}
1616

17+
export function extendedError(originalError: GraphQLError, extensions: Record<string, any>): GraphQLError {
18+
return new GraphQLError(
19+
originalError.message,
20+
originalError.nodes,
21+
originalError.source,
22+
originalError.positions,
23+
originalError.path,
24+
originalError.originalError,
25+
extensions
26+
);
27+
}
28+
29+
export function unextendedError(originalError: GraphQLError, extensionKey: string): GraphQLError {
30+
const originalExtensions = originalError.extensions;
31+
32+
if (originalExtensions == null) {
33+
return originalError;
34+
}
35+
36+
const originalExtensionKeys = Object.keys(originalExtensions);
37+
38+
if (!originalExtensionKeys.length) {
39+
return originalError;
40+
}
41+
42+
const newExtensions = {};
43+
let extensionsPresent = false;
44+
originalExtensionKeys.forEach(key => {
45+
if (key !== extensionKey) {
46+
newExtensions[key] = originalExtensions[key];
47+
extensionsPresent = true;
48+
}
49+
});
50+
51+
if (!extensionsPresent) {
52+
return new GraphQLError(
53+
originalError.message,
54+
originalError.nodes,
55+
originalError.source,
56+
originalError.positions,
57+
originalError.path,
58+
originalError.originalError,
59+
undefined
60+
);
61+
}
62+
63+
return new GraphQLError(
64+
originalError.message,
65+
originalError.nodes,
66+
originalError.source,
67+
originalError.positions,
68+
originalError.path,
69+
originalError.originalError,
70+
newExtensions
71+
);
72+
}
73+
1774
export function slicedError(originalError: GraphQLError) {
1875
return relocatedError(originalError, originalError.path != null ? originalError.path.slice(1) : undefined);
1976
}

0 commit comments

Comments
 (0)