Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 100 additions & 3 deletions src/graphql/validation/rules/overlapping_fields_can_be_merged.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ListValueNode,
ObjectFieldNode,
ObjectValueNode,
SelectionNode,
SelectionSetNode,
ValueNode,
VariableNode,
Expand All @@ -39,7 +40,7 @@
from . import ValidationContext, ValidationRule

if TYPE_CHECKING:
from collections.abc import Sequence
from collections.abc import Hashable, Sequence

__all__ = ["OverlappingFieldsCanBeMergedRule"]

Expand Down Expand Up @@ -539,8 +540,11 @@ def collect_conflicts_within(
# (except to itself). If the list only has one item, nothing needs to be
# compared.
if len(fields) > 1:
for i, field in enumerate(fields):
for other_field in fields[i + 1 :]:
# Deduplicate structurally identical fields to avoid quadratic blowup
# when a query repeats the same field many times.
unique_fields = deduplicate_fields(fields)
for i, field in enumerate(unique_fields):
for other_field in unique_fields[i + 1 :]:
conflict = find_conflict(
context,
cached_fields_and_fragment_spreads,
Expand All @@ -558,6 +562,99 @@ def collect_conflicts_within(
conflicts.append(conflict)


def deduplicate_fields(fields: list[NodeAndDef]) -> list[NodeAndDef]:
"""Deduplicate structurally equivalent fields.

Fields that are structurally equivalent (same parent type, field name, arguments,
directives and selection set, irrespective of the ordering of arguments,
directives or sibling selections) can never conflict with each other, so only one
of them needs to take part in the pairwise conflict comparison.
"""
unique: list[NodeAndDef] = []
seen: set[tuple[Hashable, ...]] = set()
for field in fields:
key = field_fingerprint(field)
if key not in seen:
seen.add(key)
unique.append(field)
return unique


def field_fingerprint(field: NodeAndDef) -> tuple[Hashable, ...]:
"""Build a canonical key identifying a field's structure.

Fields with equal keys are structurally equivalent and cannot conflict, so only
one takes part in the pairwise comparison. The key is derived from AST content
(not node identity) and normalizes argument, directive and sibling-selection
ordering recursively, matching the equivalence used by ``find_conflict``, so
reordering those parts cannot defeat deduplication.
"""
parent_type, node, _ = field
return (parent_type.name if parent_type else "", _canonical_field(node))


def _canonical_field(node: FieldNode) -> tuple[Hashable, ...]:
return (
"field",
(node.alias or node.name).value,
node.name.value,
_canonical_arguments(node.arguments),
_canonical_directives(node.directives),
_canonical_selection_set(node.selection_set),
)


def _canonical_selection(selection: SelectionNode) -> tuple[Hashable, ...]:
if isinstance(selection, FieldNode):
return _canonical_field(selection)
if isinstance(selection, FragmentSpreadNode):
return (
"spread",
selection.name.value,
_canonical_arguments(selection.arguments),
_canonical_directives(selection.directives),
)
if isinstance(selection, InlineFragmentNode):
return (
"inline",
selection.type_condition.name.value if selection.type_condition else "",
_canonical_directives(selection.directives),
_canonical_selection_set(selection.selection_set),
)
msg = f"Unexpected selection node: {type(selection).__name__}" # pragma: no cover
raise TypeError(msg) # pragma: no cover


def _canonical_selection_set(
selection_set: SelectionSetNode | None,
) -> tuple[Hashable, ...]:
if selection_set is None:
return ()
# Sorted so that reordering sibling selections does not change the result.
return tuple(sorted(map(_canonical_selection, selection_set.selections)))


def _canonical_arguments(
arguments: Sequence[ArgumentNode | FragmentArgumentNode] | None,
) -> tuple[tuple[str, str], ...]:
# Sorted by name with normalized values, matching ``same_arguments``.
return tuple(
(arg.name.value, stringify_value(arg.value))
for arg in sorted(arguments or (), key=lambda arg: arg.name.value)
)


def _canonical_directives(
directives: Sequence[DirectiveNode] | None,
) -> tuple[tuple[str, tuple[tuple[str, str], ...]], ...]:
return tuple(
sorted(
(directive.name.value, _canonical_arguments(directive.arguments))
for directive in directives or ()
)
)


def collect_conflicts_between(
context: ValidationContext,
conflicts: list[Conflict],
Expand Down
24 changes: 24 additions & 0 deletions tests/benchmarks/test_validate_repeated_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pytest

from graphql import (
GraphQLField,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
parse,
validate,
)

schema = GraphQLSchema(
query=GraphQLObjectType(
name="Query",
fields={"hello": GraphQLField(GraphQLString)},
)
)


@pytest.mark.parametrize("count", [100, 500, 1000, 2000, 3000])
def test_validate_many_repeated_fields(benchmark, count):
document = parse(f"{{ {'hello ' * count}}}")
result = benchmark(lambda: validate(schema, document))
assert result == []
87 changes: 86 additions & 1 deletion tests/validation/test_overlapping_fields_can_be_merged.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from functools import partial

import pytest

from graphql import parse, validate
from graphql.utilities import build_schema
from graphql.validation import OverlappingFieldsCanBeMergedRule

from .harness import assert_validation_errors
from .harness import assert_validation_errors, test_schema

assert_errors = partial(assert_validation_errors, OverlappingFieldsCanBeMergedRule)

Expand Down Expand Up @@ -1565,6 +1568,88 @@ def does_not_infinite_loop_on_transitively_recursive_fragments():
"""
)

def many_repeated_fields_do_not_cause_quadratic_blowup():
Comment thread
bcmyguest marked this conversation as resolved.
repeated_fields = "name " * 3000
Comment on lines +1571 to +1573

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no?

assert_valid(
f"""
fragment manyRepeatedFields on Dog {{
{repeated_fields}
}}
"""
)

def many_repeated_fields_with_conflict_still_detected():
repeated_fields = "name " * 100
doc = parse(
f"""
fragment conflictsAmongMany on Dog {{
{repeated_fields}
name: nickname
}}
"""
)
errors = validate(test_schema, doc, [OverlappingFieldsCanBeMergedRule])
assert errors
assert "'name' and 'nickname' are different fields" in errors[0].message
Comment thread
bcmyguest marked this conversation as resolved.

@pytest.mark.timeout(5)
def many_repeated_composite_fields_do_not_cause_quadratic_blowup():
# Each occurrence of a composite field has its own SelectionSetNode, so
# deduplication must fingerprint the selection set by content, not identity,
# otherwise these still trigger quadratic behavior.
repeated_fields = "mother { name } " * 3000
assert_valid(
f"""
fragment manyRepeatedCompositeFields on Dog {{
{repeated_fields}
}}
"""
)

@pytest.mark.timeout(5)
def many_repeated_fields_with_reordered_arguments_do_not_cause_quadratic_blowup():
# Fields whose arguments differ only in order are equivalent and must
# deduplicate, so the fingerprint has to be argument-order independent.
repeated_fields = "isAtLocation(x: 1, y: 2) isAtLocation(y: 2, x: 1) " * 1500
assert_valid(
f"""
fragment reorderedArgs on Dog {{
{repeated_fields}
}}
"""
)

@pytest.mark.timeout(5)
def repeated_fields_with_reordered_nested_arguments_do_not_cause_blowup():
# Reordering arguments inside a nested selection set keeps the fields
# equivalent, so deduplication must canonicalize recursively rather than rely
# on the printed selection set (which preserves source argument order).
repeated_fields = (
"mother { isAtLocation(x: 1, y: 2) } "
"mother { isAtLocation(y: 2, x: 1) } "
) * 1500
assert_valid(
f"""
fragment reorderedNestedArgs on Dog {{
{repeated_fields}
}}
"""
)

def many_repeated_composite_fields_with_conflict_still_detected():
repeated_fields = "mother { name } " * 100
doc = parse(
f"""
fragment conflictsAmongManyComposites on Dog {{
{repeated_fields}
mother {{ name: nickname }}
}}
"""
)
errors = validate(test_schema, doc, [OverlappingFieldsCanBeMergedRule])
assert errors
assert "'name' and 'nickname' are different fields" in errors[0].message
Comment thread
bcmyguest marked this conversation as resolved.

def finds_invalid_case_even_with_immediately_recursive_fragment():
assert_errors(
"""
Expand Down