Skip to content

Commit 094d2d6

Browse files
committed
Fragment args additional validation:
Re-use all existing validation, updating (or updating tests for): - ProvidedRequiredArguments - ValuesOfCorrectType - VariablesAreInputTypes - UniqueVariableNames - VariablesInAllowedPosition Some of the implications here I'm not in love with: I think it's become more clear that fragment arguments ought to be ArgumentDefinitionNodes rather than VariableDefinitionNodes, even though when *used* they are still variables. I'll try redoing this PR using ArgumentDefinitionNode and see how that simplifies or complicates the matter
1 parent 042a56a commit 094d2d6

8 files changed

+317
-49
lines changed

src/utilities/TypeInfo.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,6 @@ export class TypeInfo {
6969
/** @deprecated will be removed in 17.0.0 */
7070
getFieldDefFn?: GetFieldDefFn,
7171
) {
72-
this._fragmentDefinitions = Object.create(null);
73-
7472
this._schema = schema;
7573
this._typeStack = [];
7674
this._parentTypeStack = [];
@@ -81,6 +79,7 @@ export class TypeInfo {
8179
this._argument = null;
8280
this._enumValue = null;
8381
this._fragmentDefinition = null;
82+
this._fragmentDefinitions = Object.create(null);
8483
this._getFieldDef = getFieldDefFn ?? getFieldDef;
8584
if (initialType) {
8685
if (isInputType(initialType)) {

src/validation/__tests__/ProvidedRequiredArgumentsRule-test.ts

+108
Original file line numberDiff line numberDiff line change
@@ -356,4 +356,112 @@ describe('Validate: Provided required arguments', () => {
356356
]);
357357
});
358358
});
359+
360+
describe('Fragment required arguments', () => {
361+
it('ignores unknown arguments', () => {
362+
expectValid(`
363+
{
364+
...Foo(unknownArgument: true)
365+
}
366+
fragment Foo on Query {
367+
dog
368+
}
369+
`);
370+
});
371+
372+
// Query: should this be allowed?
373+
// We could differentiate between required/optional (i.e. no default value)
374+
// vs. nullable/non-nullable (i.e. no !), whereas now they are conflated.
375+
// So today:
376+
// $x: Int! `x:` is required and must not be null (NOT a nullable variable)
377+
// $x: Int! = 3 `x:` is not required and must not be null (MAY BE a nullable variable)
378+
// $x: Int `x:` is not required and may be null
379+
// $x: Int = 3 `x:` is not required and may be null
380+
//
381+
// It feels weird to collapse the nullable cases but not the non-nullable ones.
382+
// Whereas all four feel like they ought to mean something explicitly different.
383+
//
384+
// Potential proposal:
385+
// $x: Int! `x:` is required and must not be null (NOT a nullable variable)
386+
// $x: Int! = 3 `x:` is not required and must not be null (NOT a nullable variable)
387+
// $x: Int `x:` is required and may be null
388+
// $x: Int = 3 `x:` is not required and may be null
389+
//
390+
// Required then is whether there's a default value,
391+
// and nullable is whether there's a !
392+
it('Missing nullable argument with default is allowed', () => {
393+
expectValid(`
394+
{
395+
...F
396+
397+
}
398+
fragment F($x: Int = 3) on Query {
399+
foo
400+
}
401+
`);
402+
});
403+
// Above proposal: this should be an error
404+
it('Missing nullable argument is allowed', () => {
405+
expectValid(`
406+
{
407+
...F
408+
409+
}
410+
fragment F($x: Int) on Query {
411+
foo
412+
}
413+
`);
414+
});
415+
it('Missing non-nullable argument with default is allowed', () => {
416+
expectValid(`
417+
{
418+
...F
419+
420+
}
421+
fragment F($x: Int! = 3) on Query {
422+
foo
423+
}
424+
`);
425+
});
426+
it('Missing non-nullable argument is not allowed', () => {
427+
expectErrors(`
428+
{
429+
...F
430+
431+
}
432+
fragment F($x: Int!) on Query {
433+
foo
434+
}
435+
`).toDeepEqual([
436+
{
437+
message:
438+
'Fragment "F" argument "x" of type "{ kind: "NonNullType", type: { kind: "NamedType", name: [Object], loc: [Object] }, loc: [Object] }" is required, but it was not provided.',
439+
locations: [
440+
{ line: 3, column: 13 },
441+
{ line: 6, column: 22 },
442+
],
443+
},
444+
]);
445+
});
446+
447+
it('Supplies required variables', () => {
448+
expectValid(`
449+
{
450+
...F(x: 3)
451+
452+
}
453+
fragment F($x: Int!) on Query {
454+
foo
455+
}
456+
`);
457+
});
458+
459+
it('Skips missing fragments', () => {
460+
expectValid(`
461+
{
462+
...Missing(x: 3)
463+
}
464+
`);
465+
});
466+
});
359467
});

src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts

+31
Original file line numberDiff line numberDiff line change
@@ -1198,4 +1198,35 @@ describe('Validate: Values of correct type', () => {
11981198
]);
11991199
});
12001200
});
1201+
1202+
describe('Fragment argument values', () => {
1203+
it('list variables with invalid item', () => {
1204+
expectErrors(`
1205+
fragment InvalidItem($a: [String] = ["one", 2]) on Query {
1206+
dog { name }
1207+
}
1208+
`).toDeepEqual([
1209+
{
1210+
message: 'String cannot represent a non string value: 2',
1211+
locations: [{ line: 2, column: 53 }],
1212+
},
1213+
]);
1214+
});
1215+
1216+
it('fragment spread with invalid argument value', () => {
1217+
expectErrors(`
1218+
fragment GivesString on Query {
1219+
...ExpectsInt(a: "three")
1220+
}
1221+
fragment ExpectsInt($a: Int) on Query {
1222+
dog { name }
1223+
}
1224+
`).toDeepEqual([
1225+
{
1226+
message: 'Int cannot represent non-integer value: "three"',
1227+
locations: [{ line: 3, column: 28 }],
1228+
},
1229+
]);
1230+
});
1231+
});
12011232
});

src/validation/__tests__/VariablesAreInputTypesRule-test.ts

+27
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ describe('Validate: Variables are input types', () => {
1818
query Foo($a: Unknown, $b: [[Unknown!]]!) {
1919
field(a: $a, b: $b)
2020
}
21+
fragment Bar($a: Unknown, $b: [[Unknown!]]!) on Query {
22+
field(a: $a, b: $b)
23+
}
2124
`);
2225
});
2326

@@ -26,6 +29,9 @@ describe('Validate: Variables are input types', () => {
2629
query Foo($a: String, $b: [Boolean!]!, $c: ComplexInput) {
2730
field(a: $a, b: $b, c: $c)
2831
}
32+
fragment Bar($a: String, $b: [Boolean!]!, $c: ComplexInput) on Query {
33+
field(a: $a, b: $b, c: $c)
34+
}
2935
`);
3036
});
3137

@@ -49,4 +55,25 @@ describe('Validate: Variables are input types', () => {
4955
},
5056
]);
5157
});
58+
59+
it('output types on fragment arguments are invalid', () => {
60+
expectErrors(`
61+
fragment Bar($a: Dog, $b: [[CatOrDog!]]!, $c: Pet) on Query {
62+
field(a: $a, b: $b, c: $c)
63+
}
64+
`).toDeepEqual([
65+
{
66+
locations: [{ line: 2, column: 24 }],
67+
message: 'Variable "$a" cannot be non-input type "Dog".',
68+
},
69+
{
70+
locations: [{ line: 2, column: 33 }],
71+
message: 'Variable "$b" cannot be non-input type "[[CatOrDog!]]!".',
72+
},
73+
{
74+
locations: [{ line: 2, column: 53 }],
75+
message: 'Variable "$c" cannot be non-input type "Pet".',
76+
},
77+
]);
78+
});
5279
});

src/validation/__tests__/VariablesInAllowedPositionRule-test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -438,5 +438,27 @@ describe('Validate: Variables are in allowed positions', () => {
438438
},
439439
]);
440440
});
441+
442+
it('Int fragment arg => Int! field arg fails even when shadowed by Int! variable', () => {
443+
expectErrors(`
444+
query Query($intVar: Int!) {
445+
complicatedArgs {
446+
...A(i: $intVar)
447+
}
448+
}
449+
fragment A($intVar: Int) on ComplicatedArgs {
450+
nonNullIntArgField(nonNullIntArg: $intVar)
451+
}
452+
`).toDeepEqual([
453+
{
454+
message:
455+
'Variable "$intVar" of type "Int" used in position expecting type "Int!".',
456+
locations: [
457+
{ line: 7, column: 20 },
458+
{ line: 8, column: 45 },
459+
],
460+
},
461+
]);
462+
});
441463
});
442464
});

src/validation/rules/ProvidedRequiredArgumentsRule.ts

+38-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type { ObjMap } from '../../jsutils/ObjMap.js';
44

55
import { GraphQLError } from '../../error/GraphQLError.js';
66

7-
import type { InputValueDefinitionNode } from '../../language/ast.js';
7+
import type {
8+
InputValueDefinitionNode,
9+
VariableDefinitionNode,
10+
} from '../../language/ast.js';
811
import { Kind } from '../../language/kinds.js';
912
import { print } from '../../language/printer.js';
1013
import type { ASTVisitor } from '../../language/visitor.js';
@@ -56,6 +59,37 @@ export function ProvidedRequiredArgumentsRule(
5659
}
5760
},
5861
},
62+
FragmentSpread: {
63+
// Validate on leave to allow for directive errors to appear first.
64+
leave(spreadNode) {
65+
const fragmentDef = context.getFragment(spreadNode.name.value);
66+
if (!fragmentDef) {
67+
return false;
68+
}
69+
70+
const providedArgs = new Set(
71+
// FIXME: https://github.com/graphql/graphql-js/issues/2203
72+
/* c8 ignore next */
73+
spreadNode.arguments?.map((arg) => arg.name.value),
74+
);
75+
// FIXME: https://github.com/graphql/graphql-js/issues/2203
76+
/* c8 ignore next */
77+
for (const argDef of fragmentDef.argumentDefinitions ?? []) {
78+
if (
79+
!providedArgs.has(argDef.variable.name.value) &&
80+
isRequiredArgumentNode(argDef)
81+
) {
82+
const argTypeStr = inspect(argDef.type);
83+
context.reportError(
84+
new GraphQLError(
85+
`Fragment "${spreadNode.name.value}" argument "${argDef.variable.name.value}" of type "${argTypeStr}" is required, but it was not provided.`,
86+
{ nodes: [spreadNode, argDef] },
87+
),
88+
);
89+
}
90+
}
91+
},
92+
},
5993
};
6094
}
6195

@@ -122,6 +156,8 @@ export function ProvidedRequiredArgumentsOnDirectivesRule(
122156
};
123157
}
124158

125-
function isRequiredArgumentNode(arg: InputValueDefinitionNode): boolean {
159+
function isRequiredArgumentNode(
160+
arg: InputValueDefinitionNode | VariableDefinitionNode,
161+
): boolean {
126162
return arg.type.kind === Kind.NON_NULL_TYPE && arg.defaultValue == null;
127163
}
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { groupBy } from '../../jsutils/groupBy.js';
2+
import type { Maybe } from '../../jsutils/Maybe.js';
23

34
import { GraphQLError } from '../../error/GraphQLError.js';
45

6+
import type { VariableDefinitionNode } from '../../language/ast.js';
57
import type { ASTVisitor } from '../../language/visitor.js';
68

79
import type { ASTValidationContext } from '../ValidationContext.js';
@@ -16,25 +18,34 @@ export function UniqueVariableNamesRule(
1618
): ASTVisitor {
1719
return {
1820
OperationDefinition(operationNode) {
19-
// See: https://github.com/graphql/graphql-js/issues/2203
20-
/* c8 ignore next */
21-
const variableDefinitions = operationNode.variableDefinitions ?? [];
22-
23-
const seenVariableDefinitions = groupBy(
24-
variableDefinitions,
25-
(node) => node.variable.name.value,
26-
);
27-
28-
for (const [variableName, variableNodes] of seenVariableDefinitions) {
29-
if (variableNodes.length > 1) {
30-
context.reportError(
31-
new GraphQLError(
32-
`There can be only one variable named "$${variableName}".`,
33-
{ nodes: variableNodes.map((node) => node.variable.name) },
34-
),
35-
);
36-
}
37-
}
21+
validateVariableDefinitions(operationNode.variableDefinitions, context);
22+
},
23+
FragmentDefinition(fragmentNode) {
24+
validateVariableDefinitions(fragmentNode.argumentDefinitions, context);
3825
},
3926
};
4027
}
28+
29+
function validateVariableDefinitions(
30+
maybeVariableDefinitions: Maybe<ReadonlyArray<VariableDefinitionNode>>,
31+
context: ASTValidationContext,
32+
) {
33+
// See: https://github.com/graphql/graphql-js/issues/2203
34+
/* c8 ignore next */
35+
const variableDefinitions = maybeVariableDefinitions ?? [];
36+
const seenVariableDefinitions = groupBy(
37+
variableDefinitions,
38+
(node) => node.variable.name.value,
39+
);
40+
41+
for (const [variableName, variableNodes] of seenVariableDefinitions) {
42+
if (variableNodes.length > 1) {
43+
context.reportError(
44+
new GraphQLError(
45+
`There can be only one variable named "$${variableName}".`,
46+
{ nodes: variableNodes.map((node) => node.variable.name) },
47+
),
48+
);
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)