diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index c1ed85d7b6..5786a114ec 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -924,7 +924,7 @@ describe('Type System: Input Objects must have fields', () => { expectJSON(validateSchema(schema)).toDeepEqual([ { message: - 'Invalid circular reference. The Input Object SomeInputObject references itself in the non-null field SomeInputObject.nonNullSelf.', + 'Input Object SomeInputObject references itself via the required fields: SomeInputObject.nonNullSelf.', locations: [{ line: 7, column: 9 }], }, ]); @@ -952,13 +952,31 @@ describe('Type System: Input Objects must have fields', () => { expectJSON(validateSchema(schema)).toDeepEqual([ { message: - 'Invalid circular reference. The Input Object SomeInputObject references itself via the non-null fields: SomeInputObject.startLoop, AnotherInputObject.nextInLoop, YetAnotherInputObject.closeLoop.', + 'Input Object SomeInputObject references itself via the required fields: SomeInputObject.startLoop, AnotherInputObject.nextInLoop, YetAnotherInputObject.closeLoop.', locations: [ { line: 7, column: 9 }, { line: 11, column: 9 }, { line: 15, column: 9 }, ], }, + { + message: + 'Input Object AnotherInputObject references itself via the required fields: AnotherInputObject.nextInLoop, YetAnotherInputObject.closeLoop, SomeInputObject.startLoop.', + locations: [ + { line: 11, column: 9 }, + { line: 15, column: 9 }, + { line: 7, column: 9 }, + ], + }, + { + message: + 'Input Object YetAnotherInputObject references itself via the required fields: YetAnotherInputObject.closeLoop, SomeInputObject.startLoop, AnotherInputObject.nextInLoop.', + locations: [ + { line: 15, column: 9 }, + { line: 7, column: 9 }, + { line: 11, column: 9 }, + ], + }, ]); }); @@ -986,7 +1004,7 @@ describe('Type System: Input Objects must have fields', () => { expectJSON(validateSchema(schema)).toDeepEqual([ { message: - 'Invalid circular reference. The Input Object SomeInputObject references itself via the non-null fields: SomeInputObject.startLoop, AnotherInputObject.closeLoop.', + 'Input Object SomeInputObject references itself via the required fields: SomeInputObject.startLoop, AnotherInputObject.closeLoop.', locations: [ { line: 7, column: 9 }, { line: 11, column: 9 }, @@ -994,16 +1012,20 @@ describe('Type System: Input Objects must have fields', () => { }, { message: - 'Invalid circular reference. The Input Object AnotherInputObject references itself via the non-null fields: AnotherInputObject.startSecondLoop, YetAnotherInputObject.closeSecondLoop.', + 'Input Object AnotherInputObject references itself via the required fields: AnotherInputObject.closeLoop, SomeInputObject.startLoop.', locations: [ - { line: 12, column: 9 }, - { line: 16, column: 9 }, + { line: 11, column: 9 }, + { line: 7, column: 9 }, ], }, { message: - 'Invalid circular reference. The Input Object YetAnotherInputObject references itself in the non-null field YetAnotherInputObject.nonNullSelf.', - locations: [{ line: 17, column: 9 }], + 'Input Object YetAnotherInputObject references itself via the required fields: YetAnotherInputObject.closeSecondLoop, AnotherInputObject.closeLoop, SomeInputObject.startLoop.', + locations: [ + { line: 16, column: 9 }, + { line: 11, column: 9 }, + { line: 7, column: 9 }, + ], }, ]); }); @@ -2409,6 +2431,240 @@ describe('Type System: OneOf Input Object fields must be nullable', () => { }); }); +describe('Type System: Input Objects must not have unbreakable cycles', () => { + it('accepts a OneOf Input Object with a scalar field', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + a: Int + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a OneOf Input Object with a recursive list field', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + a: [A!] + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a OneOf Input Object referencing a non-OneOf input object', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + b: B + } + + input B { + x: Int + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a OneOf/OneOf cycle with a scalar escape', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + b: B + escape: Int + } + + input B @oneOf { + a: A + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a OneOf/non-OneOf cycle with a nullable escape', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + b: B + } + + input B { + a: A + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('rejects a self-referencing OneOf type with no escapes', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + self: A + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Input Object A references itself via the required fields: A.self.', + locations: [{ line: 7, column: 9 }], + }, + ]); + }); + + it('rejects a mixed OneOf/non-OneOf cycle with no escapes', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + b: B + } + + input B { + a: A! + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Input Object A references itself via the required fields: A.b, B.a.', + locations: [ + { line: 7, column: 9 }, + { line: 11, column: 9 }, + ], + }, + { + message: + 'Input Object B references itself via the required fields: B.a, A.b.', + locations: [ + { line: 11, column: 9 }, + { line: 7, column: 9 }, + ], + }, + ]); + }); + + it('accepts a OneOf/non-OneOf with scalar escape', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + b: B + escape: Int + } + + input B { + a: A! + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a non-OneOf/non-OneOf cycle with a nullable escape', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A { + b: B! + } + + input B { + a: A + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a non-OneOf/non-OneOf cycle with a list escape', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A { + b: [B!]! + } + + input B { + a: A! + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('rejects a larger mixed OneOf/non-OneOf cycle with no escapes', () => { + const schema = buildSchema(` + type Query { + test(arg: A): Int + } + + input A @oneOf { + b: B + } + + input B { + c: C! + } + + input C @oneOf { + a: A + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Input Object A references itself via the required fields: A.b, B.c, C.a.', + locations: [ + { line: 7, column: 9 }, + { line: 11, column: 9 }, + { line: 15, column: 9 }, + ], + }, + { + message: + 'Input Object B references itself via the required fields: B.c, C.a, A.b.', + locations: [ + { line: 11, column: 9 }, + { line: 15, column: 9 }, + { line: 7, column: 9 }, + ], + }, + { + message: + 'Input Object C references itself via the required fields: C.a, A.b, B.c.', + locations: [ + { line: 15, column: 9 }, + { line: 7, column: 9 }, + { line: 11, column: 9 }, + ], + }, + ]); + }); +}); + describe('Objects must adhere to Interface they implement', () => { it('accepts an Object which implements an Interface', () => { const schema = buildSchema(` diff --git a/src/type/validate.ts b/src/type/validate.ts index ede99c8054..7667616733 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -360,12 +360,15 @@ function validateName( } function validateTypes(context: SchemaValidationContext): void { - // Ensure Input Objects do not contain non-nullable circular references. - const validateInputObjectNonNullCircularRefs = - createInputObjectNonNullCircularRefsValidator(context); + const inputObjectUnbreakableCycleCheck = + createInputObjectUnbreakableCycleCheck(); const validateInputObjectDefaultValueCircularRefs = createInputObjectDefaultValueCircularRefsValidator(context); const typeMap = context.schema.getTypeMap(); + + // Collect Input Object types that have unbreakable cycles. + const typesWithUnbreakableCycles = new Set(); + for (const type of Object.values(typeMap)) { // Ensure all provided types are in fact GraphQL type. if (!isNamedType(type)) { @@ -403,14 +406,25 @@ function validateTypes(context: SchemaValidationContext): void { // Ensure Input Object fields are valid. validateInputFields(context, type); - // Ensure Input Objects do not contain invalid field circular references. - // Ensure Input Objects do not contain non-nullable circular references. - validateInputObjectNonNullCircularRefs(type); + // Ensure Input Objects do not have unbreakable cycles. + if (inputObjectUnbreakableCycleCheck(type)) { + typesWithUnbreakableCycles.add(type); + } // Ensure Input Objects do not contain invalid default value circular references. validateInputObjectDefaultValueCircularRefs(type); } } + + // Report errors for Input Object types that have unbreakable cycles. + for (const type of typesWithUnbreakableCycles) { + const cyclePath = traceUnbreakableCycle(type, typesWithUnbreakableCycles); + const pathStr = cyclePath.map((p) => p.fieldStr).join(', '); + context.reportError( + `Input Object ${type} references itself via the required fields: ${pathStr}.`, + cyclePath.map((p) => p.astNode), + ); + } } function validateFields( @@ -726,66 +740,142 @@ function validateOneOfInputObjectField( } } -function createInputObjectNonNullCircularRefsValidator( - context: SchemaValidationContext, -): (inputObj: GraphQLInputObjectType) => void { - // Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.js'. - // Tracks already visited types to maintain O(N) and to ensure that cycles - // are not redundantly reported. - const visitedTypes = new Set(); +// Implements the spec's InputObjectHasUnbreakableCycle algorithm. +// Tracks already checked types to maintain O(N) and to ensure that types +// are not redundantly checked. +function createInputObjectUnbreakableCycleCheck(): ( + inputObj: GraphQLInputObjectType, +) => boolean { + const knownNoCycle = new Set(); + const visited = new Set(); - // Array of types nodes used to produce meaningful errors - const fieldPath: Array<{ fieldStr: string; astNode: Maybe }> = []; + return inputObjectHasUnbreakableCycle; - // Position in the type path - const fieldPathIndexByTypeName: ObjMap = - Object.create(null); + function inputObjectHasUnbreakableCycle( + inputObj: GraphQLInputObjectType, + ): boolean { + if (knownNoCycle.has(inputObj)) { + return false; + } + if (visited.has(inputObj)) { + return true; + } - return detectCycleRecursive; + visited.add(inputObj); - // This does a straight-forward DFS to find cycles. - // It does not terminate when a cycle was found but continues to explore - // the graph to find all possible cycles. - function detectCycleRecursive(inputObj: GraphQLInputObjectType): void { - if (visitedTypes.has(inputObj)) { - return; + let result: boolean; + + if (inputObj.isOneOf) { + // OneOf Input Objects have an unbreakable cycle if every field has one. + result = true; + for (const field of Object.values(inputObj.getFields())) { + if (!inputFieldTypeHasUnbreakableCycle(field.type)) { + result = false; + break; + } + } + } else { + // Normal Input Objects have an unbreakable cycle if any non-null field has one. + result = false; + for (const field of Object.values(inputObj.getFields())) { + if ( + isNonNullType(field.type) && + inputFieldTypeHasUnbreakableCycle(field.type.ofType) + ) { + result = true; + break; + } + } } - visitedTypes.add(inputObj); - fieldPathIndexByTypeName[inputObj.name] = fieldPath.length; + visited.delete(inputObj); + + if (!result) { + knownNoCycle.add(inputObj); + } + return result; + } - const fields = Object.values(inputObj.getFields()); - for (const field of fields) { - if (isNonNullType(field.type) && isInputObjectType(field.type.ofType)) { - const fieldType = field.type.ofType; - const cycleIndex = fieldPathIndexByTypeName[fieldType.name]; + function inputFieldTypeHasUnbreakableCycle( + fieldType: GraphQLInputType, + ): boolean { + if (isListType(fieldType)) { + return false; + } + if (isNonNullType(fieldType)) { + return inputFieldTypeHasUnbreakableCycle(fieldType.ofType); + } + if (!isInputObjectType(fieldType)) { + return false; + } + return inputObjectHasUnbreakableCycle(fieldType); + } +} + +// For an Input Object type with an unbreakable cycle, traces a witness cycle +// path by following required edges to other types with unbreakable cycles. +function traceUnbreakableCycle( + startType: GraphQLInputObjectType, + typesWithUnbreakableCycles: ReadonlySet, +): Array<{ fieldStr: string; astNode: Maybe }> { + const path: Array<{ fieldStr: string; astNode: Maybe }> = []; + const seen = new Set(); + + let current: Maybe = startType; + while (current != null && !seen.has(current)) { + seen.add(current); + let next: Maybe; + + for (const field of Object.values(current.getFields())) { + let target: Maybe; + + if (current.isOneOf) { + if ( + isInputObjectType(field.type) && + typesWithUnbreakableCycles.has(field.type) + ) { + target = field.type; + } + } else if (isNonNullType(field.type)) { + target = unwrapToUnbreakableCycleType( + field.type.ofType, + typesWithUnbreakableCycles, + ); + } - fieldPath.push({ - fieldStr: `${inputObj}.${field.name}`, + if (target != null) { + path.push({ + fieldStr: `${current}.${field.name}`, astNode: field.astNode, }); - if (cycleIndex === undefined) { - detectCycleRecursive(fieldType); - } else { - const cyclePath = fieldPath.slice(cycleIndex); - const pathStr = cyclePath - .map((fieldObj) => fieldObj.fieldStr) - .join(', '); - context.reportError( - `Invalid circular reference. The Input Object ${fieldType} references itself ${ - cyclePath.length > 1 - ? 'via the non-null fields:' - : 'in the non-null field' - } ${pathStr}.`, - cyclePath.map((fieldObj) => fieldObj.astNode), - ); - } - fieldPath.pop(); + next = target; + break; } } - fieldPathIndexByTypeName[inputObj.name] = undefined; + current = next; + } + + return path; +} + +function unwrapToUnbreakableCycleType( + type: GraphQLInputType, + typesWithUnbreakableCycles: ReadonlySet, +): Maybe { + if (isListType(type)) { + return undefined; + } + if (isNonNullType(type)) { + return unwrapToUnbreakableCycleType( + type.ofType, + typesWithUnbreakableCycles, + ); + } + if (isInputObjectType(type) && typesWithUnbreakableCycles.has(type)) { + return type; } + return undefined; } function createInputObjectDefaultValueCircularRefsValidator(