Skip to content

Commit a8a04e8

Browse files
committed
spec change: allow empty selection sets
Replicates graphql/graphql-js@7d3fe64
1 parent 8a00033 commit a8a04e8

11 files changed

Lines changed: 271 additions & 38 deletions

File tree

‎src/graphql/language/parser.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def parse(
9797
no_location: bool = False,
9898
max_tokens: int | None = None,
9999
experimental_fragment_arguments: bool = False,
100+
allow_empty_selection_sets: bool = True,
100101
) -> DocumentNode:
101102
"""Given a GraphQL source, parse it into a Document.
102103
@@ -132,6 +133,17 @@ def parse(
132133
...B(x: $var)
133134
}
134135
136+
If ``allow_empty_selection_sets`` is set to ``True`` (the default), the parser
137+
accepts selection sets that contain no selections, changing the grammar from
138+
``SelectionSet : { Selection+ }`` to ``SelectionSet : { Selection* }``.
139+
See https://github.com/graphql/graphql-spec/pull/1227
140+
141+
For example::
142+
143+
{
144+
viewer { }
145+
}
146+
135147
:param source: A GraphQL source string or source object.
136148
:param no_location: By default, the parser creates AST nodes that know the
137149
location in the source that they correspond to. Setting this parameter to
@@ -143,6 +155,9 @@ def parse(
143155
number of tokens allowed within a document.
144156
:param experimental_fragment_arguments: Allows fragment variable definitions
145157
and arguments on fragment spreads to be parsed (experimental).
158+
:param allow_empty_selection_sets: Allows selection sets without selections
159+
(the default). Deprecated, since empty selection sets are now always allowed;
160+
this option will be removed in a future version.
146161
:returns: The parsed GraphQL document AST.
147162
148163
Parse a GraphQL document with the default parser options:
@@ -176,6 +191,7 @@ def parse(
176191
no_location=no_location,
177192
max_tokens=max_tokens,
178193
experimental_fragment_arguments=experimental_fragment_arguments,
194+
allow_empty_selection_sets=allow_empty_selection_sets,
179195
)
180196
return parser.parse_document()
181197

@@ -375,6 +391,7 @@ class Parser:
375391
_no_location: bool
376392
_max_tokens: int | None
377393
_experimental_fragment_arguments: bool
394+
_allow_empty_selection_sets: bool
378395
_lexer: Lexer
379396
_token_counter: int
380397

@@ -385,13 +402,15 @@ def __init__(
385402
max_tokens: int | None = None,
386403
experimental_fragment_arguments: bool = False,
387404
lexer: Lexer | None = None,
405+
allow_empty_selection_sets: bool = True,
388406
) -> None:
389407
if not is_source(source):
390408
source = Source(cast("str", source))
391409

392410
self._no_location = no_location
393411
self._max_tokens = max_tokens
394412
self._experimental_fragment_arguments = experimental_fragment_arguments
413+
self._allow_empty_selection_sets = allow_empty_selection_sets
395414
# You may override the lexer used to lex the source; this is used by schema
396415
# coordinates to introduce a lexer with a restricted syntax.
397416
self._lexer = lexer if lexer is not None else Lexer(source)
@@ -562,11 +581,23 @@ def parse_variable(self) -> VariableNode:
562581
return VariableNode(name=self.parse_name(), loc=self.loc(start))
563582

564583
def parse_selection_set(self) -> SelectionSetNode:
565-
"""SelectionSet: {Selection+}"""
584+
"""SelectionSet: {Selection+}
585+
586+
With ``allow_empty_selection_sets`` enabled (the default):
587+
588+
SelectionSet: {Selection*}
589+
590+
The ``allow_empty_selection_sets`` option is deprecated and will be removed in
591+
a future version, at which point this will be the only grammar.
592+
"""
566593
start = self._lexer.token
567594
return SelectionSetNode(
568-
selections=self.many(
569-
TokenKind.BRACE_L, self.parse_selection, TokenKind.BRACE_R
595+
selections=(
596+
self.any(TokenKind.BRACE_L, self.parse_selection, TokenKind.BRACE_R)
597+
if self._allow_empty_selection_sets
598+
else self.many(
599+
TokenKind.BRACE_L, self.parse_selection, TokenKind.BRACE_R
600+
)
570601
),
571602
loc=self.loc(start),
572603
)

‎src/graphql/language/printer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def leave_variable_definition(node: PrintedNode, *_args: Any) -> str:
113113

114114
@staticmethod
115115
def leave_selection_set(node: PrintedNode, *_args: Any) -> str:
116-
return block(node.selections)
116+
return block(node.selections) if node.selections else "{}"
117117

118118
@staticmethod
119119
def leave_field(node: PrintedNode, *_args: Any) -> str:

‎src/graphql/validation/rules/scalar_leafs.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ def enter_field(self, node: FieldNode, *_args: Any) -> None:
6262
node,
6363
)
6464
)
65-
elif not selection_set.selections:
65+
elif (
66+
not selection_set.selections
67+
and not self.context.allow_empty_selection_sets
68+
):
6669
field_name = node.name.value
6770
self.report_error(
6871
GraphQLError(

‎src/graphql/validation/rules/single_field_subscriptions.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,19 @@ def enter_operation_definition(
9494
)
9595
)
9696
return
97-
if len(grouped_field_set) > 1:
97+
if not grouped_field_set:
98+
self.report_error(
99+
GraphQLError(
100+
(
101+
"Anonymous Subscription"
102+
if operation_name is None
103+
else f"Subscription '{operation_name}'"
104+
)
105+
+ " must select one top level field.",
106+
node.selection_set,
107+
)
108+
)
109+
elif len(grouped_field_set) > 1:
98110
field_details_lists = list(grouped_field_set.values())
99111
extra_field_details_lists = field_details_lists[1:]
100112
extra_field_selection = [

‎src/graphql/validation/validate.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def validate(
4444
rules: Collection[type[ASTValidationRule]] | None = None,
4545
max_errors: int | None = None,
4646
hide_suggestions: bool = False,
47+
allow_empty_selection_sets: bool = True,
4748
) -> list[GraphQLError]:
4849
"""Implements the "Validation" section of the spec.
4950
@@ -69,6 +70,11 @@ def validate(
6970
Defaults to 100.
7071
:param hide_suggestions: Whether suggestion text should be omitted from
7172
validation errors.
73+
:param allow_empty_selection_sets: If enabled (the default), a selection set on
74+
an object, interface or union type is only required to be present, rather
75+
than to be non-empty. Pair this with the ``allow_empty_selection_sets``
76+
parse option. See https://github.com/graphql/graphql-spec/pull/1227.
77+
Deprecated, since empty selection sets are now always allowed.
7278
:returns: Validation errors, or an empty list when the document is valid.
7379
7480
Validate with the default specified rules:
@@ -115,7 +121,12 @@ def on_error(error: GraphQLError) -> None:
115121
errors.append(error)
116122

117123
context = ValidationContext(
118-
schema, document_ast, type_info, on_error, hide_suggestions
124+
schema,
125+
document_ast,
126+
type_info,
127+
on_error,
128+
hide_suggestions,
129+
allow_empty_selection_sets,
119130
)
120131

121132
# This uses a specialized visitor which runs multiple visitors in parallel,

‎src/graphql/validation/validation_context.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,9 @@ class ValidationContext(ASTValidationContext):
346346
:param type_info: TypeInfo instance used to track traversal state.
347347
:param on_error: Callback invoked for each validation error.
348348
:param hide_suggestions: Whether suggestion text should be omitted from errors.
349+
:param allow_empty_selection_sets: Whether selection sets on composite types may
350+
be empty (the default). Deprecated, since empty selection sets are now always
351+
allowed.
349352
350353
>>> from graphql import GraphQLError, TypeInfo, build_schema, parse
351354
>>> from graphql.validation import ValidationContext
@@ -367,6 +370,7 @@ class ValidationContext(ASTValidationContext):
367370
_variable_usages: dict[NodeWithSelectionSet, list[VariableUsage]]
368371
_recursive_variable_usages: dict[OperationDefinitionNode, list[VariableUsage]]
369372
_hide_suggestions: bool
373+
_allow_empty_selection_sets: bool
370374

371375
def __init__(
372376
self,
@@ -375,19 +379,29 @@ def __init__(
375379
type_info: TypeInfo,
376380
on_error: Callable[[GraphQLError], None],
377381
hide_suggestions: bool = False,
382+
allow_empty_selection_sets: bool = True,
378383
) -> None:
379384
super().__init__(ast, on_error)
380385
self.schema = schema
381386
self._type_info = type_info
382387
self._variable_usages = {}
383388
self._recursive_variable_usages = {}
384389
self._hide_suggestions = hide_suggestions
390+
self._allow_empty_selection_sets = allow_empty_selection_sets
385391

386392
@property
387393
def hide_suggestions(self) -> bool:
388394
"""Whether validation error suggestions are hidden."""
389395
return self._hide_suggestions
390396

397+
@property
398+
def allow_empty_selection_sets(self) -> bool:
399+
"""Whether empty selection sets on composite types are allowed.
400+
401+
Deprecated, since empty selection sets are now always allowed.
402+
"""
403+
return self._allow_empty_selection_sets
404+
391405
def get_variable_usages(self, node: NodeWithSelectionSet) -> list[VariableUsage]:
392406
"""Get variable usages found directly within the given node.
393407

‎tests/execution/test_executor.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,27 @@ def merges_parallel_fragments():
256256
None,
257257
)
258258

259+
def executes_empty_selection_sets():
260+
Type = GraphQLObjectType(
261+
"Type",
262+
lambda: {
263+
"a": GraphQLField(GraphQLString, resolve=lambda *_args: "Apple"),
264+
"deep": GraphQLField(Type, resolve=lambda *_args: {}),
265+
},
266+
)
267+
schema = GraphQLSchema(Type)
268+
269+
document = parse(
270+
"{ a deep { } ...Frag } fragment Frag on Type { }",
271+
allow_empty_selection_sets=True,
272+
)
273+
274+
result = execute_sync(schema, document)
275+
assert result == ({"a": "Apple", "deep": {}}, None)
276+
277+
empty_document = parse("{ }", allow_empty_selection_sets=True)
278+
assert execute_sync(schema, empty_document) == ({}, None)
279+
259280
def provides_info_about_current_execution_state():
260281
resolved_infos = []
261282

‎tests/language/test_parser.py‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TypeAlias, cast
3+
from typing import Any, TypeAlias, cast
44

55
import pytest
66

@@ -49,9 +49,11 @@
4949
Location: TypeAlias = tuple[int, int] | None
5050

5151

52-
def assert_syntax_error(text: str, message: str, location: Location) -> None:
52+
def assert_syntax_error(
53+
text: str, message: str, location: Location, **options: Any
54+
) -> None:
5355
with pytest.raises(GraphQLSyntaxError) as exc_info:
54-
parse(text)
56+
parse(text, **options)
5557
error = exc_info.value
5658
assert error.message == f"Syntax Error: {message}"
5759
assert error.description == message
@@ -503,6 +505,52 @@ def disallows_parsing_fragment_spread_arguments_without_experimental_flag():
503505
with pytest.raises(GraphQLSyntaxError):
504506
parse(document)
505507

508+
def allows_parsing_empty_selection_sets():
509+
document = parse("{ node { } }", no_location=True)
510+
511+
assert document == DocumentNode(
512+
definitions=(
513+
OperationDefinitionNode(
514+
description=None,
515+
operation=OperationType.QUERY,
516+
name=None,
517+
variable_definitions=None,
518+
directives=None,
519+
selection_set=SelectionSetNode(
520+
selections=(
521+
FieldNode(
522+
alias=None,
523+
name=NameNode(value="node"),
524+
arguments=None,
525+
directives=None,
526+
selection_set=SelectionSetNode(selections=()),
527+
),
528+
),
529+
),
530+
),
531+
),
532+
)
533+
534+
def allows_parsing_an_empty_operation_selection_set():
535+
document = parse("{ }", no_location=True)
536+
operation = cast("OperationDefinitionNode", document.definitions[0])
537+
538+
assert operation.selection_set.selections == ()
539+
540+
def allows_parsing_an_empty_fragment_selection_set():
541+
document = parse("fragment a on t { }", no_location=True)
542+
fragment = cast("FragmentDefinitionNode", document.definitions[0])
543+
544+
assert fragment.selection_set.selections == ()
545+
546+
def disallows_parsing_empty_selection_sets_when_disabled():
547+
assert_syntax_error(
548+
"{ node { } }",
549+
"Expected Name, found '}'.",
550+
(1, 10),
551+
allow_empty_selection_sets=False,
552+
)
553+
506554
def contains_location_information_that_only_stringifies_start_end():
507555
result = parse("{ id }")
508556
assert str(result.loc) == "0:6"

‎tests/language/test_printer.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ def prints_minimal_ast():
2020
ast = FieldNode(name=NameNode(value="foo"))
2121
assert print_ast(ast) == "foo"
2222

23+
def prints_empty_selection_sets():
24+
ast = parse(
25+
"{ foo { } bar { ... on Baz { } } }", allow_empty_selection_sets=True
26+
)
27+
assert print_ast(ast) == dedent(
28+
"""
29+
{
30+
foo {}
31+
bar {
32+
... on Baz {}
33+
}
34+
}
35+
"""
36+
)
37+
38+
empty_operation_ast = parse("{ }", allow_empty_selection_sets=True)
39+
assert print_ast(empty_operation_ast) == "{}"
40+
2341
def produces_helpful_error_messages():
2442
bad_ast = {"random": "Data"}
2543
with pytest.raises(TypeError) as exc_info:

0 commit comments

Comments
 (0)