Skip to content

Commit ecb3c08

Browse files
committed
deduplicate overlapping deferred and non-deferred fragment spreads
Only the fragment-spread deduplication from the upstream commit was missing: visited_fragment_names becomes a dict[str, bool] tracking whether a fragment was visited as deferred, so a deferred spread is skipped once visited at all and a non-deferred spread only when previously visited as non-deferred. Replicates graphql/graphql-js@8e01e23
1 parent bd44e60 commit ecb3c08

3 files changed

Lines changed: 201 additions & 35 deletions

File tree

src/graphql/execution/collect_fields.py

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class CollectFieldsContext(NamedTuple):
8282
variable_values: VariableValues
8383
operation: OperationDefinitionNode
8484
runtime_type: GraphQLObjectType
85-
visited_fragment_names: set[str]
85+
visited_fragment_names: dict[str, bool]
8686
hide_suggestions: bool
8787
forbidden_directive_instances: list[DirectiveNode]
8888
forbid_skip_and_include: bool
@@ -123,7 +123,7 @@ def collect_fields(
123123
variable_values,
124124
operation,
125125
runtime_type,
126-
set(),
126+
{},
127127
hide_suggestions,
128128
[],
129129
forbid_skip_and_include,
@@ -163,7 +163,7 @@ def collect_subfields(
163163
variable_values,
164164
operation,
165165
return_type,
166-
set(),
166+
{},
167167
hide_suggestions,
168168
[],
169169
False,
@@ -255,6 +255,17 @@ def collect_fields_impl(
255255
elif isinstance(selection, FragmentSpreadNode): # pragma: no branch
256256
frag_name = selection.name.value
257257

258+
if not should_include_node(
259+
context, selection, variable_values, fragment_variable_values
260+
):
261+
continue
262+
263+
fragment = fragments.get(frag_name)
264+
if fragment is None or not does_fragment_condition_match(
265+
schema, fragment.definition, runtime_type
266+
):
267+
continue
268+
258269
new_defer_usage = get_defer_usage(
259270
operation,
260271
variable_values,
@@ -263,19 +274,24 @@ def collect_fields_impl(
263274
defer_usage,
264275
)
265276

266-
if new_defer_usage is None and (
267-
frag_name in visited_fragment_names
268-
or not should_include_node(
269-
context, selection, variable_values, fragment_variable_values
270-
)
271-
):
272-
continue
277+
visited_as_deferred = visited_fragment_names.get(frag_name)
273278

274-
fragment = fragments.get(frag_name)
275-
if fragment is None or not does_fragment_condition_match(
276-
schema, fragment.definition, runtime_type
277-
):
278-
continue
279+
if new_defer_usage is None:
280+
# If this spread is not deferred, it may be skipped when already
281+
# visited as a non-deferred spread. If it was previously visited as
282+
# a deferred spread, it must be revisited.
283+
if visited_as_deferred is False:
284+
continue
285+
visited_fragment_names[frag_name] = False
286+
maybe_new_defer_usage = defer_usage
287+
else:
288+
# If this spread is deferred, it can be skipped if it has already
289+
# been visited.
290+
if visited_as_deferred is not None:
291+
continue
292+
visited_fragment_names[frag_name] = True
293+
new_defer_usages.append(new_defer_usage)
294+
maybe_new_defer_usage = new_defer_usage
279295

280296
fragment_variable_signatures = fragment.variable_signatures
281297
new_fragment_variable_values: FragmentVariableValues | None = None
@@ -288,26 +304,14 @@ def collect_fields_impl(
288304
hide_suggestions,
289305
)
290306

291-
if new_defer_usage is None:
292-
visited_fragment_names.add(frag_name)
293-
collect_fields_impl(
294-
context,
295-
fragment.definition.selection_set,
296-
grouped_field_set,
297-
new_defer_usages,
298-
defer_usage,
299-
new_fragment_variable_values,
300-
)
301-
else:
302-
new_defer_usages.append(new_defer_usage)
303-
collect_fields_impl(
304-
context,
305-
fragment.definition.selection_set,
306-
grouped_field_set,
307-
new_defer_usages,
308-
new_defer_usage,
309-
new_fragment_variable_values,
310-
)
307+
collect_fields_impl(
308+
context,
309+
fragment.definition.selection_set,
310+
grouped_field_set,
311+
new_defer_usages,
312+
maybe_new_defer_usage,
313+
new_fragment_variable_values,
314+
)
311315

312316

313317
def get_defer_usage(
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
from graphql.execution import Executor
4+
from graphql.execution.collect_fields import collect_fields
5+
from graphql.language import parse
6+
from graphql.utilities import build_schema
7+
8+
schema = build_schema(
9+
"""
10+
type Query {
11+
field: String
12+
}
13+
"""
14+
)
15+
16+
17+
def collect_root_fields(query: str):
18+
executor = Executor.build(schema, parse(query))
19+
20+
assert isinstance(executor, Executor)
21+
22+
query_type = schema.query_type
23+
assert query_type is not None
24+
25+
return collect_fields(
26+
executor.schema,
27+
executor.fragments,
28+
executor.variable_values,
29+
query_type,
30+
executor.operation,
31+
)
32+
33+
34+
def describe_collect_fields():
35+
def describe_overlapping_fragment_spreads():
36+
def not_collect_deferred_spread_after_non_deferred_collected():
37+
"""Should not collect a deferred spread after a non-deferred."""
38+
collected = collect_root_fields(
39+
"""
40+
query {
41+
...FragmentName
42+
...FragmentName @defer
43+
}
44+
fragment FragmentName on Query {
45+
field
46+
}
47+
"""
48+
)
49+
50+
assert len(collected.new_defer_usages) == 0
51+
52+
def not_collect_deferred_spread_after_deferred_collected():
53+
"""Should not collect a deferred spread after a deferred one."""
54+
collected = collect_root_fields(
55+
"""
56+
query {
57+
...FragmentName @defer
58+
...FragmentName @defer
59+
}
60+
fragment FragmentName on Query {
61+
field
62+
}
63+
"""
64+
)
65+
66+
assert len(collected.new_defer_usages) == 1
67+
68+
def collect_non_deferred_spread_after_deferred_collected():
69+
"""Should collect a non-deferred spread after a deferred one."""
70+
collected = collect_root_fields(
71+
"""
72+
query {
73+
...FragmentName @defer
74+
...FragmentName
75+
}
76+
fragment FragmentName on Query {
77+
field
78+
}
79+
"""
80+
)
81+
82+
field_details_list = collected.grouped_field_set.get("field")
83+
84+
assert field_details_list is not None
85+
assert len(field_details_list) == 2
86+
87+
def not_collect_non_deferred_spread_after_non_deferred_collected():
88+
"""Should not collect a non-deferred spread after a non-deferred."""
89+
collected = collect_root_fields(
90+
"""
91+
query {
92+
...FragmentName
93+
...FragmentName
94+
}
95+
fragment FragmentName on Query {
96+
field
97+
}
98+
"""
99+
)
100+
101+
field_details_list = collected.grouped_field_set.get("field")
102+
103+
assert field_details_list is not None
104+
assert len(field_details_list) == 1

tests/execution/test_stream.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
IncrementalStreamResult,
1414
StreamItemRecord,
1515
StreamRecord,
16+
execute,
1617
experimental_execute_incrementally,
1718
)
1819
from graphql.language import DocumentNode, parse
@@ -2522,3 +2523,60 @@ async def iterable(_info):
25222523
# is actually a generator that cannot do the cleanup in case of an athrow()
25232524
await sleep(0)
25242525
assert not finished
2526+
2527+
async def original_execute_function_throws_error_if_streamed_and_all_is_sync():
2528+
"""Original execute function throws error if streamed and all is sync
2529+
2530+
Original execute function throws error if anything is streamed and everything
2531+
else is sync.
2532+
"""
2533+
document = parse(
2534+
"""
2535+
query {
2536+
scalarList
2537+
friendList @stream { name }
2538+
}
2539+
"""
2540+
)
2541+
2542+
root_value = {
2543+
"scalarList": ["apple", "banana", "coconut"],
2544+
"friendList": [{"name": "Alice"}, {"name": "Bob"}],
2545+
}
2546+
with pytest.raises(GraphQLError) as exc_info:
2547+
await execute(schema, document, root_value) # type: ignore
2548+
2549+
assert str(exc_info.value) == (
2550+
"Executing this GraphQL operation would unexpectedly produce"
2551+
" multiple payloads (due to @defer or @stream directive)"
2552+
)
2553+
2554+
async def original_execute_function_throws_error_if_streamed_and_not_all_is_sync():
2555+
"""Original execute function throws error if streamed and not all is sync
2556+
2557+
Original execute function resolves to error if anything is streamed and
2558+
something else is async.
2559+
"""
2560+
document = parse(
2561+
"""
2562+
query {
2563+
scalarList
2564+
friendList @stream { name }
2565+
}
2566+
"""
2567+
)
2568+
2569+
async def scalar_list(_info):
2570+
return ["apple", "banana", "coconut"]
2571+
2572+
root_value = {
2573+
"scalarList": scalar_list,
2574+
"friendList": [{"name": "Alice"}, {"name": "Bob"}],
2575+
}
2576+
with pytest.raises(GraphQLError) as exc_info:
2577+
await execute(schema, document, root_value) # type: ignore
2578+
2579+
assert str(exc_info.value) == (
2580+
"Executing this GraphQL operation would unexpectedly produce"
2581+
" multiple payloads (due to @defer or @stream directive)"
2582+
)

0 commit comments

Comments
 (0)