Skip to content

Commit 83c2dd6

Browse files
committed
Use tuples for all AST collection fields (instead of lists)
Prepares AST for immutability by using tuples instead of lists for collection fields. This aligns with the JavaScript GraphQL library which uses readonly arrays, and enables future frozen datastructures.
1 parent 837f604 commit 83c2dd6

File tree

10 files changed

+105
-98
lines changed

10 files changed

+105
-98
lines changed

docs/usage/parser.rst

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,30 @@ This will give the same result as manually creating the AST document::
3535

3636
from graphql.language.ast import *
3737

38-
document = DocumentNode(definitions=[
38+
document = DocumentNode(definitions=(
3939
ObjectTypeDefinitionNode(
4040
name=NameNode(value='Query'),
41-
fields=[
41+
fields=(
4242
FieldDefinitionNode(
4343
name=NameNode(value='me'),
4444
type=NamedTypeNode(name=NameNode(value='User')),
45-
arguments=[], directives=[])
46-
], directives=[], interfaces=[]),
45+
arguments=(), directives=()),
46+
), interfaces=(), directives=()),
4747
ObjectTypeDefinitionNode(
4848
name=NameNode(value='User'),
49-
fields=[
49+
fields=(
5050
FieldDefinitionNode(
5151
name=NameNode(value='id'),
5252
type=NamedTypeNode(
5353
name=NameNode(value='ID')),
54-
arguments=[], directives=[]),
54+
arguments=(), directives=()),
5555
FieldDefinitionNode(
5656
name=NameNode(value='name'),
5757
type=NamedTypeNode(
5858
name=NameNode(value='String')),
59-
arguments=[], directives=[]),
60-
], directives=[], interfaces=[]),
61-
])
59+
arguments=(), directives=()),
60+
), interfaces=(), directives=()),
61+
))
6262

6363

6464
When parsing with ``no_location=False`` (the default), the AST nodes will also have a

src/graphql/language/parser.py

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from functools import partial
6-
from typing import Callable, List, Mapping, TypeVar, Union, cast
6+
from typing import Callable, Mapping, TypeVar, Union, cast
77

88
from ..error import GraphQLError, GraphQLSyntaxError
99
from .ast import (
@@ -349,8 +349,8 @@ def parse_operation_definition(self) -> OperationDefinitionNode:
349349
return OperationDefinitionNode(
350350
operation=OperationType.QUERY,
351351
name=None,
352-
variable_definitions=[],
353-
directives=[],
352+
variable_definitions=(),
353+
directives=(),
354354
selection_set=self.parse_selection_set(),
355355
loc=self.loc(start),
356356
)
@@ -373,7 +373,7 @@ def parse_operation_type(self) -> OperationType:
373373
except ValueError as error:
374374
raise self.unexpected(operation_token) from error
375375

376-
def parse_variable_definitions(self) -> list[VariableDefinitionNode]:
376+
def parse_variable_definitions(self) -> tuple[VariableDefinitionNode, ...]:
377377
"""VariableDefinitions: (VariableDefinition+)"""
378378
return self.optional_many(
379379
TokenKind.PAREN_L, self.parse_variable_definition, TokenKind.PAREN_R
@@ -468,7 +468,7 @@ def parse_nullability_assertion(self) -> NullabilityAssertionNode | None:
468468

469469
return nullability_assertion
470470

471-
def parse_arguments(self, is_const: bool) -> list[ArgumentNode]:
471+
def parse_arguments(self, is_const: bool) -> tuple[ArgumentNode, ...]:
472472
"""Arguments[Const]: (Argument[?Const]+)"""
473473
item = self.parse_const_argument if is_const else self.parse_argument
474474
return self.optional_many(
@@ -533,6 +533,7 @@ def parse_fragment_definition(self) -> FragmentDefinitionNode:
533533
)
534534
return FragmentDefinitionNode(
535535
name=self.parse_fragment_name(),
536+
variable_definitions=(),
536537
type_condition=self.parse_type_condition(),
537538
directives=self.parse_directives(False),
538539
selection_set=self.parse_selection_set(),
@@ -646,16 +647,16 @@ def parse_const_value_literal(self) -> ConstValueNode:
646647

647648
# Implement the parsing rules in the Directives section.
648649

649-
def parse_directives(self, is_const: bool) -> list[DirectiveNode]:
650+
def parse_directives(self, is_const: bool) -> tuple[DirectiveNode, ...]:
650651
"""Directives[Const]: Directive[?Const]+"""
651652
directives: list[DirectiveNode] = []
652653
append = directives.append
653654
while self.peek(TokenKind.AT):
654655
append(self.parse_directive(is_const))
655-
return directives
656+
return tuple(directives)
656657

657-
def parse_const_directives(self) -> list[ConstDirectiveNode]:
658-
return cast("List[ConstDirectiveNode]", self.parse_directives(True))
658+
def parse_const_directives(self) -> tuple[ConstDirectiveNode, ...]:
659+
return cast("tuple[ConstDirectiveNode, ...]", self.parse_directives(True))
659660

660661
def parse_directive(self, is_const: bool) -> DirectiveNode:
661662
"""Directive[Const]: @ Name Arguments[?Const]?"""
@@ -778,15 +779,15 @@ def parse_object_type_definition(self) -> ObjectTypeDefinitionNode:
778779
loc=self.loc(start),
779780
)
780781

781-
def parse_implements_interfaces(self) -> list[NamedTypeNode]:
782+
def parse_implements_interfaces(self) -> tuple[NamedTypeNode, ...]:
782783
"""ImplementsInterfaces"""
783784
return (
784785
self.delimited_many(TokenKind.AMP, self.parse_named_type)
785786
if self.expect_optional_keyword("implements")
786-
else []
787+
else ()
787788
)
788789

789-
def parse_fields_definition(self) -> list[FieldDefinitionNode]:
790+
def parse_fields_definition(self) -> tuple[FieldDefinitionNode, ...]:
790791
"""FieldsDefinition: {FieldDefinition+}"""
791792
return self.optional_many(
792793
TokenKind.BRACE_L, self.parse_field_definition, TokenKind.BRACE_R
@@ -810,7 +811,7 @@ def parse_field_definition(self) -> FieldDefinitionNode:
810811
loc=self.loc(start),
811812
)
812813

813-
def parse_argument_defs(self) -> list[InputValueDefinitionNode]:
814+
def parse_argument_defs(self) -> tuple[InputValueDefinitionNode, ...]:
814815
"""ArgumentsDefinition: (InputValueDefinition+)"""
815816
return self.optional_many(
816817
TokenKind.PAREN_L, self.parse_input_value_def, TokenKind.PAREN_R
@@ -872,12 +873,12 @@ def parse_union_type_definition(self) -> UnionTypeDefinitionNode:
872873
loc=self.loc(start),
873874
)
874875

875-
def parse_union_member_types(self) -> list[NamedTypeNode]:
876+
def parse_union_member_types(self) -> tuple[NamedTypeNode, ...]:
876877
"""UnionMemberTypes"""
877878
return (
878879
self.delimited_many(TokenKind.PIPE, self.parse_named_type)
879880
if self.expect_optional_token(TokenKind.EQUALS)
880-
else []
881+
else ()
881882
)
882883

883884
def parse_enum_type_definition(self) -> EnumTypeDefinitionNode:
@@ -896,7 +897,7 @@ def parse_enum_type_definition(self) -> EnumTypeDefinitionNode:
896897
loc=self.loc(start),
897898
)
898899

899-
def parse_enum_values_definition(self) -> list[EnumValueDefinitionNode]:
900+
def parse_enum_values_definition(self) -> tuple[EnumValueDefinitionNode, ...]:
900901
"""EnumValuesDefinition: {EnumValueDefinition+}"""
901902
return self.optional_many(
902903
TokenKind.BRACE_L, self.parse_enum_value_definition, TokenKind.BRACE_R
@@ -942,7 +943,7 @@ def parse_input_object_type_definition(self) -> InputObjectTypeDefinitionNode:
942943
loc=self.loc(start),
943944
)
944945

945-
def parse_input_fields_definition(self) -> list[InputValueDefinitionNode]:
946+
def parse_input_fields_definition(self) -> tuple[InputValueDefinitionNode, ...]:
946947
"""InputFieldsDefinition: {InputValueDefinition+}"""
947948
return self.optional_many(
948949
TokenKind.BRACE_L, self.parse_input_value_def, TokenKind.BRACE_R
@@ -1076,7 +1077,7 @@ def parse_directive_definition(self) -> DirectiveDefinitionNode:
10761077
loc=self.loc(start),
10771078
)
10781079

1079-
def parse_directive_locations(self) -> list[NameNode]:
1080+
def parse_directive_locations(self) -> tuple[NameNode, ...]:
10801081
"""DirectiveLocations"""
10811082
return self.delimited_many(TokenKind.PIPE, self.parse_directive_location)
10821083

@@ -1173,11 +1174,11 @@ def unexpected(self, at_token: Token | None = None) -> GraphQLError:
11731174

11741175
def any(
11751176
self, open_kind: TokenKind, parse_fn: Callable[[], T], close_kind: TokenKind
1176-
) -> list[T]:
1177+
) -> tuple[T, ...]:
11771178
"""Fetch any matching nodes, possibly none.
11781179
1179-
Returns a possibly empty list of parse nodes, determined by the ``parse_fn``.
1180-
This list begins with a lex token of ``open_kind`` and ends with a lex token of
1180+
Returns a possibly empty tuple of parse nodes, determined by the ``parse_fn``.
1181+
This tuple begins with a lex token of ``open_kind`` and ends with a lex token of
11811182
``close_kind``. Advances the parser to the next lex token after the closing
11821183
token.
11831184
"""
@@ -1187,16 +1188,16 @@ def any(
11871188
expect_optional_token = partial(self.expect_optional_token, close_kind)
11881189
while not expect_optional_token():
11891190
append(parse_fn())
1190-
return nodes
1191+
return tuple(nodes)
11911192

11921193
def optional_many(
11931194
self, open_kind: TokenKind, parse_fn: Callable[[], T], close_kind: TokenKind
1194-
) -> list[T]:
1195+
) -> tuple[T, ...]:
11951196
"""Fetch matching nodes, maybe none.
11961197
1197-
Returns a list of parse nodes, determined by the ``parse_fn``. It can be empty
1198+
Returns a tuple of parse nodes, determined by the ``parse_fn``. It can be empty
11981199
only if the open token is missing, otherwise it will always return a non-empty
1199-
list that begins with a lex token of ``open_kind`` and ends with a lex token of
1200+
tuple that begins with a lex token of ``open_kind`` and ends with a lex token of
12001201
``close_kind``. Advances the parser to the next lex token after the closing
12011202
token.
12021203
"""
@@ -1206,16 +1207,16 @@ def optional_many(
12061207
expect_optional_token = partial(self.expect_optional_token, close_kind)
12071208
while not expect_optional_token():
12081209
append(parse_fn())
1209-
return nodes
1210-
return []
1210+
return tuple(nodes)
1211+
return ()
12111212

12121213
def many(
12131214
self, open_kind: TokenKind, parse_fn: Callable[[], T], close_kind: TokenKind
1214-
) -> list[T]:
1215+
) -> tuple[T, ...]:
12151216
"""Fetch matching nodes, at least one.
12161217
1217-
Returns a non-empty list of parse nodes, determined by the ``parse_fn``. This
1218-
list begins with a lex token of ``open_kind`` and ends with a lex token of
1218+
Returns a non-empty tuple of parse nodes, determined by the ``parse_fn``. This
1219+
tuple begins with a lex token of ``open_kind`` and ends with a lex token of
12191220
``close_kind``. Advances the parser to the next lex token after the closing
12201221
token.
12211222
"""
@@ -1225,17 +1226,17 @@ def many(
12251226
expect_optional_token = partial(self.expect_optional_token, close_kind)
12261227
while not expect_optional_token():
12271228
append(parse_fn())
1228-
return nodes
1229+
return tuple(nodes)
12291230

12301231
def delimited_many(
12311232
self, delimiter_kind: TokenKind, parse_fn: Callable[[], T]
1232-
) -> list[T]:
1233+
) -> tuple[T, ...]:
12331234
"""Fetch many delimited nodes.
12341235
1235-
Returns a non-empty list of parse nodes, determined by the ``parse_fn``. This
1236-
list may begin with a lex token of ``delimiter_kind`` followed by items
1236+
Returns a non-empty tuple of parse nodes, determined by the ``parse_fn``. This
1237+
tuple may begin with a lex token of ``delimiter_kind`` followed by items
12371238
separated by lex tokens of ``delimiter_kind``. Advances the parser to the next
1238-
lex token after the last item in the list.
1239+
lex token after the last item in the tuple.
12391240
"""
12401241
expect_optional_token = partial(self.expect_optional_token, delimiter_kind)
12411242
expect_optional_token()
@@ -1245,7 +1246,7 @@ def delimited_many(
12451246
append(parse_fn())
12461247
if not expect_optional_token():
12471248
break
1248-
return nodes
1249+
return tuple(nodes)
12491250

12501251
def advance_lexer(self) -> None:
12511252
"""Advance the lexer."""

src/graphql/utilities/ast_to_dict.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,18 @@ def ast_to_dict(
4545
elif node in cache:
4646
return cache[node]
4747
cache[node] = res = {}
48+
# Note: We don't use msgspec.structs.asdict() because loc needs special
49+
# handling (converted to {start, end} dict rather than full Location object)
50+
# Filter out 'loc' - it's handled separately for the locations option
51+
fields = [f for f in node.keys if f != "loc"]
4852
res.update(
4953
{
5054
key: ast_to_dict(getattr(node, key), locations, cache)
51-
for key in ("kind", *node.keys[1:])
55+
for key in ("kind", *fields)
5256
}
5357
)
5458
if locations:
55-
loc = node.loc
59+
loc = getattr(node, "loc", None)
5660
if loc:
5761
res["loc"] = {"start": loc.start, "end": loc.end}
5862
return res

src/graphql/utilities/concat_ast.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,5 @@ def concat_ast(asts: Collection[DocumentNode]) -> DocumentNode:
1717
the ASTs together into batched AST, useful for validating many GraphQL source files
1818
which together represent one conceptual application.
1919
"""
20-
return DocumentNode(
21-
definitions=list(chain.from_iterable(document.definitions for document in asts))
22-
)
20+
all_definitions = chain.from_iterable(doc.definitions for doc in asts)
21+
return DocumentNode(definitions=tuple(all_definitions))

src/graphql/utilities/separate_operations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ def separate_operations(document_ast: DocumentNode) -> dict[str, DocumentNode]:
6060
# The list of definition nodes to be included for this operation, sorted
6161
# to retain the same order as the original document.
6262
separated_document_asts[operation_name] = DocumentNode(
63-
definitions=[
63+
definitions=tuple(
6464
node
6565
for node in document_ast.definitions
6666
if node is operation
6767
or (
6868
isinstance(node, FragmentDefinitionNode)
6969
and node.name.value in dependencies
7070
)
71-
]
71+
)
7272
)
7373

7474
return separated_document_asts

src/graphql/utilities/sort_value_node.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from __future__ import annotations
44

5-
from copy import copy
6-
75
from ..language import ListValueNode, ObjectFieldNode, ObjectValueNode, ValueNode
86
from ..pyutils import natural_comparison_key
97

@@ -18,18 +16,23 @@ def sort_value_node(value_node: ValueNode) -> ValueNode:
1816
For internal use only.
1917
"""
2018
if isinstance(value_node, ObjectValueNode):
21-
value_node = copy(value_node)
22-
value_node.fields = sort_fields(value_node.fields)
19+
# Create new node with updated fields (immutable-friendly copy-on-write)
20+
values = {k: getattr(value_node, k) for k in value_node.keys}
21+
values["fields"] = sort_fields(value_node.fields)
22+
value_node = value_node.__class__(**values)
2323
elif isinstance(value_node, ListValueNode):
24-
value_node = copy(value_node)
25-
value_node.values = tuple(sort_value_node(value) for value in value_node.values)
24+
# Create new node with updated values (immutable-friendly copy-on-write)
25+
values = {k: getattr(value_node, k) for k in value_node.keys}
26+
values["values"] = tuple(sort_value_node(value) for value in value_node.values)
27+
value_node = value_node.__class__(**values)
2628
return value_node
2729

2830

2931
def sort_field(field: ObjectFieldNode) -> ObjectFieldNode:
30-
field = copy(field)
31-
field.value = sort_value_node(field.value)
32-
return field
32+
# Create new node with updated value (immutable-friendly copy-on-write)
33+
values = {k: getattr(field, k) for k in field.keys}
34+
values["value"] = sort_value_node(field.value)
35+
return field.__class__(**values)
3336

3437

3538
def sort_fields(fields: tuple[ObjectFieldNode, ...]) -> tuple[ObjectFieldNode, ...]:

0 commit comments

Comments
 (0)