Skip to content

Commit 68e3c52

Browse files
committed
Catch OverlappingFieldsCanBeMergedRule violations with nested fragments
Replicates graphql/graphql-js@e52ed9a6
1 parent ac0da9e commit 68e3c52

2 files changed

Lines changed: 131 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
@@ -58,9 +58,10 @@ class OverlappingFieldsCanBeMergedRule(ValidationRule):
5858

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

6667
# A cache for the "field map" and list of fragment names found in any given
@@ -72,6 +73,7 @@ def enter_selection_set(self, selection_set: SelectionSetNode, *_args: Any) -> N
7273
conflicts = find_conflicts_within_selection_set(
7374
self.context,
7475
self.cached_fields_and_fragment_names,
76+
self.compared_fields_and_fragment_pairs,
7577
self.compared_fragment_pairs,
7678
self.context.get_parent_type(),
7779
selection_set,
@@ -155,6 +157,7 @@ def enter_selection_set(self, selection_set: SelectionSetNode, *_args: Any) -> N
155157
def find_conflicts_within_selection_set(
156158
context: ValidationContext,
157159
cached_fields_and_fragment_names: dict,
160+
compared_fields_and_fragment_pairs: OrderedPairSet,
158161
compared_fragment_pairs: PairSet,
159162
parent_type: GraphQLNamedType | None,
160163
selection_set: SelectionSetNode,
@@ -178,6 +181,7 @@ def find_conflicts_within_selection_set(
178181
context,
179182
conflicts,
180183
cached_fields_and_fragment_names,
184+
compared_fields_and_fragment_pairs,
181185
compared_fragment_pairs,
182186
field_map,
183187
)
@@ -190,6 +194,7 @@ def find_conflicts_within_selection_set(
190194
context,
191195
conflicts,
192196
cached_fields_and_fragment_names,
197+
compared_fields_and_fragment_pairs,
193198
compared_fragment_pairs,
194199
False,
195200
field_map,
@@ -204,6 +209,7 @@ def find_conflicts_within_selection_set(
204209
context,
205210
conflicts,
206211
cached_fields_and_fragment_names,
212+
compared_fields_and_fragment_pairs,
207213
compared_fragment_pairs,
208214
False,
209215
fragment_name,
@@ -217,6 +223,7 @@ def collect_conflicts_between_fields_and_fragment(
217223
context: ValidationContext,
218224
conflicts: list[Conflict],
219225
cached_fields_and_fragment_names: dict,
226+
compared_fields_and_fragment_pairs: OrderedPairSet,
220227
compared_fragment_pairs: PairSet,
221228
are_mutually_exclusive: bool,
222229
field_map: NodeAndDefCollection,
@@ -227,6 +234,16 @@ def collect_conflicts_between_fields_and_fragment(
227234
Collect all conflicts found between a set of fields and a fragment reference
228235
including via spreading in any nested fragments.
229236
"""
237+
# Memoize so the fields and fragments are not compared for conflicts more
238+
# than once.
239+
if compared_fields_and_fragment_pairs.has(
240+
field_map, fragment_name, are_mutually_exclusive
241+
):
242+
return
243+
compared_fields_and_fragment_pairs.add(
244+
field_map, fragment_name, are_mutually_exclusive
245+
)
246+
230247
fragment = context.get_fragment(fragment_name)
231248
if not fragment:
232249
return
@@ -245,6 +262,7 @@ def collect_conflicts_between_fields_and_fragment(
245262
context,
246263
conflicts,
247264
cached_fields_and_fragment_names,
265+
compared_fields_and_fragment_pairs,
248266
compared_fragment_pairs,
249267
are_mutually_exclusive,
250268
field_map,
@@ -254,19 +272,11 @@ def collect_conflicts_between_fields_and_fragment(
254272
# (E) Then collect any conflicts between the provided collection of fields and any
255273
# fragment names found in the given fragment.
256274
for referenced_fragment_name in referenced_fragment_names:
257-
# Memoize so two fragments are not compared for conflicts more than once.
258-
if compared_fragment_pairs.has(
259-
referenced_fragment_name, fragment_name, are_mutually_exclusive
260-
):
261-
continue
262-
compared_fragment_pairs.add(
263-
referenced_fragment_name, fragment_name, are_mutually_exclusive
264-
)
265-
266275
collect_conflicts_between_fields_and_fragment(
267276
context,
268277
conflicts,
269278
cached_fields_and_fragment_names,
279+
compared_fields_and_fragment_pairs,
270280
compared_fragment_pairs,
271281
are_mutually_exclusive,
272282
field_map,
@@ -278,6 +288,7 @@ def collect_conflicts_between_fragments(
278288
context: ValidationContext,
279289
conflicts: list[Conflict],
280290
cached_fields_and_fragment_names: dict,
291+
compared_fields_and_fragment_pairs: OrderedPairSet,
281292
compared_fragment_pairs: PairSet,
282293
are_mutually_exclusive: bool,
283294
fragment_name1: str,
@@ -318,6 +329,7 @@ def collect_conflicts_between_fragments(
318329
context,
319330
conflicts,
320331
cached_fields_and_fragment_names,
332+
compared_fields_and_fragment_pairs,
321333
compared_fragment_pairs,
322334
are_mutually_exclusive,
323335
field_map1,
@@ -331,6 +343,7 @@ def collect_conflicts_between_fragments(
331343
context,
332344
conflicts,
333345
cached_fields_and_fragment_names,
346+
compared_fields_and_fragment_pairs,
334347
compared_fragment_pairs,
335348
are_mutually_exclusive,
336349
fragment_name1,
@@ -344,6 +357,7 @@ def collect_conflicts_between_fragments(
344357
context,
345358
conflicts,
346359
cached_fields_and_fragment_names,
360+
compared_fields_and_fragment_pairs,
347361
compared_fragment_pairs,
348362
are_mutually_exclusive,
349363
referenced_fragment_name1,
@@ -354,6 +368,7 @@ def collect_conflicts_between_fragments(
354368
def find_conflicts_between_sub_selection_sets(
355369
context: ValidationContext,
356370
cached_fields_and_fragment_names: dict,
371+
compared_fields_and_fragment_pairs: OrderedPairSet,
357372
compared_fragment_pairs: PairSet,
358373
are_mutually_exclusive: bool,
359374
parent_type1: GraphQLNamedType | None,
@@ -381,6 +396,7 @@ def find_conflicts_between_sub_selection_sets(
381396
context,
382397
conflicts,
383398
cached_fields_and_fragment_names,
399+
compared_fields_and_fragment_pairs,
384400
compared_fragment_pairs,
385401
are_mutually_exclusive,
386402
field_map1,
@@ -395,6 +411,7 @@ def find_conflicts_between_sub_selection_sets(
395411
context,
396412
conflicts,
397413
cached_fields_and_fragment_names,
414+
compared_fields_and_fragment_pairs,
398415
compared_fragment_pairs,
399416
are_mutually_exclusive,
400417
field_map1,
@@ -409,6 +426,7 @@ def find_conflicts_between_sub_selection_sets(
409426
context,
410427
conflicts,
411428
cached_fields_and_fragment_names,
429+
compared_fields_and_fragment_pairs,
412430
compared_fragment_pairs,
413431
are_mutually_exclusive,
414432
field_map2,
@@ -424,6 +442,7 @@ def find_conflicts_between_sub_selection_sets(
424442
context,
425443
conflicts,
426444
cached_fields_and_fragment_names,
445+
compared_fields_and_fragment_pairs,
427446
compared_fragment_pairs,
428447
are_mutually_exclusive,
429448
fragment_name1,
@@ -437,6 +456,7 @@ def collect_conflicts_within(
437456
context: ValidationContext,
438457
conflicts: list[Conflict],
439458
cached_fields_and_fragment_names: dict,
459+
compared_fields_and_fragment_pairs: OrderedPairSet,
440460
compared_fragment_pairs: PairSet,
441461
field_map: NodeAndDefCollection,
442462
) -> None:
@@ -455,6 +475,7 @@ def collect_conflicts_within(
455475
conflict = find_conflict(
456476
context,
457477
cached_fields_and_fragment_names,
478+
compared_fields_and_fragment_pairs,
458479
compared_fragment_pairs,
459480
# within one collection is never mutually exclusive
460481
False,
@@ -470,6 +491,7 @@ def collect_conflicts_between(
470491
context: ValidationContext,
471492
conflicts: list[Conflict],
472493
cached_fields_and_fragment_names: dict,
494+
compared_fields_and_fragment_pairs: OrderedPairSet,
473495
compared_fragment_pairs: PairSet,
474496
parent_fields_are_mutually_exclusive: bool,
475497
field_map1: NodeAndDefCollection,
@@ -495,6 +517,7 @@ def collect_conflicts_between(
495517
conflict = find_conflict(
496518
context,
497519
cached_fields_and_fragment_names,
520+
compared_fields_and_fragment_pairs,
498521
compared_fragment_pairs,
499522
parent_fields_are_mutually_exclusive,
500523
response_name,
@@ -508,6 +531,7 @@ def collect_conflicts_between(
508531
def find_conflict(
509532
context: ValidationContext,
510533
cached_fields_and_fragment_names: dict,
534+
compared_fields_and_fragment_pairs: OrderedPairSet,
511535
compared_fragment_pairs: PairSet,
512536
parent_fields_are_mutually_exclusive: bool,
513537
response_name: str,
@@ -578,6 +602,7 @@ def find_conflict(
578602
conflicts = find_conflicts_between_sub_selection_sets(
579603
context,
580604
cached_fields_and_fragment_names,
605+
compared_fields_and_fragment_pairs,
581606
compared_fragment_pairs,
582607
are_mutually_exclusive,
583608
get_named_type(type1),
@@ -773,6 +798,43 @@ def subfield_conflicts(
773798
return None # no conflict
774799

775800

801+
class OrderedPairSet:
802+
"""Ordered pair set
803+
804+
A way to keep track of pairs of things where the ordering of the pair matters.
805+
806+
Provides a third argument for has/add to allow flagging the pair as weakly or
807+
strongly present within the collection.
808+
809+
The first element is matched by object identity (its ``id``), since field maps
810+
are unhashable mappings that are kept alive for the duration of the validation.
811+
"""
812+
813+
__slots__ = ("_data",)
814+
815+
_data: dict[int, dict[str, bool]]
816+
817+
def __init__(self) -> None:
818+
self._data = {}
819+
820+
def has(self, a: NodeAndDefCollection, b: str, weakly_present: bool) -> bool:
821+
map_ = self._data.get(id(a))
822+
if map_ is None:
823+
return False
824+
result = map_.get(b)
825+
if result is None:
826+
return False
827+
828+
return True if weakly_present else weakly_present == result
829+
830+
def add(self, a: NodeAndDefCollection, b: str, weakly_present: bool) -> None:
831+
map_ = self._data.get(id(a))
832+
if map_ is None:
833+
self._data[id(a)] = {b: weakly_present}
834+
else:
835+
map_[b] = weakly_present
836+
837+
776838
class PairSet:
777839
"""Pair set
778840

tests/validation/test_overlapping_fields_can_be_merged.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,35 @@ def reports_deep_conflict_in_nested_fragments():
686686
],
687687
)
688688

689+
def reports_deep_conflict_after_nested_fragments():
690+
assert_errors(
691+
"""
692+
fragment F on T {
693+
...G
694+
}
695+
fragment G on T {
696+
...H
697+
}
698+
fragment H on T {
699+
x: a
700+
}
701+
{
702+
x: b
703+
...F
704+
}
705+
""",
706+
[
707+
{
708+
"message": "Fields 'x' conflict"
709+
" because 'b' and 'a' are different fields."
710+
" Use different aliases on the fields"
711+
" to fetch both if this was intentional.",
712+
"locations": [(12, 15), (9, 15)],
713+
"path": None,
714+
}
715+
],
716+
)
717+
689718
def ignores_unknown_fragments():
690719
assert_valid(
691720
"""
@@ -1302,3 +1331,31 @@ def finds_invalid_case_even_with_immediately_recursive_fragment():
13021331
}
13031332
],
13041333
)
1334+
1335+
def does_not_infinite_loop_on_recursive_fragments_separated_by_fields():
1336+
assert_valid(
1337+
"""
1338+
{
1339+
...fragA
1340+
...fragB
1341+
}
1342+
1343+
fragment fragA on T {
1344+
x {
1345+
...fragA
1346+
x {
1347+
...fragA
1348+
}
1349+
}
1350+
}
1351+
1352+
fragment fragB on T {
1353+
x {
1354+
...fragB
1355+
x {
1356+
...fragB
1357+
}
1358+
}
1359+
}
1360+
"""
1361+
)

0 commit comments

Comments
 (0)