Skip to content

Commit 157c7cc

Browse files
validate circular default values (#4253)
* init * improve spec alignment * Avoid unnecessary circular default validation work --------- Co-authored-by: Andreas Marek <[email protected]>
1 parent c9b0e17 commit 157c7cc

7 files changed

Lines changed: 548 additions & 14 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package graphql.schema.validation;
2+
3+
import graphql.Internal;
4+
import graphql.language.ArrayValue;
5+
import graphql.language.ObjectField;
6+
import graphql.language.ObjectValue;
7+
import graphql.language.Value;
8+
import graphql.schema.GraphQLInputObjectField;
9+
import graphql.schema.GraphQLInputObjectType;
10+
import graphql.schema.GraphQLSchemaElement;
11+
import graphql.schema.GraphQLType;
12+
import graphql.schema.GraphQLTypeVisitorStub;
13+
import graphql.schema.InputValueWithState;
14+
import graphql.util.TraversalControl;
15+
import graphql.util.TraverserContext;
16+
17+
import java.util.ArrayList;
18+
import java.util.LinkedHashMap;
19+
import java.util.LinkedHashSet;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Set;
23+
24+
import static graphql.schema.GraphQLTypeUtil.unwrapAll;
25+
26+
/**
27+
* Validates that {@code InputObjectDefaultValueHasCycle(inputObject)} is {@code false}
28+
* for every input object type, as required by the Input Object type validation rules
29+
* in the GraphQL specification.
30+
* <br>
31+
* For example, consider this type configuration:
32+
* <code>
33+
* input A { b:B = {} }
34+
* input B { a:A = {} }
35+
* </code>
36+
* <br>
37+
* The default values used in these types form a cycle that can create an infinitely large
38+
* value. This validator rejects default values that can create these kinds of cycles.
39+
*
40+
* @see <a href="https://spec.graphql.org/draft/#sec-Input-Objects.Type-Validation">Input Objects Type Validation</a>
41+
*/
42+
@Internal
43+
public class NoDefaultValueCircularRefs extends GraphQLTypeVisitorStub {
44+
45+
// Coordinates already fully traversed without finding a cycle, used to avoid duplicate error reports
46+
// when the same coordinate is reachable from multiple input object types.
47+
private final Set<String> fullyExplored = new LinkedHashSet<>();
48+
49+
// The spec's "visitedFields" set, tracked as coordinate strings ("Type.field").
50+
// The spec creates a new immutable set at each step; this implementation mutates and backtracks
51+
// for the same effect.
52+
private final LinkedHashSet<String> visitedFields = new LinkedHashSet<>();
53+
54+
@Override
55+
public TraversalControl visitGraphQLInputObjectType(GraphQLInputObjectType type, TraverserContext<GraphQLSchemaElement> context) {
56+
SchemaValidationErrorCollector errorCollector = context.getVarFromParents(SchemaValidationErrorCollector.class);
57+
58+
// Implements InputObjectDefaultValueHasCycle(inputObject) from the spec:
59+
// "If defaultValue is not provided, initialize it to an empty unordered map."
60+
inputObjectDefaultValueHasCycle(type, ObjectValue.newObjectValue().build(), errorCollector);
61+
62+
return TraversalControl.CONTINUE;
63+
}
64+
65+
/**
66+
* Implements {@code InputObjectDefaultValueHasCycle(inputObject, defaultValue, visitedFields)}
67+
* from the spec, for literal (AST) default values.
68+
*/
69+
private void inputObjectDefaultValueHasCycle(
70+
GraphQLInputObjectType inputObject,
71+
Value<?> defaultValue,
72+
SchemaValidationErrorCollector errorCollector
73+
) {
74+
// "If defaultValue is a list: for each itemValue in defaultValue..."
75+
if (defaultValue instanceof ArrayValue) {
76+
for (Value<?> itemValue : ((ArrayValue) defaultValue).getValues()) {
77+
inputObjectDefaultValueHasCycle(inputObject, itemValue, errorCollector);
78+
}
79+
return;
80+
}
81+
82+
// "Otherwise, if defaultValue is an unordered map..."
83+
if (!(defaultValue instanceof ObjectValue)) {
84+
return;
85+
}
86+
87+
ObjectValue objectValue = (ObjectValue) defaultValue;
88+
Map<String, Value<?>> defaultValueMap = new LinkedHashMap<>();
89+
for (ObjectField field : objectValue.getObjectFields()) {
90+
defaultValueMap.put(field.getName(), field.getValue());
91+
}
92+
93+
// "For each field in inputObject: if InputFieldDefaultValueHasCycle(...)"
94+
for (GraphQLInputObjectField field : inputObject.getFieldDefinitions()) {
95+
String fieldName = field.getName();
96+
boolean hasDefaultValue = defaultValueMap.containsKey(fieldName);
97+
if (!hasDefaultValue && field.getInputFieldDefaultValue().isNotSet()) {
98+
continue;
99+
}
100+
101+
GraphQLType namedFieldType = unwrapAll(field.getType());
102+
if (!(namedFieldType instanceof GraphQLInputObjectType)) {
103+
continue;
104+
}
105+
106+
GraphQLInputObjectType fieldInputObject = (GraphQLInputObjectType) namedFieldType;
107+
if (hasDefaultValue) {
108+
// "Let fieldDefaultValue be the value for fieldName in defaultValue.
109+
// If fieldDefaultValue exists: InputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue, visitedFields)"
110+
inputObjectDefaultValueHasCycle(fieldInputObject, defaultValueMap.get(fieldName), errorCollector);
111+
} else {
112+
// "Otherwise: let fieldDefaultValue be the default value of field..."
113+
inputFieldDefaultValueHasCycle(field, fieldInputObject, inputObject.getName(), errorCollector);
114+
}
115+
}
116+
}
117+
118+
/**
119+
* Implements {@code InputObjectDefaultValueHasCycle(inputObject, defaultValue, visitedFields)}
120+
* from the spec, for external (programmatic Map/List) default values.
121+
*/
122+
private void inputObjectDefaultValueHasCycle(
123+
GraphQLInputObjectType inputObject,
124+
Object defaultValue,
125+
SchemaValidationErrorCollector errorCollector
126+
) {
127+
// "If defaultValue is a list: for each itemValue in defaultValue..."
128+
if (defaultValue instanceof Iterable) {
129+
for (Object itemValue : (Iterable<?>) defaultValue) {
130+
if (itemValue != null) {
131+
inputObjectDefaultValueHasCycle(inputObject, itemValue, errorCollector);
132+
}
133+
}
134+
return;
135+
}
136+
137+
// "Otherwise, if defaultValue is an unordered map..."
138+
if (!(defaultValue instanceof Map)) {
139+
return;
140+
}
141+
142+
@SuppressWarnings("unchecked")
143+
Map<String, Object> defaultValueMap = (Map<String, Object>) defaultValue;
144+
145+
// "For each field in inputObject: if InputFieldDefaultValueHasCycle(...)"
146+
for (GraphQLInputObjectField field : inputObject.getFieldDefinitions()) {
147+
String fieldName = field.getName();
148+
boolean hasDefaultValue = defaultValueMap.containsKey(fieldName);
149+
if (!hasDefaultValue && field.getInputFieldDefaultValue().isNotSet()) {
150+
continue;
151+
}
152+
153+
GraphQLType namedFieldType = unwrapAll(field.getType());
154+
if (!(namedFieldType instanceof GraphQLInputObjectType)) {
155+
continue;
156+
}
157+
158+
GraphQLInputObjectType fieldInputObject = (GraphQLInputObjectType) namedFieldType;
159+
if (hasDefaultValue) {
160+
// "Let fieldDefaultValue be the value for fieldName in defaultValue.
161+
// If fieldDefaultValue exists: InputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue, visitedFields)"
162+
Object fieldDefaultValue = defaultValueMap.get(fieldName);
163+
if (fieldDefaultValue != null) {
164+
inputObjectDefaultValueHasCycle(fieldInputObject, fieldDefaultValue, errorCollector);
165+
}
166+
} else {
167+
// "Otherwise: let fieldDefaultValue be the default value of field..."
168+
inputFieldDefaultValueHasCycle(field, fieldInputObject, inputObject.getName(), errorCollector);
169+
}
170+
}
171+
}
172+
173+
/**
174+
* Implements the "Otherwise" branch of {@code InputFieldDefaultValueHasCycle(field, defaultValue, visitedFields)}
175+
* from the spec — called when the field is not present in the parent's default value,
176+
* so the field's own default will be used at runtime.
177+
*/
178+
private void inputFieldDefaultValueHasCycle(
179+
GraphQLInputObjectField field,
180+
GraphQLInputObjectType namedFieldType,
181+
String parentTypeName,
182+
SchemaValidationErrorCollector errorCollector
183+
) {
184+
// "Let fieldDefaultValue be the default value of field.
185+
// If fieldDefaultValue does not exist: return false."
186+
InputValueWithState fieldDefaultValue = field.getInputFieldDefaultValue();
187+
if (fieldDefaultValue.isNotSet()) {
188+
return;
189+
}
190+
191+
String coordinate = parentTypeName + "." + field.getName();
192+
193+
// "If field is within visitedFields: return true."
194+
if (visitedFields.contains(coordinate)) {
195+
// Cycle found — collect intermediate nodes (everything after the coordinate itself)
196+
List<String> intermediaries = new ArrayList<>();
197+
boolean found = false;
198+
for (String entry : visitedFields) {
199+
if (found) {
200+
intermediaries.add(entry);
201+
}
202+
if (entry.equals(coordinate)) {
203+
found = true;
204+
}
205+
}
206+
207+
String message;
208+
if (intermediaries.isEmpty()) {
209+
message = "Invalid circular reference. The default value of Input Object field "
210+
+ coordinate + " references itself.";
211+
} else {
212+
message = "Invalid circular reference. The default value of Input Object field "
213+
+ coordinate + " references itself via the default values of: "
214+
+ String.join(", ", intermediaries) + ".";
215+
}
216+
217+
errorCollector.addError(new SchemaValidationError(
218+
SchemaValidationErrorType.DefaultValueCircularRef, message));
219+
return;
220+
}
221+
222+
if (fullyExplored.contains(coordinate)) {
223+
return;
224+
}
225+
fullyExplored.add(coordinate);
226+
227+
// "Let nextVisitedFields be a new set containing field and everything from visitedFields.
228+
// Return InputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue, nextVisitedFields)."
229+
visitedFields.add(coordinate);
230+
231+
if (fieldDefaultValue.isLiteral() && fieldDefaultValue.getValue() instanceof Value) {
232+
inputObjectDefaultValueHasCycle(namedFieldType, (Value<?>) fieldDefaultValue.getValue(), errorCollector);
233+
} else if (fieldDefaultValue.isExternal() && fieldDefaultValue.getValue() != null) {
234+
inputObjectDefaultValueHasCycle(namedFieldType, fieldDefaultValue.getValue(), errorCollector);
235+
}
236+
237+
visitedFields.remove(coordinate);
238+
}
239+
}

src/main/java/graphql/schema/validation/SchemaValidationErrorType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ public enum SchemaValidationErrorType implements SchemaValidationErrorClassifica
2525
OneOfNotInhabited,
2626
RequiredInputFieldCannotBeDeprecated,
2727
RequiredFieldArgumentCannotBeDeprecated,
28-
RequiredDirectiveArgumentCannotBeDeprecated
28+
RequiredDirectiveArgumentCannotBeDeprecated,
29+
DefaultValueCircularRef
2930
}

src/main/java/graphql/schema/validation/SchemaValidator.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public class SchemaValidator {
1919

2020
public SchemaValidator() {
2121
rules.add(new NoUnbrokenInputCycles());
22+
rules.add(new NoDefaultValueCircularRefs());
2223
rules.add(new TypesImplementInterfaces());
2324
rules.add(new TypeAndFieldRule());
2425
rules.add(new DefaultValuesAreValid());
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package graphql
2+
3+
import graphql.schema.validation.InvalidSchemaException
4+
import spock.lang.Specification
5+
6+
/**
7+
* Tests for mutually recursive input types with default values.
8+
*
9+
* These schemas are now rejected at build time by NoDefaultValueCircularRefs,
10+
* which detects circular references in input object field default values.
11+
*
12+
* Previously, graphql-java accepted these schemas at build time but hit a
13+
* StackOverflowError at query execution time when the circular defaults were
14+
* expanded in ValuesResolverConversion.defaultValueToInternalValue.
15+
*/
16+
class CircularInputDefaultValuesTest extends Specification {
17+
18+
def "mutually recursive input types with default values - rejected at schema build time"() {
19+
when:
20+
TestUtil.schema('''
21+
type Query {
22+
test(arg: A): String
23+
}
24+
input A { b: B = {} }
25+
input B { a: A = {} }
26+
''')
27+
28+
then:
29+
def e = thrown(InvalidSchemaException)
30+
e.message.contains("Invalid circular reference")
31+
}
32+
33+
def "self-referential input type with default value - rejected at schema build time"() {
34+
when:
35+
TestUtil.schema('''
36+
type Query {
37+
test(arg: A): String
38+
}
39+
input A { a: A = {} }
40+
''')
41+
42+
then:
43+
def e = thrown(InvalidSchemaException)
44+
e.message.contains("Invalid circular reference")
45+
}
46+
47+
def "mutually recursive input types with default values - rejected before query execution"() {
48+
when:
49+
TestUtil.schema('''
50+
type Query {
51+
test(arg: A): String
52+
}
53+
input A { b: B = {} }
54+
input B { a: A = {} }
55+
''')
56+
57+
then:
58+
def e = thrown(InvalidSchemaException)
59+
e.message.contains("Invalid circular reference")
60+
}
61+
62+
def "self-referential input type with default value - rejected before query execution"() {
63+
when:
64+
TestUtil.schema('''
65+
type Query {
66+
test(arg: A): String
67+
}
68+
input A { a: A = {} }
69+
''')
70+
71+
then:
72+
def e = thrown(InvalidSchemaException)
73+
e.message.contains("Invalid circular reference")
74+
}
75+
76+
def "mutually recursive defaults via argument default - rejected at schema build time"() {
77+
when:
78+
TestUtil.schema('''
79+
type Query {
80+
test(arg: A = {}): String
81+
}
82+
input A { b: B = {} }
83+
input B { a: A = {} }
84+
''')
85+
86+
then:
87+
def e = thrown(InvalidSchemaException)
88+
e.message.contains("Invalid circular reference")
89+
}
90+
}

src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,20 +1446,20 @@ class SchemaDiffingTest extends Specification {
14461446
def schema1 = schema('''
14471447
input I {
14481448
name: String
1449-
field: I = {name: "default name"}
1449+
field: I = {name: "default name", field: null}
14501450
}
14511451
type Query {
14521452
foo(arg: I): String
1453-
}
1453+
}
14541454
''')
14551455
def schema2 = schema('''
14561456
input I {
14571457
name: String
1458-
field: [I] = [{name: "default name"}]
1458+
field: [I] = [{name: "default name", field: null}]
14591459
}
14601460
type Query {
14611461
foo(arg: I): String
1462-
}
1462+
}
14631463
''')
14641464

14651465
when:

0 commit comments

Comments
 (0)