Skip to content

Commit cf0c922

Browse files
committed
validateSchema: validate Input Objects self-references
1 parent 0a30b62 commit cf0c922

File tree

3 files changed

+148
-12
lines changed

3 files changed

+148
-12
lines changed

src/type/__tests__/validation-test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,80 @@ describe('Type System: Input Objects must have fields', () => {
687687
]);
688688
});
689689

690+
it('accepts an Input Object with breakable circular reference', () => {
691+
const schema = buildSchema(`
692+
type Query {
693+
field(arg: SomeInputObject): String
694+
}
695+
696+
input SomeInputObject {
697+
self: SomeInputObject
698+
arrayOfSelf: [SomeInputObject]
699+
nonNullArrayOfSelf: [SomeInputObject]!
700+
nonNullArrayOfNonNullSelf: [SomeInputObject!]!
701+
intermediateSelf: AnotherInputObject
702+
}
703+
704+
input AnotherInputObject {
705+
parent: SomeInputObject
706+
}
707+
`);
708+
709+
expect(validateSchema(schema)).to.deep.equal([]);
710+
});
711+
712+
it('rejects an Input Object with non-breakable circular reference', () => {
713+
const schema = buildSchema(`
714+
type Query {
715+
field(arg: SomeInputObject): String
716+
}
717+
718+
input SomeInputObject {
719+
nonNullSelf: SomeInputObject!
720+
}
721+
`);
722+
723+
expect(validateSchema(schema)).to.deep.equal([
724+
{
725+
message:
726+
'Cannot reference Input Object "SomeInputObject" within itself through a series of non-null fields: "nonNullSelf".',
727+
locations: [{ line: 7, column: 9 }],
728+
},
729+
]);
730+
});
731+
732+
it('rejects Input Objects with non-breakable circular reference spread across them', () => {
733+
const schema = buildSchema(`
734+
type Query {
735+
field(arg: SomeInputObject): String
736+
}
737+
738+
input SomeInputObject {
739+
startLoop: AnotherInputObject!
740+
}
741+
742+
input AnotherInputObject {
743+
nextInLoop: YetAnotherInputObject!
744+
}
745+
746+
input YetAnotherInputObject {
747+
closeLoop: SomeInputObject!
748+
}
749+
`);
750+
751+
expect(validateSchema(schema)).to.deep.equal([
752+
{
753+
message:
754+
'Cannot reference Input Object "SomeInputObject" within itself through a series of non-null fields: "startLoop.nextInLoop.closeLoop".',
755+
locations: [
756+
{ line: 7, column: 9 },
757+
{ line: 11, column: 9 },
758+
{ line: 15, column: 9 },
759+
],
760+
},
761+
]);
762+
});
763+
690764
it('rejects an Input Object type with incorrectly typed fields', () => {
691765
const schema = buildSchema(`
692766
type Query {

src/type/validate.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ function validateTypes(context: SchemaValidationContext): void {
271271
validateInputFields(context, type);
272272
}
273273
});
274+
275+
// Ensure Input Objects do not contain non-nullable circular references
276+
validateInputObjectCircularReferences(context);
274277
}
275278

276279
function validateFields(
@@ -578,6 +581,64 @@ function validateInputFields(
578581
});
579582
}
580583

584+
function validateInputObjectCircularReferences(
585+
context: SchemaValidationContext,
586+
): void {
587+
// Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.js'.
588+
// Tracks already visited types to maintain O(N) and to ensure that cycles
589+
// are not redundantly reported.
590+
const visitedTypes = Object.create(null);
591+
592+
// Array of types nodes used to produce meaningful errors
593+
const fieldPath = [];
594+
595+
// Position in the type path
596+
const fieldPathIndexByTypeName = Object.create(null);
597+
598+
const typeMap = context.schema.getTypeMap();
599+
for (const type of objectValues(typeMap)) {
600+
if (isInputObjectType(type)) {
601+
detectCycleRecursive(type);
602+
}
603+
}
604+
605+
// This does a straight-forward DFS to find cycles.
606+
// It does not terminate when a cycle was found but continues to explore
607+
// the graph to find all possible cycles.
608+
function detectCycleRecursive(inputObj: GraphQLInputObjectType) {
609+
if (visitedTypes[inputObj.name]) {
610+
return;
611+
}
612+
613+
visitedTypes[inputObj.name] = true;
614+
fieldPathIndexByTypeName[inputObj.name] = fieldPath.length;
615+
616+
const fields = objectValues(inputObj.getFields());
617+
for (const field of fields) {
618+
if (isNonNullType(field.type) && isInputObjectType(field.type.ofType)) {
619+
const fieldType = field.type.ofType;
620+
const cycleIndex = fieldPathIndexByTypeName[fieldType.name];
621+
622+
fieldPath.push(field);
623+
if (cycleIndex === undefined) {
624+
detectCycleRecursive(fieldType);
625+
} else {
626+
const cyclePath = fieldPath.slice(cycleIndex);
627+
const fieldNames = cyclePath.map(fieldObj => fieldObj.name);
628+
context.reportError(
629+
`Cannot reference Input Object "${fieldType.name}" within itself ` +
630+
`through a series of non-null fields: "${fieldNames.join('.')}".`,
631+
cyclePath.map(fieldObj => fieldObj.astNode),
632+
);
633+
}
634+
fieldPath.pop();
635+
}
636+
}
637+
638+
fieldPathIndexByTypeName[inputObj.name] = undefined;
639+
}
640+
}
641+
581642
function getAllNodes<T: ASTNode, K: ASTNode>(object: {
582643
+astNode: ?T,
583644
+extensionASTNodes?: ?$ReadOnlyArray<K>,

src/validation/rules/NoFragmentCycles.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ export function NoFragmentCycles(context: ValidationContext): ASTVisitor {
3434
return {
3535
OperationDefinition: () => false,
3636
FragmentDefinition(node) {
37-
if (!visitedFrags[node.name.value]) {
38-
detectCycleRecursive(node);
39-
}
37+
detectCycleRecursive(node);
4038
return false;
4139
},
4240
};
@@ -45,6 +43,10 @@ export function NoFragmentCycles(context: ValidationContext): ASTVisitor {
4543
// It does not terminate when a cycle was found but continues to explore
4644
// the graph to find all possible cycles.
4745
function detectCycleRecursive(fragment: FragmentDefinitionNode) {
46+
if (visitedFrags[fragment.name.value]) {
47+
return;
48+
}
49+
4850
const fragmentName = fragment.name.value;
4951
visitedFrags[fragmentName] = true;
5052

@@ -60,24 +62,23 @@ export function NoFragmentCycles(context: ValidationContext): ASTVisitor {
6062
const spreadName = spreadNode.name.value;
6163
const cycleIndex = spreadPathIndexByName[spreadName];
6264

65+
spreadPath.push(spreadNode);
6366
if (cycleIndex === undefined) {
64-
spreadPath.push(spreadNode);
65-
if (!visitedFrags[spreadName]) {
66-
const spreadFragment = context.getFragment(spreadName);
67-
if (spreadFragment) {
68-
detectCycleRecursive(spreadFragment);
69-
}
67+
const spreadFragment = context.getFragment(spreadName);
68+
if (spreadFragment) {
69+
detectCycleRecursive(spreadFragment);
7070
}
71-
spreadPath.pop();
7271
} else {
7372
const cyclePath = spreadPath.slice(cycleIndex);
73+
const fragmentNames = cyclePath.slice(0, -1).map(s => s.name.value);
7474
context.reportError(
7575
new GraphQLError(
76-
cycleErrorMessage(spreadName, cyclePath.map(s => s.name.value)),
77-
cyclePath.concat(spreadNode),
76+
cycleErrorMessage(spreadName, fragmentNames),
77+
cyclePath,
7878
),
7979
);
8080
}
81+
spreadPath.pop();
8182
}
8283

8384
spreadPathIndexByName[fragmentName] = undefined;

0 commit comments

Comments
 (0)