|
| 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 | +} |
0 commit comments