Skip to content

Commit d80bd91

Browse files
committed
Add path to input validation error
1 parent cdf47f4 commit d80bd91

File tree

4 files changed

+355
-231
lines changed

4 files changed

+355
-231
lines changed

src/methods/validate-fixture-input.ts

Lines changed: 99 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@ import {
1919
visit,
2020
visitWithTypeInfo,
2121
BREAK,
22+
ASTNode,
2223
} from "graphql";
2324

2425
import { inlineNamedFragmentSpreads } from "../utils/inline-named-fragment-spreads.js";
2526

27+
export interface FixtureInputValidationError {
28+
message: string;
29+
path: (string | number)[];
30+
}
31+
2632
export interface ValidateFixtureInputResult {
27-
errors: string[];
33+
errors: FixtureInputValidationError[];
34+
}
35+
36+
interface NestedValue {
37+
value: any;
38+
path: (string | number)[];
2839
}
2940

3041
/**
@@ -46,15 +57,15 @@ export function validateFixtureInput(
4657
): ValidateFixtureInputResult {
4758
const inlineFragmentSpreadsAst = inlineNamedFragmentSpreads(queryAST);
4859
const typeInfo = new TypeInfo(schema);
49-
const valueStack: any[][] = [[value]];
60+
const valueStack: NestedValue[][] = [[{ value, path: [] }]];
5061
const typeStack: (GraphQLNamedType | undefined)[] = [];
5162
const possibleTypesStack: Set<string>[] = [
5263
new Set([schema.getQueryType()!.name]),
5364
];
5465
const typenameResponseKeyStack: (string | undefined)[] = [];
5566
const expectedFieldsStack: Map<string, Set<string>>[] = [new Map()];
5667

57-
const errors: string[] = [];
68+
const errors: FixtureInputValidationError[] = [];
5869

5970
visit(
6071
inlineFragmentSpreadsAst,
@@ -87,7 +98,7 @@ export function validateFixtureInput(
8798
},
8899
},
89100
Field: {
90-
enter(node) {
101+
enter(node, _key, _parent, _path, ancestors) {
91102
const currentValues = valueStack[valueStack.length - 1];
92103
const nestedValues = [];
93104

@@ -108,14 +119,19 @@ export function validateFixtureInput(
108119

109120
const fieldDefinition = typeInfo.getFieldDef();
110121
if (fieldDefinition === undefined || fieldDefinition === null) {
111-
errors.push(
112-
`Cannot validate ${responseKey}: missing field definition`,
113-
);
122+
const currentPath = pathFromAncestors(ancestors);
123+
errors.push({
124+
message: `Cannot validate ${responseKey}: missing field definition`,
125+
path: [...currentPath, responseKey],
126+
});
114127
return BREAK;
115128
}
116129
const fieldType = fieldDefinition.type;
117130

118-
for (const currentValue of currentValues) {
131+
for (const {
132+
value: currentValue,
133+
path: currentPath,
134+
} of currentValues) {
119135
const valueForResponseKey = currentValue[responseKey];
120136

121137
// Field is missing from fixture
@@ -134,7 +150,10 @@ export function validateFixtureInput(
134150
typenameResponseKey,
135151
)
136152
) {
137-
errors.push(`Missing expected fixture data for ${responseKey}`);
153+
errors.push({
154+
message: `Missing expected fixture data for ${responseKey}`,
155+
path: [...currentPath, responseKey],
156+
});
138157
}
139158
}
140159
// Scalars and Enums (including wrapped types)
@@ -146,8 +165,11 @@ export function validateFixtureInput(
146165
coerceInputValue(
147166
valueForResponseKey,
148167
fieldType,
149-
(path, _invalidValue, error) => {
150-
errors.push(`${error.message} At "${path.join(".")}"`);
168+
(_path, _invalidValue, error) => {
169+
errors.push({
170+
message: error.message,
171+
path: [...currentPath, responseKey],
172+
});
151173
},
152174
);
153175
}
@@ -169,14 +191,15 @@ export function validateFixtureInput(
169191
processNestedArrays(
170192
valueForResponseKey,
171193
unwrappedFieldType,
172-
responseKey,
194+
[...currentPath, responseKey],
173195
);
174196
nestedValues.push(...flattened);
175197
errors.push(...flattenErrors);
176198
} else {
177-
errors.push(
178-
`Expected array for ${responseKey}, but got ${typeof valueForResponseKey}`,
179-
);
199+
errors.push({
200+
message: `Expected array, but got ${typeof valueForResponseKey}`,
201+
path: [...currentPath, responseKey],
202+
});
180203
}
181204
}
182205
// Objects - validate and add to traversal stack
@@ -185,29 +208,36 @@ export function validateFixtureInput(
185208
isAbstractType(unwrappedFieldType)
186209
) {
187210
if (valueForResponseKey === null) {
188-
errors.push(
189-
`Expected object for ${responseKey}, but got null`,
190-
);
211+
errors.push({
212+
message: `Expected object, but got null`,
213+
path: [...currentPath, responseKey],
214+
});
191215
} else if (typeof valueForResponseKey === "object") {
192-
nestedValues.push(valueForResponseKey);
216+
nestedValues.push({
217+
value: valueForResponseKey,
218+
path: [...currentPath, responseKey],
219+
});
193220
} else {
194-
errors.push(
195-
`Expected object for ${responseKey}, but got ${typeof valueForResponseKey}`,
196-
);
221+
errors.push({
222+
message: `Expected object, but got ${typeof valueForResponseKey}`,
223+
path: [...currentPath, responseKey],
224+
});
197225
}
198226
}
199227
// Unexpected type - defensive check that should never be reached
200228
else {
201-
errors.push(
202-
`Unexpected type for ${responseKey}: ${unwrappedFieldType}`,
203-
);
229+
errors.push({
230+
message: `Unexpected type, expected ${unwrappedFieldType}`,
231+
path: [...currentPath, responseKey],
232+
});
204233
}
205234
}
206235
// No type information - should not happen with valid query
207236
else {
208-
errors.push(
209-
`Cannot validate ${responseKey}: missing type information`,
210-
);
237+
errors.push({
238+
message: `Cannot validate ${responseKey}: missing type information`,
239+
path: [...currentPath, responseKey],
240+
});
211241
}
212242
}
213243

@@ -236,7 +266,7 @@ export function validateFixtureInput(
236266
},
237267
},
238268
SelectionSet: {
239-
enter(node, _key, parent) {
269+
enter(node, _key, parent, _path, ancestors) {
240270
// If this SelectionSet belongs to a Field, prepare to track expected fields
241271
if (parent && "kind" in parent && parent.kind === Kind.FIELD) {
242272
expectedFieldsStack.push(new Map());
@@ -281,9 +311,11 @@ export function validateFixtureInput(
281311
).length;
282312

283313
if (!hasTypename && fragmentSpreadCount > 1) {
284-
errors.push(
285-
`Missing __typename field for abstract type ${getNamedType(typeInfo.getType())?.name}`,
286-
);
314+
const currentPath = pathFromAncestors([...ancestors, parent!]);
315+
errors.push({
316+
message: `Missing __typename field for abstract type ${getNamedType(typeInfo.getType())?.name}`,
317+
path: currentPath,
318+
});
287319
return BREAK;
288320
}
289321
}
@@ -348,42 +380,43 @@ export function validateFixtureInput(
348380
function processNestedArrays(
349381
value: any[],
350382
listType: GraphQLList<any>,
351-
fieldName: string,
352-
): { values: any[]; errors: string[] } {
353-
const result: any[] = [];
354-
const errors: string[] = [];
383+
path: (string | number)[],
384+
): { values: NestedValue[]; errors: FixtureInputValidationError[] } {
385+
const nestedValues: NestedValue[] = [];
386+
const errors: FixtureInputValidationError[] = [];
355387
const elementType = listType.ofType;
356388

357389
for (const [index, element] of value.entries()) {
358390
if (element === null) {
359391
if (!isNullableType(elementType)) {
360-
errors.push(
361-
`Null value found in non-nullable array at ${fieldName}[${index}]`,
362-
);
392+
errors.push({
393+
message: "Null value found in non-nullable array",
394+
path: [...path, index],
395+
});
363396
}
364397
} else if (isListType(elementType)) {
365398
// Element type is a list - expect nested array and recurse
366399
if (Array.isArray(element)) {
367-
const nested = processNestedArrays(
368-
element,
369-
elementType,
370-
`${fieldName}[${index}]`,
371-
);
372-
result.push(...nested.values);
400+
const nested = processNestedArrays(element, elementType, [
401+
...path,
402+
index,
403+
]);
404+
nestedValues.push(...nested.values);
373405
errors.push(...nested.errors);
374406
} else {
375407
// Error: fixture structure doesn't match schema nesting
376-
errors.push(
377-
`Expected array at ${fieldName}[${index}], but got ${typeof element}`,
378-
);
408+
errors.push({
409+
message: `Expected array, but got ${typeof element}`,
410+
path: [...path, index],
411+
});
379412
}
380413
} else {
381414
// Non-list type - add directly
382-
result.push(element);
415+
nestedValues.push({ value: element, path: [...path, index] });
383416
}
384417
}
385418

386-
return { values: result, errors };
419+
return { values: nestedValues, errors };
387420
}
388421

389422
/**
@@ -461,13 +494,13 @@ function isValueExpectedForType(
461494
* - No schema lookups needed - possible types were pre-computed during traversal
462495
*/
463496
function checkForExtraFields(
464-
fixtureObjects: any[],
497+
fixtureObjects: NestedValue[],
465498
expectedFields: Map<string, Set<string>>,
466499
typenameResponseKey: string | undefined,
467-
): string[] {
468-
const errors: string[] = [];
500+
): FixtureInputValidationError[] {
501+
const errors: FixtureInputValidationError[] = [];
469502

470-
for (const fixtureObject of fixtureObjects) {
503+
for (const { value: fixtureObject, path } of fixtureObjects) {
471504
if (
472505
typeof fixtureObject === "object" &&
473506
fixtureObject !== null &&
@@ -502,13 +535,22 @@ function checkForExtraFields(
502535
// Check each field in the fixture object
503536
for (const fixtureField of fixtureFields) {
504537
if (!expectedForThisObject.has(fixtureField)) {
505-
errors.push(
506-
`Extra field "${fixtureField}" found in fixture data not in query`,
507-
);
538+
errors.push({
539+
message: `Extra field "${fixtureField}" found in fixture data not in query`,
540+
path: [...path, fixtureField],
541+
});
508542
}
509543
}
510544
}
511545
}
512546

513547
return errors;
514548
}
549+
550+
function pathFromAncestors(
551+
ancestors: ReadonlyArray<ASTNode | ReadonlyArray<ASTNode>>,
552+
): (string | number)[] {
553+
return ancestors
554+
.filter((ancestor) => "kind" in ancestor && ancestor.kind === Kind.FIELD)
555+
.map((ancestor) => ancestor.alias?.value || ancestor.name.value);
556+
}

src/methods/validate-test-assets.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { determineMutationFromTarget } from "../utils/determine-mutation-from-ta
44

55
import { validateInputQuery } from "./validate-input-query.js";
66
import { validateFixtureOutput } from "./validate-fixture-output.js";
7-
import { validateFixtureInput } from "./validate-fixture-input.js";
7+
import {
8+
validateFixtureInput,
9+
FixtureInputValidationError,
10+
} from "./validate-fixture-input.js";
811
import { FixtureData } from "./load-fixture.js";
912

1013
/**
@@ -28,7 +31,7 @@ export interface CompleteValidationResult {
2831
errors: ReadonlyArray<GraphQLError>;
2932
};
3033
inputFixture: {
31-
errors: string[];
34+
errors: FixtureInputValidationError[];
3235
};
3336
outputFixture: {
3437
errors: { message: string }[];

0 commit comments

Comments
 (0)