Skip to content

Commit 062a975

Browse files
feature: Adds NoFragmentCyclesRule
1 parent 0049176 commit 062a975

File tree

3 files changed

+392
-1
lines changed

3 files changed

+392
-1
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
2+
/**
3+
* No fragment cycles
4+
*
5+
* The graph of fragment spreads must not form any cycles including spreading itself.
6+
* Otherwise an operation could infinitely spread or infinitely execute on cycles in the underlying data.
7+
*
8+
* See https://spec.graphql.org/draft/#sec-Fragment-spreads-must-not-form-cycles
9+
*/
10+
func NoFragmentCyclesRule(context: ValidationContext) -> Visitor {
11+
// Tracks already visited fragments to maintain O(N) and to ensure that cycles
12+
// are not redundantly reported.
13+
var visitedFrags = Set<String>()
14+
15+
// Array of AST nodes used to produce meaningful errors
16+
var spreadPath = [FragmentSpread]()
17+
18+
// Position in the spread path
19+
var spreadPathIndexByName = [String: Int]()
20+
21+
// This does a straight-forward DFS to find cycles.
22+
// It does not terminate when a cycle was found but continues to explore
23+
// the graph to find all possible cycles.
24+
func detectCycleRecursive(fragment: FragmentDefinition) {
25+
if visitedFrags.contains(fragment.name.value) {
26+
return
27+
}
28+
29+
let fragmentName = fragment.name.value
30+
visitedFrags.insert(fragmentName)
31+
32+
let spreadNodes = context.getFragmentSpreads(node: fragment.selectionSet)
33+
if spreadNodes.count == 0 {
34+
return
35+
}
36+
37+
spreadPathIndexByName[fragmentName] = spreadPath.count
38+
39+
for spreadNode in spreadNodes {
40+
let spreadName = spreadNode.name.value
41+
let cycleIndex = spreadPathIndexByName[spreadName]
42+
43+
spreadPath.append(spreadNode)
44+
if let cycleIndex = cycleIndex {
45+
let cyclePath = Array(spreadPath[cycleIndex ..< spreadPath.count])
46+
let viaPath = cyclePath[0 ..< max(cyclePath.count - 1, 0)]
47+
.map { "\"\($0.name.value)\"" }.joined(separator: ", ")
48+
49+
context.report(
50+
error: GraphQLError(
51+
message: "Cannot spread fragment \"\(spreadName)\" within itself" +
52+
(viaPath != "" ? " via \(viaPath)." : "."),
53+
nodes: cyclePath
54+
)
55+
)
56+
} else {
57+
if let spreadFragment = context.getFragment(name: spreadName) {
58+
detectCycleRecursive(fragment: spreadFragment)
59+
}
60+
}
61+
spreadPath.removeLast()
62+
}
63+
64+
spreadPathIndexByName[fragmentName] = nil
65+
}
66+
67+
return Visitor(
68+
enter: { node, _, _, _, _ in
69+
if node is OperationDefinition {
70+
return .skip
71+
}
72+
if let fragmentDefinition = node as? FragmentDefinition {
73+
detectCycleRecursive(fragment: fragmentDefinition)
74+
return .skip
75+
}
76+
return .continue
77+
}
78+
)
79+
}

Sources/GraphQL/Validation/SpecifiedRules.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public let specifiedRules: [(ValidationContext) -> Visitor] = [
1515
KnownFragmentNamesRule,
1616
NoUnusedFragmentsRule,
1717
PossibleFragmentSpreadsRule,
18-
// NoFragmentCyclesRule,
18+
NoFragmentCyclesRule,
1919
// UniqueVariableNamesRule,
2020
// NoUndefinedVariablesRule,
2121
NoUnusedVariablesRule,

0 commit comments

Comments
 (0)