flowquery 1.0.32 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/compute/flowquery.d.ts +43 -0
  2. package/dist/compute/flowquery.d.ts.map +1 -0
  3. package/dist/compute/flowquery.js +30 -0
  4. package/dist/compute/flowquery.js.map +1 -0
  5. package/dist/compute/runner.d.ts +0 -21
  6. package/dist/compute/runner.d.ts.map +1 -1
  7. package/dist/compute/runner.js.map +1 -1
  8. package/dist/flowquery.min.js +1 -1
  9. package/dist/index.browser.d.ts +1 -1
  10. package/dist/index.browser.d.ts.map +1 -1
  11. package/dist/index.browser.js +10 -10
  12. package/dist/index.browser.js.map +1 -1
  13. package/dist/index.node.d.ts +4 -4
  14. package/dist/index.node.d.ts.map +1 -1
  15. package/dist/index.node.js +13 -13
  16. package/dist/index.node.js.map +1 -1
  17. package/dist/parsing/context.d.ts +1 -0
  18. package/dist/parsing/context.d.ts.map +1 -1
  19. package/dist/parsing/context.js +5 -0
  20. package/dist/parsing/context.js.map +1 -1
  21. package/dist/parsing/expressions/operator.d.ts +2 -2
  22. package/dist/parsing/expressions/operator.d.ts.map +1 -1
  23. package/dist/parsing/expressions/operator.js +6 -1
  24. package/dist/parsing/expressions/operator.js.map +1 -1
  25. package/dist/parsing/operations/match.d.ts +5 -1
  26. package/dist/parsing/operations/match.d.ts.map +1 -1
  27. package/dist/parsing/operations/match.js +25 -1
  28. package/dist/parsing/operations/match.js.map +1 -1
  29. package/dist/parsing/operations/union.d.ts +36 -0
  30. package/dist/parsing/operations/union.d.ts.map +1 -0
  31. package/dist/parsing/operations/union.js +121 -0
  32. package/dist/parsing/operations/union.js.map +1 -0
  33. package/dist/parsing/operations/union_all.d.ts +10 -0
  34. package/dist/parsing/operations/union_all.d.ts.map +1 -0
  35. package/dist/parsing/operations/union_all.js +17 -0
  36. package/dist/parsing/operations/union_all.js.map +1 -0
  37. package/dist/parsing/parser.d.ts +2 -3
  38. package/dist/parsing/parser.d.ts.map +1 -1
  39. package/dist/parsing/parser.js +72 -24
  40. package/dist/parsing/parser.js.map +1 -1
  41. package/dist/parsing/parser_state.d.ts +13 -0
  42. package/dist/parsing/parser_state.d.ts.map +1 -0
  43. package/dist/parsing/parser_state.js +27 -0
  44. package/dist/parsing/parser_state.js.map +1 -0
  45. package/dist/tokenization/keyword.d.ts +4 -1
  46. package/dist/tokenization/keyword.d.ts.map +1 -1
  47. package/dist/tokenization/keyword.js +3 -0
  48. package/dist/tokenization/keyword.js.map +1 -1
  49. package/dist/tokenization/token.d.ts +6 -0
  50. package/dist/tokenization/token.d.ts.map +1 -1
  51. package/dist/tokenization/token.js +18 -0
  52. package/dist/tokenization/token.js.map +1 -1
  53. package/docs/flowquery.min.js +1 -1
  54. package/flowquery-py/pyproject.toml +1 -1
  55. package/flowquery-py/src/__init__.py +2 -0
  56. package/flowquery-py/src/compute/__init__.py +2 -1
  57. package/flowquery-py/src/compute/flowquery.py +68 -0
  58. package/flowquery-py/src/graph/node.py +1 -1
  59. package/flowquery-py/src/parsing/operations/__init__.py +4 -0
  60. package/flowquery-py/src/parsing/operations/match.py +24 -2
  61. package/flowquery-py/src/parsing/operations/union.py +115 -0
  62. package/flowquery-py/src/parsing/operations/union_all.py +17 -0
  63. package/flowquery-py/src/parsing/parser.py +68 -24
  64. package/flowquery-py/src/parsing/parser_state.py +26 -0
  65. package/flowquery-py/src/tokenization/keyword.py +3 -0
  66. package/flowquery-py/src/tokenization/token.py +21 -0
  67. package/flowquery-py/tests/compute/test_runner.py +542 -1
  68. package/flowquery-py/tests/parsing/test_parser.py +82 -0
  69. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  70. package/package.json +1 -1
  71. package/src/compute/flowquery.ts +46 -0
  72. package/src/compute/runner.ts +0 -24
  73. package/src/index.browser.ts +17 -14
  74. package/src/index.node.ts +21 -18
  75. package/src/parsing/context.ts +6 -0
  76. package/src/parsing/expressions/operator.ts +8 -3
  77. package/src/parsing/operations/match.ts +24 -1
  78. package/src/parsing/operations/union.ts +114 -0
  79. package/src/parsing/operations/union_all.ts +16 -0
  80. package/src/parsing/parser.ts +74 -23
  81. package/src/parsing/parser_state.ts +25 -0
  82. package/src/tokenization/keyword.ts +3 -0
  83. package/src/tokenization/token.ts +24 -0
  84. package/tests/compute/runner.test.ts +467 -0
  85. package/tests/parsing/parser.test.ts +76 -0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.22"
3
+ version = "1.0.23"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -6,6 +6,7 @@ This is the Python implementation of FlowQuery.
6
6
  This module provides the core components for defining, parsing, and executing FlowQuery queries.
7
7
  """
8
8
 
9
+ from .compute.flowquery import FlowQuery
9
10
  from .compute.runner import Runner
10
11
  from .io.command_line import CommandLine
11
12
  from .parsing.functions.aggregate_function import AggregateFunction
@@ -21,6 +22,7 @@ from .parsing.functions.reducer_element import ReducerElement
21
22
  from .parsing.parser import Parser
22
23
 
23
24
  __all__ = [
25
+ "FlowQuery",
24
26
  "Runner",
25
27
  "CommandLine",
26
28
  "Parser",
@@ -1,5 +1,6 @@
1
1
  """Compute module for FlowQuery."""
2
2
 
3
+ from .flowquery import FlowQuery
3
4
  from .runner import Runner
4
5
 
5
- __all__ = ["Runner"]
6
+ __all__ = ["FlowQuery", "Runner"]
@@ -0,0 +1,68 @@
1
+ """FlowQuery public API surface.
2
+
3
+ Extends Runner with extensibility features such as function listing
4
+ and plugin registration, keeping the Runner focused on execution.
5
+ """
6
+
7
+ from typing import List, Optional, Type
8
+
9
+ from ..parsing.functions.function import Function
10
+ from ..parsing.functions.function_factory import FunctionFactory
11
+ from ..parsing.functions.function_metadata import (
12
+ FunctionMetadata,
13
+ get_function_metadata,
14
+ )
15
+ from .runner import Runner
16
+
17
+
18
+ class FlowQuery(Runner):
19
+ """FlowQuery is the public API surface for the FlowQuery library.
20
+
21
+ It extends Runner with convenience class methods for function
22
+ introspection and plugin registration.
23
+
24
+ Example:
25
+ fq = FlowQuery("WITH 1 as x RETURN x")
26
+ await fq.run()
27
+ print(fq.results) # [{'x': 1}]
28
+
29
+ # List all registered functions
30
+ functions = FlowQuery.list_functions()
31
+ """
32
+
33
+ #: Base Function class for creating custom plugin functions.
34
+ Function: Type[Function] = Function
35
+
36
+ @staticmethod
37
+ def list_functions(
38
+ category: Optional[str] = None,
39
+ async_only: bool = False,
40
+ sync_only: bool = False,
41
+ ) -> List[FunctionMetadata]:
42
+ """List all registered functions with their metadata.
43
+
44
+ Args:
45
+ category: Optional category filter
46
+ async_only: If True, return only async functions
47
+ sync_only: If True, return only sync functions
48
+
49
+ Returns:
50
+ List of function metadata
51
+ """
52
+ return FunctionFactory.list_functions(
53
+ category=category,
54
+ async_only=async_only,
55
+ sync_only=sync_only,
56
+ )
57
+
58
+ @staticmethod
59
+ def get_function_metadata(name: str) -> Optional[FunctionMetadata]:
60
+ """Get metadata for a specific function.
61
+
62
+ Args:
63
+ name: The function name
64
+
65
+ Returns:
66
+ Function metadata or None
67
+ """
68
+ return get_function_metadata(name.lower())
@@ -75,7 +75,7 @@ class Node(ASTNode):
75
75
  return bool(record[key] == expression.value())
76
76
  return True
77
77
 
78
- def set_value(self, value: Dict[str, Any]) -> None:
78
+ def set_value(self, value: Optional[Dict[str, Any]]) -> None:
79
79
  self._value = value # type: ignore[assignment]
80
80
 
81
81
  def value(self) -> Optional['NodeRecord']:
@@ -12,6 +12,8 @@ from .match import Match
12
12
  from .operation import Operation
13
13
  from .projection import Projection
14
14
  from .return_op import Return
15
+ from .union import Union
16
+ from .union_all import UnionAll
15
17
  from .unwind import Unwind
16
18
  from .where import Where
17
19
  from .with_op import With
@@ -32,4 +34,6 @@ __all__ = [
32
34
  "Match",
33
35
  "CreateNode",
34
36
  "CreateRelationship",
37
+ "Union",
38
+ "UnionAll",
35
39
  ]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from typing import List, Optional
4
4
 
5
+ from ...graph.node import Node
5
6
  from ...graph.pattern import Pattern
6
7
  from ...graph.patterns import Patterns
7
8
  from .operation import Operation
@@ -10,21 +11,42 @@ from .operation import Operation
10
11
  class Match(Operation):
11
12
  """Represents a MATCH operation for graph pattern matching."""
12
13
 
13
- def __init__(self, patterns: Optional[List[Pattern]] = None) -> None:
14
+ def __init__(self, patterns: Optional[List[Pattern]] = None, optional: bool = False) -> None:
14
15
  super().__init__()
15
16
  self._patterns = Patterns(patterns or [])
17
+ self._optional = optional
16
18
 
17
19
  @property
18
20
  def patterns(self) -> List[Pattern]:
19
21
  return self._patterns.patterns if self._patterns else []
20
22
 
23
+ @property
24
+ def optional(self) -> bool:
25
+ return self._optional
26
+
27
+ def __str__(self) -> str:
28
+ return "OptionalMatch" if self._optional else "Match"
29
+
21
30
  async def run(self) -> None:
22
- """Executes the match operation by chaining the patterns together."""
31
+ """Executes the match operation by chaining the patterns together.
32
+ If optional and no match is found, continues with null values."""
23
33
  await self._patterns.initialize()
34
+ matched = False
24
35
 
25
36
  async def to_do_next() -> None:
37
+ nonlocal matched
38
+ matched = True
26
39
  if self.next:
27
40
  await self.next.run()
28
41
 
29
42
  self._patterns.to_do_next = to_do_next
30
43
  await self._patterns.traverse()
44
+
45
+ # For OPTIONAL MATCH: if nothing matched, continue with None values
46
+ if not matched and self._optional:
47
+ for pattern in self._patterns.patterns:
48
+ for element in pattern.chain:
49
+ if isinstance(element, Node):
50
+ element.set_value(None)
51
+ if self.next:
52
+ await self.next.run()
@@ -0,0 +1,115 @@
1
+ """Represents a UNION operation that combines results from two sub-queries."""
2
+
3
+ import json
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .operation import Operation
7
+
8
+
9
+ class Union(Operation):
10
+ """Represents a UNION operation that combines results from two sub-queries.
11
+
12
+ UNION merges the results of a left and right query pipeline, removing
13
+ duplicate rows. Both sides must return the same column names.
14
+
15
+ Example:
16
+ WITH 1 AS x RETURN x
17
+ UNION
18
+ WITH 2 AS x RETURN x
19
+ # Results: [{x: 1}, {x: 2}]
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ super().__init__()
24
+ self._left: Optional[Operation] = None
25
+ self._right: Optional[Operation] = None
26
+ self._results: List[Dict[str, Any]] = []
27
+
28
+ @property
29
+ def left(self) -> Operation:
30
+ if self._left is None:
31
+ raise ValueError("Left operation is not set")
32
+ return self._left
33
+
34
+ @left.setter
35
+ def left(self, operation: Operation) -> None:
36
+ self._left = operation
37
+
38
+ @property
39
+ def right(self) -> Operation:
40
+ if self._right is None:
41
+ raise ValueError("Right operation is not set")
42
+ return self._right
43
+
44
+ @right.setter
45
+ def right(self, operation: Operation) -> None:
46
+ self._right = operation
47
+
48
+ @staticmethod
49
+ def _last_in_chain(operation: Operation) -> Operation:
50
+ current = operation
51
+ while current.next is not None:
52
+ current = current.next
53
+ return current
54
+
55
+ async def initialize(self) -> None:
56
+ self._results = []
57
+ if self.next:
58
+ await self.next.initialize()
59
+
60
+ async def run(self) -> None:
61
+ # Execute left pipeline
62
+ assert self._left is not None
63
+ await self._left.initialize()
64
+ await self._left.run()
65
+ await self._left.finish()
66
+ left_last = self._last_in_chain(self._left)
67
+ left_results: List[Dict[str, Any]] = left_last.results
68
+
69
+ # Execute right pipeline
70
+ assert self._right is not None
71
+ await self._right.initialize()
72
+ await self._right.run()
73
+ await self._right.finish()
74
+ right_last = self._last_in_chain(self._right)
75
+ right_results: List[Dict[str, Any]] = right_last.results
76
+
77
+ # Validate column names match
78
+ if left_results and right_results:
79
+ left_keys = sorted(left_results[0].keys())
80
+ right_keys = sorted(right_results[0].keys())
81
+ if left_keys != right_keys:
82
+ raise ValueError(
83
+ "All sub queries in a UNION must have the same return column names"
84
+ )
85
+
86
+ # Combine results
87
+ self._results = self._combine(left_results, right_results)
88
+
89
+ def _combine(
90
+ self,
91
+ left: List[Dict[str, Any]],
92
+ right: List[Dict[str, Any]],
93
+ ) -> List[Dict[str, Any]]:
94
+ """Combines results from left and right pipelines.
95
+
96
+ UNION removes duplicates; subclass UnionAll overrides to keep all rows.
97
+ """
98
+ combined = list(left)
99
+ for row in right:
100
+ serialized = json.dumps(row, sort_keys=True, default=str)
101
+ is_duplicate = any(
102
+ json.dumps(existing, sort_keys=True, default=str) == serialized
103
+ for existing in combined
104
+ )
105
+ if not is_duplicate:
106
+ combined.append(row)
107
+ return combined
108
+
109
+ async def finish(self) -> None:
110
+ if self.next:
111
+ await self.next.finish()
112
+
113
+ @property
114
+ def results(self) -> List[Dict[str, Any]]:
115
+ return self._results
@@ -0,0 +1,17 @@
1
+ """Represents a UNION ALL operation that concatenates results without deduplication."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ from .union import Union
6
+
7
+
8
+ class UnionAll(Union):
9
+ """Represents a UNION ALL operation that concatenates results from two sub-queries
10
+ without removing duplicates."""
11
+
12
+ def _combine(
13
+ self,
14
+ left: List[Dict[str, Any]],
15
+ right: List[Dict[str, Any]],
16
+ ) -> List[Dict[str, Any]]:
17
+ return list(left) + list(right)
@@ -20,7 +20,6 @@ from .components.from_ import From
20
20
  from .components.headers import Headers
21
21
  from .components.null import Null
22
22
  from .components.post import Post
23
- from .context import Context
24
23
  from .data_structures.associative_array import AssociativeArray
25
24
  from .data_structures.json_array import JSONArray
26
25
  from .data_structures.key_value_pair import KeyValuePair
@@ -63,9 +62,12 @@ from .operations.load import Load
63
62
  from .operations.match import Match
64
63
  from .operations.operation import Operation
65
64
  from .operations.return_op import Return
65
+ from .operations.union import Union
66
+ from .operations.union_all import UnionAll
66
67
  from .operations.unwind import Unwind
67
68
  from .operations.where import Where
68
69
  from .operations.with_op import With
70
+ from .parser_state import ParserState
69
71
 
70
72
 
71
73
  class Parser(BaseParser):
@@ -82,9 +84,7 @@ class Parser(BaseParser):
82
84
 
83
85
  def __init__(self, tokens: Optional[List[Token]] = None):
84
86
  super().__init__(tokens)
85
- self._variables: Dict[str, ASTNode] = {}
86
- self._context = Context()
87
- self._returns = 0
87
+ self._state = ParserState()
88
88
 
89
89
  def parse(self, statement: str) -> ASTNode:
90
90
  """Parses a FlowQuery statement into an Abstract Syntax Tree.
@@ -112,13 +112,17 @@ class Parser(BaseParser):
112
112
  else:
113
113
  self._skip_whitespace_and_comments()
114
114
 
115
+ # UNION separates two query pipelines — break and handle after the loop
116
+ if self.token.is_union():
117
+ break
118
+
115
119
  operation = self._parse_operation()
116
120
  if operation is None and not is_sub_query:
117
121
  raise ValueError("Expected one of WITH, UNWIND, RETURN, LOAD, OR CALL")
118
122
  elif operation is None and is_sub_query:
119
123
  return root
120
124
 
121
- if self._returns > 1:
125
+ if self._state.returns > 1:
122
126
  raise ValueError("Only one RETURN statement is allowed")
123
127
 
124
128
  if isinstance(previous, Call) and not previous.has_yield:
@@ -146,6 +150,26 @@ class Parser(BaseParser):
146
150
 
147
151
  previous = operation
148
152
 
153
+ # Handle UNION: wrap left and right pipelines into a Union node
154
+ if not self.token.is_eof() and self.token.is_union():
155
+ if not isinstance(operation, (Return, Call)):
156
+ raise ValueError(
157
+ "Each side of UNION must end with a RETURN or CALL statement"
158
+ )
159
+ union = self._parse_union()
160
+ assert union is not None
161
+ union.left = root.first_child() # type: ignore[assignment]
162
+ # Save and reset parser state for right-side scope
163
+ state: ParserState = self._state
164
+ self._state = ParserState()
165
+ right_root = self._parse_tokenized(is_sub_query)
166
+ union.right = right_root.first_child() # type: ignore[assignment]
167
+ # Restore parser state
168
+ self._state = state
169
+ new_root = ASTNode()
170
+ new_root.add_child(union)
171
+ return new_root
172
+
149
173
  if not isinstance(operation, (Return, Call, CreateNode, CreateRelationship)):
150
174
  raise ValueError("Last statement must be a RETURN, WHERE, CALL, or CREATE statement")
151
175
 
@@ -199,7 +223,7 @@ class Parser(BaseParser):
199
223
  else:
200
224
  raise ValueError("Expected alias")
201
225
  unwind = Unwind(expression)
202
- self._variables[alias.get_alias()] = unwind
226
+ self._state.variables[alias.get_alias()] = unwind
203
227
  return unwind
204
228
 
205
229
  def _parse_return(self) -> Optional[Return]:
@@ -217,7 +241,7 @@ class Parser(BaseParser):
217
241
  raise ValueError("Expected expression")
218
242
  if distinct or any(expr.has_reducers() for expr in expressions):
219
243
  return AggregatedReturn(expressions)
220
- self._returns += 1
244
+ self._state.increment_returns()
221
245
  return Return(expressions)
222
246
 
223
247
  def _parse_where(self) -> Optional[Where]:
@@ -291,7 +315,7 @@ class Parser(BaseParser):
291
315
  alias = self._parse_alias()
292
316
  if alias is not None:
293
317
  load.add_child(alias)
294
- self._variables[alias.get_alias()] = load
318
+ self._state.variables[alias.get_alias()] = load
295
319
  else:
296
320
  raise ValueError("Expected alias")
297
321
  return load
@@ -318,14 +342,21 @@ class Parser(BaseParser):
318
342
  return call
319
343
 
320
344
  def _parse_match(self) -> Optional[Match]:
345
+ optional = False
346
+ if self.token.is_optional():
347
+ optional = True
348
+ self.set_next_token()
349
+ self._expect_and_skip_whitespace_and_comments()
321
350
  if not self.token.is_match():
351
+ if optional:
352
+ raise ValueError("Expected MATCH after OPTIONAL")
322
353
  return None
323
354
  self.set_next_token()
324
355
  self._expect_and_skip_whitespace_and_comments()
325
356
  patterns = list(self._parse_patterns())
326
357
  if len(patterns) == 0:
327
358
  raise ValueError("Expected graph pattern")
328
- return Match(patterns)
359
+ return Match(patterns, optional)
329
360
 
330
361
  def _parse_create(self) -> Optional[Operation]:
331
362
  """Parse CREATE VIRTUAL statement for nodes and relationships."""
@@ -383,6 +414,19 @@ class Parser(BaseParser):
383
414
  else:
384
415
  return CreateNode(node, query)
385
416
 
417
+ def _parse_union(self) -> Optional[Union]:
418
+ """Parse a UNION or UNION ALL keyword."""
419
+ if not self.token.is_union():
420
+ return None
421
+ self.set_next_token()
422
+ self._skip_whitespace_and_comments()
423
+ if self.token.is_all():
424
+ union: Union = UnionAll()
425
+ self.set_next_token()
426
+ else:
427
+ union = Union()
428
+ return union
429
+
386
430
  def _parse_sub_query(self) -> Optional[ASTNode]:
387
431
  """Parse a sub-query enclosed in braces."""
388
432
  if not self.token.is_opening_brace():
@@ -411,7 +455,7 @@ class Parser(BaseParser):
411
455
  if pattern is not None:
412
456
  if identifier is not None:
413
457
  pattern.identifier = identifier
414
- self._variables[identifier] = pattern
458
+ self._state.variables[identifier] = pattern
415
459
  yield pattern
416
460
  else:
417
461
  break
@@ -491,8 +535,8 @@ class Parser(BaseParser):
491
535
  node = Node()
492
536
  node.label = label
493
537
  node.properties = dict(self._parse_properties())
494
- if identifier is not None and identifier in self._variables:
495
- reference = self._variables.get(identifier)
538
+ if identifier is not None and identifier in self._state.variables:
539
+ reference = self._state.variables.get(identifier)
496
540
  # Resolve through Expression -> Reference -> Node (e.g., after WITH)
497
541
  ref_child = reference.first_child() if isinstance(reference, Expression) else None
498
542
  if isinstance(ref_child, Reference):
@@ -504,7 +548,7 @@ class Parser(BaseParser):
504
548
  node = NodeReference(node, reference)
505
549
  elif identifier is not None:
506
550
  node.identifier = identifier
507
- self._variables[identifier] = node
551
+ self._state.variables[identifier] = node
508
552
  if not self.token.is_right_parenthesis():
509
553
  raise ValueError("Expected closing parenthesis for node definition")
510
554
  self.set_next_token()
@@ -547,8 +591,8 @@ class Parser(BaseParser):
547
591
  relationship = Relationship()
548
592
  relationship.direction = direction
549
593
  relationship.properties = properties
550
- if variable is not None and variable in self._variables:
551
- reference = self._variables.get(variable)
594
+ if variable is not None and variable in self._state.variables:
595
+ reference = self._state.variables.get(variable)
552
596
  # Resolve through Expression -> Reference -> Relationship (e.g., after WITH)
553
597
  first = reference.first_child() if isinstance(reference, Expression) else None
554
598
  if isinstance(first, Reference):
@@ -560,7 +604,7 @@ class Parser(BaseParser):
560
604
  relationship = RelationshipReference(relationship, reference)
561
605
  elif variable is not None:
562
606
  relationship.identifier = variable
563
- self._variables[variable] = relationship
607
+ self._state.variables[variable] = relationship
564
608
  if hops is not None:
565
609
  relationship.hops = hops
566
610
  relationship.type = rel_type
@@ -647,7 +691,7 @@ class Parser(BaseParser):
647
691
  reference = expression.first_child()
648
692
  assert isinstance(reference, Reference) # For type narrowing
649
693
  expression.set_alias(reference.identifier)
650
- self._variables[reference.identifier] = expression
694
+ self._state.variables[reference.identifier] = expression
651
695
  elif (alias_option == AliasOption.REQUIRED and
652
696
  alias is None and
653
697
  not isinstance(expression.first_child(), Reference)):
@@ -656,7 +700,7 @@ class Parser(BaseParser):
656
700
  raise ValueError("Alias not allowed")
657
701
  elif alias_option in (AliasOption.OPTIONAL, AliasOption.REQUIRED) and alias is not None:
658
702
  expression.set_alias(alias.get_alias())
659
- self._variables[alias.get_alias()] = expression
703
+ self._state.variables[alias.get_alias()] = expression
660
704
  yield expression
661
705
  else:
662
706
  break
@@ -670,7 +714,7 @@ class Parser(BaseParser):
670
714
  self._skip_whitespace_and_comments()
671
715
  if self.token.is_identifier_or_keyword() and (self.peek() is None or not self.peek().is_left_parenthesis()):
672
716
  identifier = self.token.value or ""
673
- reference = Reference(identifier, self._variables.get(identifier))
717
+ reference = Reference(identifier, self._state.variables.get(identifier))
674
718
  self.set_next_token()
675
719
  lookup = self._parse_lookup(reference)
676
720
  expression.add_node(lookup)
@@ -1018,7 +1062,7 @@ class Parser(BaseParser):
1018
1062
  if not self.token.is_identifier():
1019
1063
  raise ValueError("Expected identifier")
1020
1064
  reference = Reference(self.token.value)
1021
- self._variables[reference.identifier] = reference
1065
+ self._state.variables[reference.identifier] = reference
1022
1066
  func.add_child(reference)
1023
1067
  self.set_next_token()
1024
1068
  self._expect_and_skip_whitespace_and_comments()
@@ -1052,7 +1096,7 @@ class Parser(BaseParser):
1052
1096
  if not self.token.is_right_parenthesis():
1053
1097
  raise ValueError("Expected right parenthesis")
1054
1098
  self.set_next_token()
1055
- del self._variables[reference.identifier]
1099
+ del self._state.variables[reference.identifier]
1056
1100
  return func
1057
1101
 
1058
1102
  def _parse_function(self) -> Optional[Function]:
@@ -1068,10 +1112,10 @@ class Parser(BaseParser):
1068
1112
  raise ValueError(f"Unknown function: {name}")
1069
1113
 
1070
1114
  # Check for nested aggregate functions
1071
- if isinstance(func, AggregateFunction) and self._context.contains_type(AggregateFunction):
1115
+ if isinstance(func, AggregateFunction) and self._state.context.contains_type(AggregateFunction):
1072
1116
  raise ValueError("Aggregate functions cannot be nested")
1073
1117
 
1074
- self._context.push(func)
1118
+ self._state.context.push(func)
1075
1119
  self.set_next_token() # skip function name
1076
1120
  self.set_next_token() # skip left parenthesis
1077
1121
  self._skip_whitespace_and_comments()
@@ -1088,7 +1132,7 @@ class Parser(BaseParser):
1088
1132
  if not self.token.is_right_parenthesis():
1089
1133
  raise ValueError("Expected right parenthesis")
1090
1134
  self.set_next_token()
1091
- self._context.pop()
1135
+ self._state.context.pop()
1092
1136
  return func
1093
1137
 
1094
1138
  def _parse_async_function(self) -> Optional[AsyncFunction]:
@@ -0,0 +1,26 @@
1
+ from typing import Dict
2
+
3
+ from .ast_node import ASTNode
4
+ from .context import Context
5
+
6
+
7
+ class ParserState:
8
+ def __init__(self) -> None:
9
+ self._variables: Dict[str, ASTNode] = {}
10
+ self._context = Context()
11
+ self._returns = 0
12
+
13
+ @property
14
+ def variables(self) -> Dict[str, ASTNode]:
15
+ return self._variables
16
+
17
+ @property
18
+ def context(self) -> Context:
19
+ return self._context
20
+
21
+ @property
22
+ def returns(self) -> int:
23
+ return self._returns
24
+
25
+ def increment_returns(self) -> None:
26
+ self._returns += 1
@@ -8,6 +8,7 @@ class Keyword(Enum):
8
8
 
9
9
  RETURN = "RETURN"
10
10
  MATCH = "MATCH"
11
+ OPTIONAL = "OPTIONAL"
11
12
  WHERE = "WHERE"
12
13
  CREATE = "CREATE"
13
14
  VIRTUAL = "VIRTUAL"
@@ -49,3 +50,5 @@ class Keyword(Enum):
49
50
  CONTAINS = "CONTAINS"
50
51
  STARTS = "STARTS"
51
52
  ENDS = "ENDS"
53
+ UNION = "UNION"
54
+ ALL = "ALL"
@@ -462,6 +462,13 @@ class Token:
462
462
  def is_match(self) -> bool:
463
463
  return self._type == TokenType.KEYWORD and self._value == Keyword.MATCH.value
464
464
 
465
+ @staticmethod
466
+ def OPTIONAL() -> Token:
467
+ return Token(TokenType.KEYWORD, Keyword.OPTIONAL.value)
468
+
469
+ def is_optional(self) -> bool:
470
+ return self._type == TokenType.KEYWORD and self._value == Keyword.OPTIONAL.value
471
+
465
472
  @staticmethod
466
473
  def AS() -> Token:
467
474
  return Token(TokenType.KEYWORD, Keyword.AS.value)
@@ -609,6 +616,20 @@ class Token:
609
616
  def is_limit(self) -> bool:
610
617
  return self._type == TokenType.KEYWORD and self._value == Keyword.LIMIT.value
611
618
 
619
+ @staticmethod
620
+ def UNION() -> Token:
621
+ return Token(TokenType.KEYWORD, Keyword.UNION.value)
622
+
623
+ def is_union(self) -> bool:
624
+ return self._type == TokenType.KEYWORD and self._value == Keyword.UNION.value
625
+
626
+ @staticmethod
627
+ def ALL() -> Token:
628
+ return Token(TokenType.KEYWORD, Keyword.ALL.value)
629
+
630
+ def is_all(self) -> bool:
631
+ return self._type == TokenType.KEYWORD and self._value == Keyword.ALL.value
632
+
612
633
  # End of file token
613
634
 
614
635
  @staticmethod