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.
- package/dist/compute/flowquery.d.ts +43 -0
- package/dist/compute/flowquery.d.ts.map +1 -0
- package/dist/compute/flowquery.js +30 -0
- package/dist/compute/flowquery.js.map +1 -0
- package/dist/compute/runner.d.ts +0 -21
- package/dist/compute/runner.d.ts.map +1 -1
- package/dist/compute/runner.js.map +1 -1
- package/dist/flowquery.min.js +1 -1
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.d.ts.map +1 -1
- package/dist/index.browser.js +10 -10
- package/dist/index.browser.js.map +1 -1
- package/dist/index.node.d.ts +4 -4
- package/dist/index.node.d.ts.map +1 -1
- package/dist/index.node.js +13 -13
- package/dist/index.node.js.map +1 -1
- package/dist/parsing/context.d.ts +1 -0
- package/dist/parsing/context.d.ts.map +1 -1
- package/dist/parsing/context.js +5 -0
- package/dist/parsing/context.js.map +1 -1
- package/dist/parsing/expressions/operator.d.ts +2 -2
- package/dist/parsing/expressions/operator.d.ts.map +1 -1
- package/dist/parsing/expressions/operator.js +6 -1
- package/dist/parsing/expressions/operator.js.map +1 -1
- package/dist/parsing/operations/match.d.ts +5 -1
- package/dist/parsing/operations/match.d.ts.map +1 -1
- package/dist/parsing/operations/match.js +25 -1
- package/dist/parsing/operations/match.js.map +1 -1
- package/dist/parsing/operations/union.d.ts +36 -0
- package/dist/parsing/operations/union.d.ts.map +1 -0
- package/dist/parsing/operations/union.js +121 -0
- package/dist/parsing/operations/union.js.map +1 -0
- package/dist/parsing/operations/union_all.d.ts +10 -0
- package/dist/parsing/operations/union_all.d.ts.map +1 -0
- package/dist/parsing/operations/union_all.js +17 -0
- package/dist/parsing/operations/union_all.js.map +1 -0
- package/dist/parsing/parser.d.ts +2 -3
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +72 -24
- package/dist/parsing/parser.js.map +1 -1
- package/dist/parsing/parser_state.d.ts +13 -0
- package/dist/parsing/parser_state.d.ts.map +1 -0
- package/dist/parsing/parser_state.js +27 -0
- package/dist/parsing/parser_state.js.map +1 -0
- package/dist/tokenization/keyword.d.ts +4 -1
- package/dist/tokenization/keyword.d.ts.map +1 -1
- package/dist/tokenization/keyword.js +3 -0
- package/dist/tokenization/keyword.js.map +1 -1
- package/dist/tokenization/token.d.ts +6 -0
- package/dist/tokenization/token.d.ts.map +1 -1
- package/dist/tokenization/token.js +18 -0
- package/dist/tokenization/token.js.map +1 -1
- package/docs/flowquery.min.js +1 -1
- package/flowquery-py/pyproject.toml +1 -1
- package/flowquery-py/src/__init__.py +2 -0
- package/flowquery-py/src/compute/__init__.py +2 -1
- package/flowquery-py/src/compute/flowquery.py +68 -0
- package/flowquery-py/src/graph/node.py +1 -1
- package/flowquery-py/src/parsing/operations/__init__.py +4 -0
- package/flowquery-py/src/parsing/operations/match.py +24 -2
- package/flowquery-py/src/parsing/operations/union.py +115 -0
- package/flowquery-py/src/parsing/operations/union_all.py +17 -0
- package/flowquery-py/src/parsing/parser.py +68 -24
- package/flowquery-py/src/parsing/parser_state.py +26 -0
- package/flowquery-py/src/tokenization/keyword.py +3 -0
- package/flowquery-py/src/tokenization/token.py +21 -0
- package/flowquery-py/tests/compute/test_runner.py +542 -1
- package/flowquery-py/tests/parsing/test_parser.py +82 -0
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/compute/flowquery.ts +46 -0
- package/src/compute/runner.ts +0 -24
- package/src/index.browser.ts +17 -14
- package/src/index.node.ts +21 -18
- package/src/parsing/context.ts +6 -0
- package/src/parsing/expressions/operator.ts +8 -3
- package/src/parsing/operations/match.ts +24 -1
- package/src/parsing/operations/union.ts +114 -0
- package/src/parsing/operations/union_all.ts +16 -0
- package/src/parsing/parser.ts +74 -23
- package/src/parsing/parser_state.ts +25 -0
- package/src/tokenization/keyword.ts +3 -0
- package/src/tokenization/token.ts +24 -0
- package/tests/compute/runner.test.ts +467 -0
- package/tests/parsing/parser.test.ts +76 -0
|
@@ -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",
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
495
|
-
reference = self.
|
|
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.
|
|
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.
|
|
551
|
-
reference = self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|