Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Simplify directive cycle detection
  • Loading branch information
andimarek committed Aug 23, 2026
commit 91589010604c7c38ead78c3b536277d2cde53102
104 changes: 31 additions & 73 deletions src/main/java/graphql/schema/idl/SchemaTypeDirectivesChecker.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
Expand Down Expand Up @@ -190,7 +189,6 @@ private static boolean isNoNullArgWithoutDefaultValue(InputValueDefinition defin
private void commonCheck(Collection<DirectiveDefinition> directiveDefinitions, List<GraphQLError> errors) {
List<DirectiveDefinition> directiveDefinitionsList = new ArrayList<>(directiveDefinitions);
Map<String, DirectiveDefinition> directiveDefinitionsByName = getByName(directiveDefinitionsList, DirectiveDefinition::getName, mergeFirst());
Map<String, Map<String, InputValueDefinition>> directiveReferencesByName = directiveReferencesByName(directiveDefinitionsByName);

directiveDefinitions.forEach(directiveDefinition -> {
assertTypeName(directiveDefinition, errors);
Expand All @@ -202,14 +200,7 @@ private void commonCheck(Collection<DirectiveDefinition> directiveDefinitions, L
}
});
});
checkIndirectDirectiveCycles(directiveDefinitionsByName, directiveReferencesByName, errors);
}

private static Map<String, Map<String, InputValueDefinition>> directiveReferencesByName(
Map<String, DirectiveDefinition> directiveDefinitionsByName) {
Map<String, Map<String, InputValueDefinition>> result = new LinkedHashMap<>();
directiveDefinitionsByName.forEach((name, directiveDefinition) -> result.put(name, directiveReferences(directiveDefinition)));
return result;
checkIndirectDirectiveCycles(directiveDefinitionsByName, errors);
}

private static Map<String, InputValueDefinition> directiveReferences(DirectiveDefinition directiveDefinition) {
Expand All @@ -233,82 +224,49 @@ private static void recordDirectiveReferences(DirectiveDefinition directiveDefin

private static void checkIndirectDirectiveCycles(
Map<String, DirectiveDefinition> directiveDefinitionsByName,
Map<String, Map<String, InputValueDefinition>> directiveReferencesByName,
List<GraphQLError> errors) {
Set<String> checked = new LinkedHashSet<>();
Set<String> visiting = new LinkedHashSet<>();
List<String> path = new ArrayList<>();
for (String directiveName : directiveDefinitionsByName.keySet()) {
checkIndirectDirectiveCycles(directiveName, directiveDefinitionsByName, directiveReferencesByName, checked, visiting, path, errors);
// Insertion order records the active recursion path for the error message.
LinkedHashSet<String> currentPath = new LinkedHashSet<>();
for (DirectiveDefinition directiveDefinition : directiveDefinitionsByName.values()) {
checkDirectiveReferencesForCycles(directiveDefinition, directiveDefinitionsByName, checked, currentPath, errors);
}
}

private static void checkIndirectDirectiveCycles(String directiveName,
Map<String, DirectiveDefinition> directiveDefinitionsByName,
Map<String, Map<String, InputValueDefinition>> directiveReferencesByName,
Set<String> checked,
Set<String> visiting,
List<String> path,
List<GraphQLError> errors) {
private static void checkDirectiveReferencesForCycles(DirectiveDefinition directiveDefinition,
Map<String, DirectiveDefinition> directiveDefinitionsByName,
Set<String> checked,
LinkedHashSet<String> currentPath,
List<GraphQLError> errors) {
String directiveName = directiveDefinition.getName();
if (checked.contains(directiveName)) {
return;
}

visiting.add(directiveName);
path.add(directiveName);
checkIndirectDirectiveCycleReferences(directiveName, directiveDefinitionsByName, directiveReferencesByName, checked, visiting, path, errors);
path.remove(path.size() - 1);
visiting.remove(directiveName);
checked.add(directiveName);
}

private static void checkIndirectDirectiveCycleReferences(String directiveName,
Map<String, DirectiveDefinition> directiveDefinitionsByName,
Map<String, Map<String, InputValueDefinition>> directiveReferencesByName,
Set<String> checked,
Set<String> visiting,
List<String> path,
List<GraphQLError> errors) {
Map<String, InputValueDefinition> references = directiveReferencesByName.getOrDefault(directiveName, Collections.emptyMap());
for (Map.Entry<String, InputValueDefinition> entry : references.entrySet()) {
checkIndirectDirectiveCycleReference(entry.getKey(), entry.getValue(), directiveDefinitionsByName, directiveReferencesByName, checked, visiting, path, errors);
}
}

private static void checkIndirectDirectiveCycleReference(String referencedDirectiveName,
InputValueDefinition inputValueDefinition,
Map<String, DirectiveDefinition> directiveDefinitionsByName,
Map<String, Map<String, InputValueDefinition>> directiveReferencesByName,
Set<String> checked,
Set<String> visiting,
List<String> path,
List<GraphQLError> errors) {
if (visiting.contains(referencedDirectiveName)) {
addIndirectDirectiveCycleError(referencedDirectiveName, inputValueDefinition, directiveDefinitionsByName, path, errors);
return;
}
if (!checked.contains(referencedDirectiveName)) {
checkIndirectDirectiveCycles(referencedDirectiveName, directiveDefinitionsByName, directiveReferencesByName, checked, visiting, path, errors);
currentPath.add(directiveName);
for (Map.Entry<String, InputValueDefinition> reference : directiveReferences(directiveDefinition).entrySet()) {
String referencedDirectiveName = reference.getKey();
if (currentPath.contains(referencedDirectiveName)) {
DirectiveDefinition repeatedDirective = assertNotNull(directiveDefinitionsByName.get(referencedDirectiveName));
String cyclePath = directiveCyclePath(referencedDirectiveName, currentPath);
errors.add(new DirectiveIllegalReferenceError(repeatedDirective, reference.getValue(), cyclePath));
continue;
}
DirectiveDefinition referencedDirective = directiveDefinitionsByName.get(referencedDirectiveName);
if (referencedDirective != null) {
checkDirectiveReferencesForCycles(referencedDirective, directiveDefinitionsByName, checked, currentPath, errors);
}
}
currentPath.remove(directiveName);
checked.add(directiveName);
}

private static void addIndirectDirectiveCycleError(String repeatedDirectiveName,
InputValueDefinition inputValueDefinition,
Map<String, DirectiveDefinition> directiveDefinitionsByName,
List<String> path,
List<GraphQLError> errors) {
List<String> cyclePath = directiveCyclePath(repeatedDirectiveName, path);
String cyclePathString = String.join(" -> ", cyclePath);

DirectiveDefinition directiveDefinition = assertNotNull(directiveDefinitionsByName.get(repeatedDirectiveName));
errors.add(new DirectiveIllegalReferenceError(directiveDefinition, inputValueDefinition, cyclePathString));
}

private static List<String> directiveCyclePath(String repeatedDirectiveName, List<String> path) {
int cycleStart = path.indexOf(repeatedDirectiveName);
List<String> cyclePath = new ArrayList<>(path.subList(cycleStart, path.size()));
private static String directiveCyclePath(String repeatedDirectiveName, LinkedHashSet<String> currentPath) {
List<String> pathList = new ArrayList<>(currentPath);
int cycleStart = pathList.indexOf(repeatedDirectiveName);
List<String> cyclePath = new ArrayList<>(pathList.subList(cycleStart, pathList.size()));
cyclePath.add(repeatedDirectiveName);
return cyclePath;
return String.join(" -> ", cyclePath);
}

private static void assertTypeName(NamedNode<?> node, List<GraphQLError> errors) {
Expand Down
39 changes: 10 additions & 29 deletions src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2271,9 +2271,16 @@ class SchemaGeneratorTest extends Specification {
schema != null
}

def "#4201 indirect cyclical directive definitions are rejected without stack overflow - #name"() {
def "#4201 indirect cyclical directive definitions are rejected without stack overflow"() {
given:
def registry = new SchemaParser().parse(sdl)
def registry = new SchemaParser().parse('''
directive @foo(x: Int @bar(y: 1)) on FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @bar(y: Int @foo(x: 2)) on FIELD_DEFINITION | ARGUMENT_DEFINITION

type Query {
field: String @foo(x: 10) @bar(y: 20)
}
''')

when:
UnExecutableSchemaGenerator.makeUnExecutableSchema(registry)
Expand All @@ -2282,33 +2289,7 @@ class SchemaGeneratorTest extends Specification {
def e = thrown(SchemaProblem)
e.errors.size() == 1
e.errors.get(0) instanceof DirectiveIllegalReferenceError
e.errors.get(0).getMessage().contains(cycleMessage)

where:
name << ["two directives", "three directives"]
sdl << [
'''
directive @foo(x: Int @bar(y: 1)) on FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @bar(y: Int @foo(x: 2)) on FIELD_DEFINITION | ARGUMENT_DEFINITION

type Query {
field: String @foo(x: 10) @bar(y: 20)
}
''',
'''
directive @dirA(x: Int @dirB(y: 1)) on FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @dirB(y: Int @dirC(z: 2)) on FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @dirC(z: Int @dirA(x: 3)) on FIELD_DEFINITION | ARGUMENT_DEFINITION

type Query {
field: String @dirA(x: 10) @dirB(y: 20) @dirC(z: 30)
}
'''
]
cycleMessage << [
"'foo' must not reference itself via directive cycle 'foo -> bar -> foo'",
"'dirA' must not reference itself via directive cycle 'dirA -> dirB -> dirC -> dirA'"
]
e.errors.get(0).getMessage().contains("'foo' must not reference itself via directive cycle 'foo -> bar -> foo'")
}

def "code registry default data fetcher is respected"() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,8 @@ class SchemaTypeDirectivesCheckerTest extends Specification {
errors.get(0).getMessage() == "'invalidExample' must not reference itself on 'arg''[@2:39]'"
}

def "directive must not indirectly reference itself"() {
def "directive must not indirectly reference itself - #name"() {
given:
def spec = '''
directive @foo(arg: String @bar) on ARGUMENT_DEFINITION
directive @bar(arg: String @foo) on ARGUMENT_DEFINITION

type Query {
f1 : String
}
'''
def registry = parse(spec)
def errors = []

Expand All @@ -251,30 +243,53 @@ class SchemaTypeDirectivesCheckerTest extends Specification {
then:
errors.size() == 1
errors.get(0) instanceof DirectiveIllegalReferenceError
errors.get(0).getMessage().contains("'foo' must not reference itself via directive cycle 'foo -> bar -> foo'")
errors.get(0).getMessage().contains(cycleMessage)

where:
name << ["two directives", "three directives"]
spec << [
'''
directive @foo(arg: String @bar) on ARGUMENT_DEFINITION
directive @bar(arg: String @foo) on ARGUMENT_DEFINITION

type Query {
f1 : String
}
''',
'''
directive @dirA(x: Int @dirB(y: 1)) on ARGUMENT_DEFINITION
directive @dirB(y: Int @dirC(z: 2)) on ARGUMENT_DEFINITION
directive @dirC(z: Int @dirA(x: 3)) on ARGUMENT_DEFINITION

type Query {
f1 : String
}
'''
]
cycleMessage << [
"'foo' must not reference itself via directive cycle 'foo -> bar -> foo'",
"'dirA' must not reference itself via directive cycle 'dirA -> dirB -> dirC -> dirA'"
]
}

def "directive must not indirectly reference itself through a longer cycle"() {
def "acyclic directive references are allowed"() {
given:
def spec = '''
directive @dirA(x: Int @dirB(y: 1)) on ARGUMENT_DEFINITION
directive @dirB(y: Int @dirC(z: 2)) on ARGUMENT_DEFINITION
directive @dirC(z: Int @dirA(x: 3)) on ARGUMENT_DEFINITION
def registry = parse('''
directive @foo(arg: String @bar) on ARGUMENT_DEFINITION
directive @bar(arg: String @baz) on ARGUMENT_DEFINITION
directive @baz on ARGUMENT_DEFINITION

type Query {
f1 : String
}
'''
def registry = parse(spec)
''')
def errors = []

when:
new SchemaTypeDirectivesChecker(registry, RuntimeWiring.newRuntimeWiring().build()).checkTypeDirectives(errors)

then:
errors.size() == 1
errors.get(0) instanceof DirectiveIllegalReferenceError
errors.get(0).getMessage().contains("'dirA' must not reference itself via directive cycle 'dirA -> dirB -> dirC -> dirA'")
errors.isEmpty()
}

def "directive must not begin with '__'"() {
Expand Down
Loading