From 5276c0d1de5ff57493ce1379e0960145c551b03b Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Thu, 19 Feb 2026 02:55:13 -0800 Subject: [PATCH 1/2] init --- .../NoDefaultValueCircularRefs.java | 199 +++++++++++++++++ .../validation/SchemaValidationErrorType.java | 3 +- .../schema/validation/SchemaValidator.java | 1 + .../CircularInputDefaultValuesTest.groovy | 90 ++++++++ .../schema/diffing/SchemaDiffingTest.groovy | 8 +- .../NoDefaultValueCircularRefsTest.groovy | 202 ++++++++++++++++++ .../validation/SchemaValidatorTest.groovy | 19 +- 7 files changed, 508 insertions(+), 14 deletions(-) create mode 100644 src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java create mode 100644 src/test/groovy/graphql/CircularInputDefaultValuesTest.groovy create mode 100644 src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy diff --git a/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java b/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java new file mode 100644 index 0000000000..7f32106ce0 --- /dev/null +++ b/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java @@ -0,0 +1,199 @@ +package graphql.schema.validation; + +import graphql.Internal; +import graphql.language.ArrayValue; +import graphql.language.ObjectField; +import graphql.language.ObjectValue; +import graphql.language.Value; +import graphql.schema.GraphQLInputObjectField; +import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLTypeVisitorStub; +import graphql.schema.InputValueWithState; +import graphql.util.TraversalControl; +import graphql.util.TraverserContext; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static graphql.schema.GraphQLTypeUtil.unwrapAll; + +/** + * Ensures that input object field default values do not form circular references. + *
+ * For example, consider this type configuration: + * + * input A { b:B = {} } + * input B { a:A = {} } + * + *
+ * The default values used in these types form a cycle that can create an infinitely large + * value. This validator rejects default values that can create these kinds of cycles. + *
+ * This validator is equivalent to graphql-js v17's + * {@code createInputObjectDefaultValueCircularRefsValidator} + */ +@Internal +public class NoDefaultValueCircularRefs extends GraphQLTypeVisitorStub { + + // Coordinates already fully traversed without finding a cycle, used to avoid duplicate error reports + // when the same coordinate is reachable from multiple input object types. + private final Set fullyExplored = new LinkedHashSet<>(); + + // The current traversal path as an insertion-ordered set of coordinate strings ("Type.field"). + private final LinkedHashSet traversalPath = new LinkedHashSet<>(); + + @Override + public TraversalControl visitGraphQLInputObjectType(GraphQLInputObjectType type, TraverserContext context) { + SchemaValidationErrorCollector errorCollector = context.getVarFromParents(SchemaValidationErrorCollector.class); + + // Start with an empty object as a way to visit every field in this input + // object type and apply every default value. + checkLiteralDefaultValueCycle(type, ObjectValue.newObjectValue().build(), errorCollector); + + return TraversalControl.CONTINUE; + } + + /** traverse a Value literal and check for cycles */ + private void checkLiteralDefaultValueCycle( + GraphQLInputObjectType inputObj, + Value defaultValue, + SchemaValidationErrorCollector errorCollector + ) { + if (defaultValue instanceof ArrayValue) { + for (Value itemValue : ((ArrayValue) defaultValue).getValues()) { + checkLiteralDefaultValueCycle(inputObj, itemValue, errorCollector); + } + return; + } + + if (!(defaultValue instanceof ObjectValue)) { + return; + } + + ObjectValue objectValue = (ObjectValue) defaultValue; + Map> fieldValues = new LinkedHashMap<>(); + for (ObjectField field : objectValue.getObjectFields()) { + fieldValues.put(field.getName(), field.getValue()); + } + + for (GraphQLInputObjectField field : inputObj.getFieldDefinitions()) { + GraphQLType namedFieldType = unwrapAll(field.getType()); + if (!(namedFieldType instanceof GraphQLInputObjectType)) { + continue; + } + + GraphQLInputObjectType fieldInputType = (GraphQLInputObjectType) namedFieldType; + if (fieldValues.containsKey(field.getName())) { + // Field is explicitly provided -- check the provided value + checkLiteralDefaultValueCycle(fieldInputType, fieldValues.get(field.getName()), errorCollector); + } else { + // Field is not provided -- check its default value + checkFieldDefaultValueCycle(field, fieldInputType, inputObj.getName(), errorCollector); + } + } + } + + /** traverse an external value and check for cycles */ + private void checkExternalDefaultValueCycle( + GraphQLInputObjectType inputObj, + Object defaultValue, + SchemaValidationErrorCollector errorCollector + ) { + if (defaultValue instanceof Iterable) { + for (Object itemValue : (Iterable) defaultValue) { + if (itemValue != null) { + checkExternalDefaultValueCycle(inputObj, itemValue, errorCollector); + } + } + return; + } + + if (!(defaultValue instanceof Map)) { + return; + } + + @SuppressWarnings("unchecked") + Map mapValue = (Map) defaultValue; + + for (GraphQLInputObjectField field : inputObj.getFieldDefinitions()) { + GraphQLType namedFieldType = unwrapAll(field.getType()); + if (!(namedFieldType instanceof GraphQLInputObjectType)) { + continue; + } + + GraphQLInputObjectType fieldInputType = (GraphQLInputObjectType) namedFieldType; + if (mapValue.containsKey(field.getName())) { + Object value = mapValue.get(field.getName()); + if (value != null) { + checkExternalDefaultValueCycle(fieldInputType, value, errorCollector); + } + } else { + checkFieldDefaultValueCycle(field, fieldInputType, inputObj.getName(), errorCollector); + } + } + } + + /** Check if a field's default value creates a cycle. */ + private void checkFieldDefaultValueCycle( + GraphQLInputObjectField field, + GraphQLInputObjectType fieldType, + String parentTypeName, + SchemaValidationErrorCollector errorCollector + ) { + InputValueWithState defaultInput = field.getInputFieldDefaultValue(); + if (defaultInput.isNotSet()) { + return; + } + + String coordinate = parentTypeName + "." + field.getName(); + + if (traversalPath.contains(coordinate)) { + // Cycle found — collect intermediate nodes (everything after the coordinate itself) + List intermediaries = new ArrayList<>(); + boolean found = false; + for (String entry : traversalPath) { + if (found) { + intermediaries.add(entry); + } + if (entry.equals(coordinate)) { + found = true; + } + } + + String message; + if (intermediaries.isEmpty()) { + message = "Invalid circular reference. The default value of Input Object field " + + coordinate + " references itself."; + } else { + message = "Invalid circular reference. The default value of Input Object field " + + coordinate + " references itself via the default values of: " + + String.join(", ", intermediaries) + "."; + } + + errorCollector.addError(new SchemaValidationError( + SchemaValidationErrorType.DefaultValueCircularRef, message)); + return; + } + + if (fullyExplored.contains(coordinate)) { + return; + } + fullyExplored.add(coordinate); + + traversalPath.add(coordinate); + + if (defaultInput.isLiteral() && defaultInput.getValue() instanceof Value) { + checkLiteralDefaultValueCycle(fieldType, (Value) defaultInput.getValue(), errorCollector); + } else if (defaultInput.isExternal() && defaultInput.getValue() != null) { + checkExternalDefaultValueCycle(fieldType, defaultInput.getValue(), errorCollector); + } + + traversalPath.remove(coordinate); + } +} diff --git a/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java b/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java index b8392b18d7..d8b473928b 100644 --- a/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java +++ b/src/main/java/graphql/schema/validation/SchemaValidationErrorType.java @@ -24,5 +24,6 @@ public enum SchemaValidationErrorType implements SchemaValidationErrorClassifica OneOfNonNullableField, RequiredInputFieldCannotBeDeprecated, RequiredFieldArgumentCannotBeDeprecated, - RequiredDirectiveArgumentCannotBeDeprecated + RequiredDirectiveArgumentCannotBeDeprecated, + DefaultValueCircularRef } diff --git a/src/main/java/graphql/schema/validation/SchemaValidator.java b/src/main/java/graphql/schema/validation/SchemaValidator.java index 1f676fb77e..fac409378a 100644 --- a/src/main/java/graphql/schema/validation/SchemaValidator.java +++ b/src/main/java/graphql/schema/validation/SchemaValidator.java @@ -19,6 +19,7 @@ public class SchemaValidator { public SchemaValidator() { rules.add(new NoUnbrokenInputCycles()); + rules.add(new NoDefaultValueCircularRefs()); rules.add(new TypesImplementInterfaces()); rules.add(new TypeAndFieldRule()); rules.add(new DefaultValuesAreValid()); diff --git a/src/test/groovy/graphql/CircularInputDefaultValuesTest.groovy b/src/test/groovy/graphql/CircularInputDefaultValuesTest.groovy new file mode 100644 index 0000000000..43a92975e3 --- /dev/null +++ b/src/test/groovy/graphql/CircularInputDefaultValuesTest.groovy @@ -0,0 +1,90 @@ +package graphql + +import graphql.schema.validation.InvalidSchemaException +import spock.lang.Specification + +/** + * Tests for mutually recursive input types with default values. + * + * These schemas are now rejected at build time by NoDefaultValueCircularRefs, + * which detects circular references in input object field default values. + * + * Previously, graphql-java accepted these schemas at build time but hit a + * StackOverflowError at query execution time when the circular defaults were + * expanded in ValuesResolverConversion.defaultValueToInternalValue. + */ +class CircularInputDefaultValuesTest extends Specification { + + def "mutually recursive input types with default values - rejected at schema build time"() { + when: + TestUtil.schema(''' + type Query { + test(arg: A): String + } + input A { b: B = {} } + input B { a: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference") + } + + def "self-referential input type with default value - rejected at schema build time"() { + when: + TestUtil.schema(''' + type Query { + test(arg: A): String + } + input A { a: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference") + } + + def "mutually recursive input types with default values - rejected before query execution"() { + when: + TestUtil.schema(''' + type Query { + test(arg: A): String + } + input A { b: B = {} } + input B { a: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference") + } + + def "self-referential input type with default value - rejected before query execution"() { + when: + TestUtil.schema(''' + type Query { + test(arg: A): String + } + input A { a: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference") + } + + def "mutually recursive defaults via argument default - rejected at schema build time"() { + when: + TestUtil.schema(''' + type Query { + test(arg: A = {}): String + } + input A { b: B = {} } + input B { a: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference") + } +} diff --git a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy index 46481fa7a5..bd263631ab 100644 --- a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy @@ -1446,20 +1446,20 @@ class SchemaDiffingTest extends Specification { def schema1 = schema(''' input I { name: String - field: I = {name: "default name"} + field: I = {name: "default name", field: null} } type Query { foo(arg: I): String - } + } ''') def schema2 = schema(''' input I { name: String - field: [I] = [{name: "default name"}] + field: [I] = [{name: "default name", field: null}] } type Query { foo(arg: I): String - } + } ''') when: diff --git a/src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy b/src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy new file mode 100644 index 0000000000..bd35006c25 --- /dev/null +++ b/src/test/groovy/graphql/schema/validation/NoDefaultValueCircularRefsTest.groovy @@ -0,0 +1,202 @@ +package graphql.schema.validation + +import graphql.TestUtil +import spock.lang.Specification + +class NoDefaultValueCircularRefsTest extends Specification { + def "self-referential default value is rejected"() { + when: + TestUtil.schema(''' + type Query { test(arg: A): String } + input A { x: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field A.x references itself.") + } + + def "mutual recursion through defaults is rejected"() { + when: + TestUtil.schema(''' + type Query { test(arg: A): String } + input A { b: B = {} } + input B { a: A = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference") + e.message.contains("A.b") + } + + def "transitive cycle through three types is rejected"() { + when: + TestUtil.schema(''' + type Query { test(arg: B): String } + input B { x: B2 = {} } + input B2 { x: B3 = {} } + input B3 { x: B = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field B.x references itself via the default values of: B2.x, B3.x.") + } + + def "self-reference through list wrapping"() { + when: + TestUtil.schema(''' + type Query { test(arg: C): String } + input C { x: [C] = [{}] } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field C.x references itself.") + } + + def "nested default value that eventually cycles"() { + when: + TestUtil.schema(''' + type Query { test(arg: D): String } + input D { x: D = { x: { x: {} } } } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field D.x references itself.") + } + + def "cross-field cycle through defaults"() { + when: + TestUtil.schema(''' + type Query { test(arg: E): String } + input E { + x: E = { x: null } + y: E = { y: null } + } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field E.x references itself via the default values of: E.y.") + } + + def "cycle through non-null wrapping"() { + when: + TestUtil.schema(''' + type Query { test(arg: F): String } + input F { x: F2! = {} } + input F2 { x: F = { x: {} } } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field F2.x references itself.") + } + + def "partial default with non-provided recursive field"() { + when: + TestUtil.schema(''' + type Query { test(arg: A): String } + input A { x: B = {name: "hi"} } + input B { + name: String + a: A = {} + } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("Invalid circular reference. The default value of Input Object field A.x references itself via the default values of: B.a.") + } + + def "multiple independent cycles are reported"() { + when: + TestUtil.schema(''' + type Query { test(a: A, b: P): String } + input A { x: A = {} } + input P { x: P = {} } + ''') + + then: + def e = thrown(InvalidSchemaException) + e.message.contains("A.x references itself") + e.message.contains("P.x references itself") + } + + def "explicit field in default breaks cycle"() { + when: + def schema = TestUtil.schema(''' + type Query { test(arg: A): String } + input A { b: B = {a: null} } + input B { a: A = {} } + ''') + + then: + noExceptionThrown() + schema.getType("A") != null + } + + def "recursive field without default does not cycle"() { + when: + def schema = TestUtil.schema(''' + type Query { test(arg: A): String } + input A { b: B = {} } + input B { a: A } + ''') + + then: + noExceptionThrown() + schema.getType("A") != null + } + + def "scalar default value does not cycle"() { + when: + def schema = TestUtil.schema(''' + type Query { test(arg: A): String } + input A { name: String = "hi" } + ''') + + then: + noExceptionThrown() + schema.getType("A") != null + } + + def "null literal default does not cycle"() { + when: + def schema = TestUtil.schema(''' + type Query { test(arg: A): String } + input A { x: A = null } + ''') + + then: + noExceptionThrown() + schema.getType("A") != null + } + + def "empty list default does not cycle"() { + when: + def schema = TestUtil.schema(''' + type Query { test(arg: A): String } + input A { x: [A] = [] } + ''') + + then: + noExceptionThrown() + schema.getType("A") != null + } + + def "explicit null on recursive field breaks self-reference"() { + when: + def schema = TestUtil.schema(''' + type Query { test(arg: A): String } + input A { x: A = {x: null} } + ''') + + then: + noExceptionThrown() + schema.getType("A") != null + } +} diff --git a/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy b/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy index 706542df8d..a854129d41 100644 --- a/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy +++ b/src/test/groovy/graphql/schema/validation/SchemaValidatorTest.groovy @@ -11,15 +11,16 @@ class SchemaValidatorTest extends Specification { def validator = new SchemaValidator() def rules = validator.rules then: - rules.size() == 9 + rules.size() == 10 rules[0] instanceof NoUnbrokenInputCycles - rules[1] instanceof TypesImplementInterfaces - rules[2] instanceof TypeAndFieldRule - rules[3] instanceof DefaultValuesAreValid - rules[4] instanceof AppliedDirectivesAreValid - rules[5] instanceof AppliedDirectiveArgumentsAreValid - rules[6] instanceof InputAndOutputTypesUsedAppropriately - rules[7] instanceof OneOfInputObjectRules - rules[8] instanceof DeprecatedInputObjectAndArgumentsAreValid + rules[1] instanceof NoDefaultValueCircularRefs + rules[2] instanceof TypesImplementInterfaces + rules[3] instanceof TypeAndFieldRule + rules[4] instanceof DefaultValuesAreValid + rules[5] instanceof AppliedDirectivesAreValid + rules[6] instanceof AppliedDirectiveArgumentsAreValid + rules[7] instanceof InputAndOutputTypesUsedAppropriately + rules[8] instanceof OneOfInputObjectRules + rules[9] instanceof DeprecatedInputObjectAndArgumentsAreValid } } From cd67897ffba506b140f74c37863ab02e951528b8 Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Thu, 19 Feb 2026 03:12:54 -0800 Subject: [PATCH 2/2] improve spec alignment --- .../NoDefaultValueCircularRefs.java | 124 +++++++++++------- 1 file changed, 77 insertions(+), 47 deletions(-) diff --git a/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java b/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java index 7f32106ce0..70822426da 100644 --- a/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java +++ b/src/main/java/graphql/schema/validation/NoDefaultValueCircularRefs.java @@ -24,7 +24,9 @@ import static graphql.schema.GraphQLTypeUtil.unwrapAll; /** - * Ensures that input object field default values do not form circular references. + * Validates that {@code InputObjectDefaultValueHasCycle(inputObject)} is {@code false} + * for every input object type, as required by the Input Object type validation rules + * in the GraphQL specification. *
* For example, consider this type configuration: * @@ -34,9 +36,8 @@ *
* The default values used in these types form a cycle that can create an infinitely large * value. This validator rejects default values that can create these kinds of cycles. - *
- * This validator is equivalent to graphql-js v17's - * {@code createInputObjectDefaultValueCircularRefsValidator} + * + * @see Input Objects Type Validation */ @Internal public class NoDefaultValueCircularRefs extends GraphQLTypeVisitorStub { @@ -45,119 +46,146 @@ public class NoDefaultValueCircularRefs extends GraphQLTypeVisitorStub { // when the same coordinate is reachable from multiple input object types. private final Set fullyExplored = new LinkedHashSet<>(); - // The current traversal path as an insertion-ordered set of coordinate strings ("Type.field"). - private final LinkedHashSet traversalPath = new LinkedHashSet<>(); + // The spec's "visitedFields" set, tracked as coordinate strings ("Type.field"). + // The spec creates a new immutable set at each step; this implementation mutates and backtracks + // for the same effect. + private final LinkedHashSet visitedFields = new LinkedHashSet<>(); @Override public TraversalControl visitGraphQLInputObjectType(GraphQLInputObjectType type, TraverserContext context) { SchemaValidationErrorCollector errorCollector = context.getVarFromParents(SchemaValidationErrorCollector.class); - // Start with an empty object as a way to visit every field in this input - // object type and apply every default value. - checkLiteralDefaultValueCycle(type, ObjectValue.newObjectValue().build(), errorCollector); + // Implements InputObjectDefaultValueHasCycle(inputObject) from the spec: + // "If defaultValue is not provided, initialize it to an empty unordered map." + inputObjectDefaultValueHasCycle(type, ObjectValue.newObjectValue().build(), errorCollector); return TraversalControl.CONTINUE; } - /** traverse a Value literal and check for cycles */ - private void checkLiteralDefaultValueCycle( - GraphQLInputObjectType inputObj, + /** + * Implements {@code InputObjectDefaultValueHasCycle(inputObject, defaultValue, visitedFields)} + * from the spec, for literal (AST) default values. + */ + private void inputObjectDefaultValueHasCycle( + GraphQLInputObjectType inputObject, Value defaultValue, SchemaValidationErrorCollector errorCollector ) { + // "If defaultValue is a list: for each itemValue in defaultValue..." if (defaultValue instanceof ArrayValue) { for (Value itemValue : ((ArrayValue) defaultValue).getValues()) { - checkLiteralDefaultValueCycle(inputObj, itemValue, errorCollector); + inputObjectDefaultValueHasCycle(inputObject, itemValue, errorCollector); } return; } + // "Otherwise, if defaultValue is an unordered map..." if (!(defaultValue instanceof ObjectValue)) { return; } ObjectValue objectValue = (ObjectValue) defaultValue; - Map> fieldValues = new LinkedHashMap<>(); + Map> defaultValueMap = new LinkedHashMap<>(); for (ObjectField field : objectValue.getObjectFields()) { - fieldValues.put(field.getName(), field.getValue()); + defaultValueMap.put(field.getName(), field.getValue()); } - for (GraphQLInputObjectField field : inputObj.getFieldDefinitions()) { + // "For each field in inputObject: if InputFieldDefaultValueHasCycle(...)" + for (GraphQLInputObjectField field : inputObject.getFieldDefinitions()) { GraphQLType namedFieldType = unwrapAll(field.getType()); if (!(namedFieldType instanceof GraphQLInputObjectType)) { continue; } - GraphQLInputObjectType fieldInputType = (GraphQLInputObjectType) namedFieldType; - if (fieldValues.containsKey(field.getName())) { - // Field is explicitly provided -- check the provided value - checkLiteralDefaultValueCycle(fieldInputType, fieldValues.get(field.getName()), errorCollector); + GraphQLInputObjectType fieldInputObject = (GraphQLInputObjectType) namedFieldType; + String fieldName = field.getName(); + if (defaultValueMap.containsKey(fieldName)) { + // "Let fieldDefaultValue be the value for fieldName in defaultValue. + // If fieldDefaultValue exists: InputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue, visitedFields)" + inputObjectDefaultValueHasCycle(fieldInputObject, defaultValueMap.get(fieldName), errorCollector); } else { - // Field is not provided -- check its default value - checkFieldDefaultValueCycle(field, fieldInputType, inputObj.getName(), errorCollector); + // "Otherwise: let fieldDefaultValue be the default value of field..." + inputFieldDefaultValueHasCycle(field, fieldInputObject, inputObject.getName(), errorCollector); } } } - /** traverse an external value and check for cycles */ - private void checkExternalDefaultValueCycle( - GraphQLInputObjectType inputObj, + /** + * Implements {@code InputObjectDefaultValueHasCycle(inputObject, defaultValue, visitedFields)} + * from the spec, for external (programmatic Map/List) default values. + */ + private void inputObjectDefaultValueHasCycle( + GraphQLInputObjectType inputObject, Object defaultValue, SchemaValidationErrorCollector errorCollector ) { + // "If defaultValue is a list: for each itemValue in defaultValue..." if (defaultValue instanceof Iterable) { for (Object itemValue : (Iterable) defaultValue) { if (itemValue != null) { - checkExternalDefaultValueCycle(inputObj, itemValue, errorCollector); + inputObjectDefaultValueHasCycle(inputObject, itemValue, errorCollector); } } return; } + // "Otherwise, if defaultValue is an unordered map..." if (!(defaultValue instanceof Map)) { return; } @SuppressWarnings("unchecked") - Map mapValue = (Map) defaultValue; + Map defaultValueMap = (Map) defaultValue; - for (GraphQLInputObjectField field : inputObj.getFieldDefinitions()) { + // "For each field in inputObject: if InputFieldDefaultValueHasCycle(...)" + for (GraphQLInputObjectField field : inputObject.getFieldDefinitions()) { GraphQLType namedFieldType = unwrapAll(field.getType()); if (!(namedFieldType instanceof GraphQLInputObjectType)) { continue; } - GraphQLInputObjectType fieldInputType = (GraphQLInputObjectType) namedFieldType; - if (mapValue.containsKey(field.getName())) { - Object value = mapValue.get(field.getName()); - if (value != null) { - checkExternalDefaultValueCycle(fieldInputType, value, errorCollector); + GraphQLInputObjectType fieldInputObject = (GraphQLInputObjectType) namedFieldType; + String fieldName = field.getName(); + if (defaultValueMap.containsKey(fieldName)) { + // "Let fieldDefaultValue be the value for fieldName in defaultValue. + // If fieldDefaultValue exists: InputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue, visitedFields)" + Object fieldDefaultValue = defaultValueMap.get(fieldName); + if (fieldDefaultValue != null) { + inputObjectDefaultValueHasCycle(fieldInputObject, fieldDefaultValue, errorCollector); } } else { - checkFieldDefaultValueCycle(field, fieldInputType, inputObj.getName(), errorCollector); + // "Otherwise: let fieldDefaultValue be the default value of field..." + inputFieldDefaultValueHasCycle(field, fieldInputObject, inputObject.getName(), errorCollector); } } } - /** Check if a field's default value creates a cycle. */ - private void checkFieldDefaultValueCycle( + /** + * Implements the "Otherwise" branch of {@code InputFieldDefaultValueHasCycle(field, defaultValue, visitedFields)} + * from the spec — called when the field is not present in the parent's default value, + * so the field's own default will be used at runtime. + */ + private void inputFieldDefaultValueHasCycle( GraphQLInputObjectField field, - GraphQLInputObjectType fieldType, + GraphQLInputObjectType namedFieldType, String parentTypeName, SchemaValidationErrorCollector errorCollector ) { - InputValueWithState defaultInput = field.getInputFieldDefaultValue(); - if (defaultInput.isNotSet()) { + // "Let fieldDefaultValue be the default value of field. + // If fieldDefaultValue does not exist: return false." + InputValueWithState fieldDefaultValue = field.getInputFieldDefaultValue(); + if (fieldDefaultValue.isNotSet()) { return; } String coordinate = parentTypeName + "." + field.getName(); - if (traversalPath.contains(coordinate)) { + // "If field is within visitedFields: return true." + if (visitedFields.contains(coordinate)) { // Cycle found — collect intermediate nodes (everything after the coordinate itself) List intermediaries = new ArrayList<>(); boolean found = false; - for (String entry : traversalPath) { + for (String entry : visitedFields) { if (found) { intermediaries.add(entry); } @@ -186,14 +214,16 @@ private void checkFieldDefaultValueCycle( } fullyExplored.add(coordinate); - traversalPath.add(coordinate); + // "Let nextVisitedFields be a new set containing field and everything from visitedFields. + // Return InputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue, nextVisitedFields)." + visitedFields.add(coordinate); - if (defaultInput.isLiteral() && defaultInput.getValue() instanceof Value) { - checkLiteralDefaultValueCycle(fieldType, (Value) defaultInput.getValue(), errorCollector); - } else if (defaultInput.isExternal() && defaultInput.getValue() != null) { - checkExternalDefaultValueCycle(fieldType, defaultInput.getValue(), errorCollector); + if (fieldDefaultValue.isLiteral() && fieldDefaultValue.getValue() instanceof Value) { + inputObjectDefaultValueHasCycle(namedFieldType, (Value) fieldDefaultValue.getValue(), errorCollector); + } else if (fieldDefaultValue.isExternal() && fieldDefaultValue.getValue() != null) { + inputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue.getValue(), errorCollector); } - traversalPath.remove(coordinate); + visitedFields.remove(coordinate); } }