Skip to content

Commit fb82185

Browse files
committed
Catch OverlappingFieldsCanBeMergedRule violations with nested fragments
Replicates graphql/graphql-js@45e28a5b
1 parent d0d818e commit fb82185

2 files changed

Lines changed: 129 additions & 12 deletions

File tree

src/graphql/validation/rules/overlapping_fields_can_be_merged.py

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ class OverlappingFieldsCanBeMergedRule(ValidationRule):
5656

5757
def __init__(self, context: ValidationContext):
5858
super().__init__(context)
59-
# A memoization for when two fragments are compared "between" each other for
60-
# conflicts. Two fragments may be compared many times, so memoizing this can
61-
# dramatically improve the performance of this validator.
59+
# A memoization for when fields and a fragment or two fragments are compared
60+
# "between" each other for conflicts. Comparisons may be made many times, so
61+
# memoizing this can dramatically improve the performance of this validator.
62+
self.compared_fields_and_fragment_pairs = OrderedPairSet()
6263
self.compared_fragment_pairs = PairSet()
6364

6465
# A cache for the "field map" and list of fragment names found in any given
@@ -70,6 +71,7 @@ def enter_selection_set(self, selection_set: SelectionSetNode, *_args: Any) -> N
7071
conflicts = find_conflicts_within_selection_set(
7172
self.context,
7273
self.cached_fields_and_fragment_names,
74+
self.compared_fields_and_fragment_pairs,
7375
self.compared_fragment_pairs,
7476
self.context.get_parent_type(),
7577
selection_set,
@@ -156,6 +158,7 @@ def enter_selection_set(self, selection_set: SelectionSetNode, *_args: Any) -> N
156158
def find_conflicts_within_selection_set(
157159
context: ValidationContext,
158160
cached_fields_and_fragment_names: Dict,
161+
compared_fields_and_fragment_pairs: "OrderedPairSet",
159162
compared_fragment_pairs: "PairSet",
160163
parent_type: Optional[GraphQLNamedType],
161164
selection_set: SelectionSetNode,
@@ -179,6 +182,7 @@ def find_conflicts_within_selection_set(
179182
context,
180183
conflicts,
181184
cached_fields_and_fragment_names,
185+
compared_fields_and_fragment_pairs,
182186
compared_fragment_pairs,
183187
field_map,
184188
)
@@ -191,6 +195,7 @@ def find_conflicts_within_selection_set(
191195
context,
192196
conflicts,
193197
cached_fields_and_fragment_names,
198+
compared_fields_and_fragment_pairs,
194199
compared_fragment_pairs,
195200
False,
196201
field_map,
@@ -205,6 +210,7 @@ def find_conflicts_within_selection_set(
205210
context,
206211
conflicts,
207212
cached_fields_and_fragment_names,
213+
compared_fields_and_fragment_pairs,
208214
compared_fragment_pairs,
209215
False,
210216
fragment_name,
@@ -218,6 +224,7 @@ def collect_conflicts_between_fields_and_fragment(
218224
context: ValidationContext,
219225
conflicts: List[Conflict],
220226
cached_fields_and_fragment_names: Dict,
227+
compared_fields_and_fragment_pairs: "OrderedPairSet",
221228
compared_fragment_pairs: "PairSet",
222229
are_mutually_exclusive: bool,
223230
field_map: NodeAndDefCollection,
@@ -228,6 +235,16 @@ def collect_conflicts_between_fields_and_fragment(
228235
Collect all conflicts found between a set of fields and a fragment reference
229236
including via spreading in any nested fragments.
230237
"""
238+
# Memoize so the fields and fragments are not compared for conflicts more
239+
# than once.
240+
if compared_fields_and_fragment_pairs.has(
241+
field_map, fragment_name, are_mutually_exclusive
242+
):
243+
return
244+
compared_fields_and_fragment_pairs.add(
245+
field_map, fragment_name, are_mutually_exclusive
246+
)
247+
231248
fragment = context.get_fragment(fragment_name)
232249
if not fragment:
233250
return None
@@ -246,6 +263,7 @@ def collect_conflicts_between_fields_and_fragment(
246263
context,
247264
conflicts,
248265
cached_fields_and_fragment_names,
266+
compared_fields_and_fragment_pairs,
249267
compared_fragment_pairs,
250268
are_mutually_exclusive,
251269
field_map,
@@ -255,19 +273,11 @@ def collect_conflicts_between_fields_and_fragment(
255273
# (E) Then collect any conflicts between the provided collection of fields and any
256274
# fragment names found in the given fragment.
257275
for referenced_fragment_name in referenced_fragment_names:
258-
# Memoize so two fragments are not compared for conflicts more than once.
259-
if compared_fragment_pairs.has(
260-
referenced_fragment_name, fragment_name, are_mutually_exclusive
261-
):
262-
continue
263-
compared_fragment_pairs.add(
264-
referenced_fragment_name, fragment_name, are_mutually_exclusive
265-
)
266-
267276
collect_conflicts_between_fields_and_fragment(
268277
context,
269278
conflicts,
270279
cached_fields_and_fragment_names,
280+
compared_fields_and_fragment_pairs,
271281
compared_fragment_pairs,
272282
are_mutually_exclusive,
273283
field_map,
@@ -279,6 +289,7 @@ def collect_conflicts_between_fragments(
279289
context: ValidationContext,
280290
conflicts: List[Conflict],
281291
cached_fields_and_fragment_names: Dict,
292+
compared_fields_and_fragment_pairs: "OrderedPairSet",
282293
compared_fragment_pairs: "PairSet",
283294
are_mutually_exclusive: bool,
284295
fragment_name1: str,
@@ -319,6 +330,7 @@ def collect_conflicts_between_fragments(
319330
context,
320331
conflicts,
321332
cached_fields_and_fragment_names,
333+
compared_fields_and_fragment_pairs,
322334
compared_fragment_pairs,
323335
are_mutually_exclusive,
324336
field_map1,
@@ -332,6 +344,7 @@ def collect_conflicts_between_fragments(
332344
context,
333345
conflicts,
334346
cached_fields_and_fragment_names,
347+
compared_fields_and_fragment_pairs,
335348
compared_fragment_pairs,
336349
are_mutually_exclusive,
337350
fragment_name1,
@@ -345,6 +358,7 @@ def collect_conflicts_between_fragments(
345358
context,
346359
conflicts,
347360
cached_fields_and_fragment_names,
361+
compared_fields_and_fragment_pairs,
348362
compared_fragment_pairs,
349363
are_mutually_exclusive,
350364
referenced_fragment_name1,
@@ -355,6 +369,7 @@ def collect_conflicts_between_fragments(
355369
def find_conflicts_between_sub_selection_sets(
356370
context: ValidationContext,
357371
cached_fields_and_fragment_names: Dict,
372+
compared_fields_and_fragment_pairs: "OrderedPairSet",
358373
compared_fragment_pairs: "PairSet",
359374
are_mutually_exclusive: bool,
360375
parent_type1: Optional[GraphQLNamedType],
@@ -382,6 +397,7 @@ def find_conflicts_between_sub_selection_sets(
382397
context,
383398
conflicts,
384399
cached_fields_and_fragment_names,
400+
compared_fields_and_fragment_pairs,
385401
compared_fragment_pairs,
386402
are_mutually_exclusive,
387403
field_map1,
@@ -396,6 +412,7 @@ def find_conflicts_between_sub_selection_sets(
396412
context,
397413
conflicts,
398414
cached_fields_and_fragment_names,
415+
compared_fields_and_fragment_pairs,
399416
compared_fragment_pairs,
400417
are_mutually_exclusive,
401418
field_map1,
@@ -410,6 +427,7 @@ def find_conflicts_between_sub_selection_sets(
410427
context,
411428
conflicts,
412429
cached_fields_and_fragment_names,
430+
compared_fields_and_fragment_pairs,
413431
compared_fragment_pairs,
414432
are_mutually_exclusive,
415433
field_map2,
@@ -425,6 +443,7 @@ def find_conflicts_between_sub_selection_sets(
425443
context,
426444
conflicts,
427445
cached_fields_and_fragment_names,
446+
compared_fields_and_fragment_pairs,
428447
compared_fragment_pairs,
429448
are_mutually_exclusive,
430449
fragment_name1,
@@ -438,6 +457,7 @@ def collect_conflicts_within(
438457
context: ValidationContext,
439458
conflicts: List[Conflict],
440459
cached_fields_and_fragment_names: Dict,
460+
compared_fields_and_fragment_pairs: "OrderedPairSet",
441461
compared_fragment_pairs: "PairSet",
442462
field_map: NodeAndDefCollection,
443463
) -> None:
@@ -456,6 +476,7 @@ def collect_conflicts_within(
456476
conflict = find_conflict(
457477
context,
458478
cached_fields_and_fragment_names,
479+
compared_fields_and_fragment_pairs,
459480
compared_fragment_pairs,
460481
# within one collection is never mutually exclusive
461482
False,
@@ -471,6 +492,7 @@ def collect_conflicts_between(
471492
context: ValidationContext,
472493
conflicts: List[Conflict],
473494
cached_fields_and_fragment_names: Dict,
495+
compared_fields_and_fragment_pairs: "OrderedPairSet",
474496
compared_fragment_pairs: "PairSet",
475497
parent_fields_are_mutually_exclusive: bool,
476498
field_map1: NodeAndDefCollection,
@@ -496,6 +518,7 @@ def collect_conflicts_between(
496518
conflict = find_conflict(
497519
context,
498520
cached_fields_and_fragment_names,
521+
compared_fields_and_fragment_pairs,
499522
compared_fragment_pairs,
500523
parent_fields_are_mutually_exclusive,
501524
response_name,
@@ -509,6 +532,7 @@ def collect_conflicts_between(
509532
def find_conflict(
510533
context: ValidationContext,
511534
cached_fields_and_fragment_names: Dict,
535+
compared_fields_and_fragment_pairs: "OrderedPairSet",
512536
compared_fragment_pairs: "PairSet",
513537
parent_fields_are_mutually_exclusive: bool,
514538
response_name: str,
@@ -570,6 +594,7 @@ def find_conflict(
570594
conflicts = find_conflicts_between_sub_selection_sets(
571595
context,
572596
cached_fields_and_fragment_names,
597+
compared_fields_and_fragment_pairs,
573598
compared_fragment_pairs,
574599
are_mutually_exclusive,
575600
get_named_type(type1),
@@ -745,6 +770,43 @@ def subfield_conflicts(
745770
return None # no conflict
746771

747772

773+
class OrderedPairSet:
774+
"""Ordered pair set
775+
776+
A way to keep track of pairs of things where the ordering of the pair matters.
777+
778+
Provides a third argument for has/add to allow flagging the pair as weakly or
779+
strongly present within the collection.
780+
781+
The first element is matched by object identity (its ``id``), since field maps
782+
are unhashable mappings that are kept alive for the duration of the validation.
783+
"""
784+
785+
__slots__ = ("_data",)
786+
787+
_data: Dict[int, Dict[str, bool]]
788+
789+
def __init__(self) -> None:
790+
self._data = {}
791+
792+
def has(self, a: NodeAndDefCollection, b: str, weakly_present: bool) -> bool:
793+
map_ = self._data.get(id(a))
794+
if map_ is None:
795+
return False
796+
result = map_.get(b)
797+
if result is None:
798+
return False
799+
800+
return True if weakly_present else weakly_present == result
801+
802+
def add(self, a: NodeAndDefCollection, b: str, weakly_present: bool) -> None:
803+
map_ = self._data.get(id(a))
804+
if map_ is None:
805+
self._data[id(a)] = {b: weakly_present}
806+
else:
807+
map_[b] = weakly_present
808+
809+
748810
class PairSet:
749811
"""Pair set
750812

tests/validation/test_overlapping_fields_can_be_merged.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,35 @@ def reports_deep_conflict_in_nested_fragments():
537537
],
538538
)
539539

540+
def reports_deep_conflict_after_nested_fragments():
541+
assert_errors(
542+
"""
543+
fragment F on T {
544+
...G
545+
}
546+
fragment G on T {
547+
...H
548+
}
549+
fragment H on T {
550+
x: a
551+
}
552+
{
553+
x: b
554+
...F
555+
}
556+
""",
557+
[
558+
{
559+
"message": "Fields 'x' conflict"
560+
" because 'b' and 'a' are different fields."
561+
" Use different aliases on the fields"
562+
" to fetch both if this was intentional.",
563+
"locations": [(12, 15), (9, 15)],
564+
"path": None,
565+
}
566+
],
567+
)
568+
540569
def ignores_unknown_fragments():
541570
assert_valid("""
542571
{
@@ -1138,3 +1167,29 @@ def finds_invalid_case_even_with_immediately_recursive_fragment():
11381167
}
11391168
],
11401169
)
1170+
1171+
def does_not_infinite_loop_on_recursive_fragments_separated_by_fields():
1172+
assert_valid("""
1173+
{
1174+
...fragA
1175+
...fragB
1176+
}
1177+
1178+
fragment fragA on T {
1179+
x {
1180+
...fragA
1181+
x {
1182+
...fragA
1183+
}
1184+
}
1185+
}
1186+
1187+
fragment fragB on T {
1188+
x {
1189+
...fragB
1190+
x {
1191+
...fragB
1192+
}
1193+
}
1194+
}
1195+
""")

0 commit comments

Comments
 (0)