Skip to content

Commit 4071971

Browse files
committed
Implement NoFragmentCycles validation (also tests)
1 parent 1f0c725 commit 4071971

File tree

2 files changed

+205
-2
lines changed

2 files changed

+205
-2
lines changed

graphql/core/validation/rules.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from ..error import GraphQLError
33
from ..type.definition import is_composite_type, is_input_type, is_leaf_type
44
from ..language import ast
5-
from ..language.visitor import Visitor
5+
from ..language.visitor import Visitor, visit
66
from ..language.printer import print_ast
77

88

@@ -179,7 +179,76 @@ class PossibleFragmentSpreads(ValidationRule):
179179

180180

181181
class NoFragmentCycles(ValidationRule):
182-
pass
182+
def __init__(self, context):
183+
super(NoFragmentCycles, self).__init__(context)
184+
185+
self.spreads_in_fragment = {}
186+
definitions = context.get_ast().definitions
187+
for node in definitions:
188+
if isinstance(node, ast.FragmentDefinition):
189+
self.spreads_in_fragment[node.name.value] = self.gather_spreads(node)
190+
191+
self.known_to_lead_to_cycle = set()
192+
193+
def enter_FragmentDefinition(self, node, *args):
194+
errors = []
195+
initial_name = node.name.value
196+
spread_path = []
197+
198+
# This will convert the ast.FragmentDefinition to something that we can add
199+
# to a set. Otherwise we get a `unhashable type: dict` error.
200+
# This makes it so that we can define a way to uniquely identify a FragmentDefinition
201+
# within a set.
202+
fragment_node_to_hashable = lambda fs: (fs.loc['start'], fs.loc['end'], fs.name.value)
203+
204+
def detect_cycle_recursive(fragment_name):
205+
spread_nodes = self.spreads_in_fragment[fragment_name]
206+
207+
for spread_node in spread_nodes:
208+
if fragment_node_to_hashable(spread_node) in self.known_to_lead_to_cycle:
209+
continue
210+
211+
if spread_node.name.value == initial_name:
212+
cycle_path = spread_path + [spread_node]
213+
self.known_to_lead_to_cycle |= set(map(fragment_node_to_hashable, cycle_path))
214+
215+
errors.append(GraphQLError(
216+
self.cycle_error_message(initial_name, [s.name.value for s in spread_path]),
217+
cycle_path
218+
))
219+
continue
220+
221+
if any(spread is spread_node for spread in spread_path):
222+
continue
223+
224+
spread_path.append(spread_node)
225+
detect_cycle_recursive(spread_node.name.value)
226+
spread_path.pop()
227+
228+
detect_cycle_recursive(initial_name)
229+
if errors:
230+
return errors
231+
232+
@staticmethod
233+
def cycle_error_message(fragment_name, spread_names):
234+
via = ' via {}'.format(', '.join(spread_names)) if spread_names else ''
235+
return 'Cannot spread fragment "{}" within itself{}.'.format(fragment_name, via)
236+
237+
@classmethod
238+
def gather_spreads(cls, node):
239+
visitor = cls.CollectFragmentSpreadNodesVisitor()
240+
visit(node, visitor)
241+
return visitor.collect_fragment_spread_nodes()
242+
243+
class CollectFragmentSpreadNodesVisitor(Visitor):
244+
def __init__(self):
245+
self.spread_nodes = []
246+
247+
def enter_FragmentSpread(self, node, *args):
248+
self.spread_nodes.append(node)
249+
250+
def collect_fragment_spread_nodes(self):
251+
return self.spread_nodes
183252

184253

185254
class NoUndefinedVariables(ValidationRule):
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from graphql.core.language.location import SourceLocation as L
2+
from graphql.core.validation.rules import NoFragmentCycles
3+
from utils import expect_passes_rule, expect_fails_rule
4+
5+
6+
def cycle_error_message(fragment_name, spread_names, *locations):
7+
return {
8+
'message': NoFragmentCycles.cycle_error_message(fragment_name, spread_names),
9+
'locations': list(locations)
10+
}
11+
12+
13+
def test_single_reference_is_valid():
14+
expect_passes_rule(NoFragmentCycles, '''
15+
fragment fragA on Dog { ...fragB }
16+
fragment fragB on Dog { name }
17+
''')
18+
19+
20+
def test_spreading_twice_is_not_circular():
21+
expect_passes_rule(NoFragmentCycles, '''
22+
fragment fragA on Dog { ...fragB, ...fragB }
23+
fragment fragB on Dog { name }
24+
''')
25+
26+
27+
def test_spreading_twice_indirectly_is_not_circular():
28+
expect_passes_rule(NoFragmentCycles, '''
29+
fragment fragA on Dog { ...fragB, ...fragC }
30+
fragment fragB on Dog { ...fragC }
31+
fragment fragC on Dog { name }
32+
''')
33+
34+
35+
def test_double_spread_within_abstract_types():
36+
expect_passes_rule(NoFragmentCycles, '''
37+
fragment nameFragment on Pet {
38+
... on Dog { name }
39+
... on Cat { name }
40+
}
41+
fragment spreadsInAnon on Pet {
42+
... on Dog { ...nameFragment }
43+
... on Cat { ...nameFragment }
44+
}
45+
''')
46+
47+
48+
def test_spreading_recursively_within_field_fails():
49+
expect_fails_rule(NoFragmentCycles, '''
50+
fragment fragA on Human { relatives { ...fragA } },
51+
''', [
52+
cycle_error_message('fragA', [], L(2, 43))
53+
])
54+
55+
56+
def test_no_spreading_itself_directly():
57+
expect_fails_rule(NoFragmentCycles, '''
58+
fragment fragA on Dog { ...fragA }
59+
''', [
60+
cycle_error_message('fragA', [], L(2, 29))
61+
])
62+
63+
64+
def test_no_spreading_itself_directly_within_inline_fragment():
65+
expect_fails_rule(NoFragmentCycles, '''
66+
fragment fragA on Pet {
67+
... on Dog {
68+
...fragA
69+
}
70+
}
71+
''', [
72+
cycle_error_message('fragA', [], L(4, 13))
73+
])
74+
75+
76+
def test_no_spreading_itself_indirectly():
77+
expect_fails_rule(NoFragmentCycles, '''
78+
fragment fragA on Dog { ...fragB }
79+
fragment fragB on Dog { ...fragA }
80+
''', [
81+
cycle_error_message('fragA', ['fragB'], L(2, 29), L(3, 29))
82+
])
83+
84+
85+
def test_no_spreading_itself_indirectly_reports_opposite_order():
86+
expect_fails_rule(NoFragmentCycles, '''
87+
fragment fragB on Dog { ...fragA }
88+
fragment fragA on Dog { ...fragB }
89+
''', [
90+
cycle_error_message('fragB', ['fragA'], L(2, 29), L(3, 29))
91+
])
92+
93+
94+
def test_no_spreading_itself_indirectly_within_inline_fragment():
95+
expect_fails_rule(NoFragmentCycles, '''
96+
fragment fragA on Pet {
97+
... on Dog {
98+
...fragB
99+
}
100+
}
101+
fragment fragB on Pet {
102+
... on Dog {
103+
...fragA
104+
}
105+
}
106+
''', [
107+
cycle_error_message('fragA', ['fragB'], L(4, 13), L(9, 13))
108+
])
109+
110+
111+
def test_no_spreading_itself_deeply():
112+
expect_fails_rule(NoFragmentCycles, '''
113+
fragment fragA on Dog { ...fragB }
114+
fragment fragB on Dog { ...fragC }
115+
fragment fragC on Dog { ...fragO }
116+
fragment fragX on Dog { ...fragY }
117+
fragment fragY on Dog { ...fragZ }
118+
fragment fragZ on Dog { ...fragO }
119+
fragment fragO on Dog { ...fragA, ...fragX }
120+
''', [
121+
cycle_error_message('fragA', ['fragB', 'fragC', 'fragO'], L(2, 29), L(3, 29), L(4, 29), L(8, 29)),
122+
cycle_error_message('fragX', ['fragY', 'fragZ', 'fragO'], L(5, 29), L(6, 29), L(7, 29), L(8, 39))
123+
])
124+
125+
126+
def test_no_spreading_itself_deeply_two_paths(): # -- new rule
127+
expect_fails_rule(NoFragmentCycles, '''
128+
fragment fragA on Dog { ...fragB, ...fragC }
129+
fragment fragB on Dog { ...fragA }
130+
fragment fragC on Dog { ...fragA }
131+
''', [
132+
cycle_error_message('fragA', ['fragB'], L(2, 29), L(3, 29)),
133+
cycle_error_message('fragA', ['fragC'], L(2, 39), L(4, 29))
134+
])

0 commit comments

Comments
 (0)