flowquery 1.0.37 → 1.0.39
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/flowquery.min.js +1 -1
- package/dist/parsing/expressions/operator.js +4 -4
- package/dist/parsing/expressions/operator.js.map +1 -1
- package/dist/parsing/operations/aggregated_return.d.ts.map +1 -1
- package/dist/parsing/operations/aggregated_return.js +6 -2
- package/dist/parsing/operations/aggregated_return.js.map +1 -1
- package/dist/parsing/operations/group_by.d.ts.map +1 -1
- package/dist/parsing/operations/group_by.js +3 -2
- package/dist/parsing/operations/group_by.js.map +1 -1
- package/dist/parsing/operations/limit.d.ts +3 -0
- package/dist/parsing/operations/limit.d.ts.map +1 -1
- package/dist/parsing/operations/limit.js +9 -0
- package/dist/parsing/operations/limit.js.map +1 -1
- package/dist/parsing/operations/order_by.d.ts +35 -0
- package/dist/parsing/operations/order_by.d.ts.map +1 -0
- package/dist/parsing/operations/order_by.js +87 -0
- package/dist/parsing/operations/order_by.js.map +1 -0
- package/dist/parsing/operations/return.d.ts +6 -0
- package/dist/parsing/operations/return.d.ts.map +1 -1
- package/dist/parsing/operations/return.js +24 -1
- package/dist/parsing/operations/return.js.map +1 -1
- package/dist/parsing/parser.d.ts +1 -0
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +67 -10
- package/dist/parsing/parser.js.map +1 -1
- package/dist/tokenization/token.d.ts +8 -0
- package/dist/tokenization/token.d.ts.map +1 -1
- package/dist/tokenization/token.js +24 -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/parsing/expressions/operator.py +4 -4
- package/flowquery-py/src/parsing/operations/__init__.py +3 -0
- package/flowquery-py/src/parsing/operations/aggregated_return.py +4 -1
- package/flowquery-py/src/parsing/operations/limit.py +11 -0
- package/flowquery-py/src/parsing/operations/order_by.py +72 -0
- package/flowquery-py/src/parsing/operations/return_op.py +32 -1
- package/flowquery-py/src/parsing/parser.py +57 -9
- package/flowquery-py/src/tokenization/token.py +28 -0
- package/flowquery-py/tests/compute/test_runner.py +238 -1
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/parsing/expressions/operator.ts +4 -4
- package/src/parsing/operations/aggregated_return.ts +9 -5
- package/src/parsing/operations/group_by.ts +4 -2
- package/src/parsing/operations/limit.ts +10 -1
- package/src/parsing/operations/order_by.ts +75 -0
- package/src/parsing/operations/return.ts +26 -1
- package/src/parsing/parser.ts +64 -10
- package/src/tokenization/token.ts +32 -0
- package/tests/compute/runner.test.ts +211 -0
|
@@ -163,7 +163,7 @@ class Not(Operator):
|
|
|
163
163
|
|
|
164
164
|
class Is(Operator):
|
|
165
165
|
def __init__(self) -> None:
|
|
166
|
-
super().__init__(
|
|
166
|
+
super().__init__(0, True)
|
|
167
167
|
|
|
168
168
|
def value(self) -> int:
|
|
169
169
|
return 1 if self.lhs.value() == self.rhs.value() else 0
|
|
@@ -171,7 +171,7 @@ class Is(Operator):
|
|
|
171
171
|
|
|
172
172
|
class IsNot(Operator):
|
|
173
173
|
def __init__(self) -> None:
|
|
174
|
-
super().__init__(
|
|
174
|
+
super().__init__(0, True)
|
|
175
175
|
|
|
176
176
|
def value(self) -> int:
|
|
177
177
|
return 1 if self.lhs.value() != self.rhs.value() else 0
|
|
@@ -179,7 +179,7 @@ class IsNot(Operator):
|
|
|
179
179
|
|
|
180
180
|
class In(Operator):
|
|
181
181
|
def __init__(self) -> None:
|
|
182
|
-
super().__init__(
|
|
182
|
+
super().__init__(0, True)
|
|
183
183
|
|
|
184
184
|
def value(self) -> int:
|
|
185
185
|
lst = self.rhs.value()
|
|
@@ -190,7 +190,7 @@ class In(Operator):
|
|
|
190
190
|
|
|
191
191
|
class NotIn(Operator):
|
|
192
192
|
def __init__(self) -> None:
|
|
193
|
-
super().__init__(
|
|
193
|
+
super().__init__(0, True)
|
|
194
194
|
|
|
195
195
|
def value(self) -> int:
|
|
196
196
|
lst = self.rhs.value()
|
|
@@ -10,6 +10,7 @@ from .limit import Limit
|
|
|
10
10
|
from .load import Load
|
|
11
11
|
from .match import Match
|
|
12
12
|
from .operation import Operation
|
|
13
|
+
from .order_by import OrderBy, SortField
|
|
13
14
|
from .projection import Projection
|
|
14
15
|
from .return_op import Return
|
|
15
16
|
from .union import Union
|
|
@@ -36,4 +37,6 @@ __all__ = [
|
|
|
36
37
|
"CreateRelationship",
|
|
37
38
|
"Union",
|
|
38
39
|
"UnionAll",
|
|
40
|
+
"OrderBy",
|
|
41
|
+
"SortField",
|
|
39
42
|
]
|
|
@@ -19,4 +19,7 @@ class AggregatedReturn(Return):
|
|
|
19
19
|
def results(self) -> List[Dict[str, Any]]:
|
|
20
20
|
if self._where is not None:
|
|
21
21
|
self._group_by.where = self._where
|
|
22
|
-
|
|
22
|
+
results = list(self._group_by.generate_results())
|
|
23
|
+
if self._order_by is not None:
|
|
24
|
+
results = self._order_by.sort(results)
|
|
25
|
+
return results
|
|
@@ -11,6 +11,17 @@ class Limit(Operation):
|
|
|
11
11
|
self._count = 0
|
|
12
12
|
self._limit = limit
|
|
13
13
|
|
|
14
|
+
@property
|
|
15
|
+
def is_limit_reached(self) -> bool:
|
|
16
|
+
return self._count >= self._limit
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def limit_value(self) -> int:
|
|
20
|
+
return self._limit
|
|
21
|
+
|
|
22
|
+
def increment(self) -> None:
|
|
23
|
+
self._count += 1
|
|
24
|
+
|
|
14
25
|
async def run(self) -> None:
|
|
15
26
|
if self._count >= self._limit:
|
|
16
27
|
return
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Represents an ORDER BY operation that sorts results."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from .operation import Operation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SortField:
|
|
9
|
+
"""A single sort specification: field name and direction."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, field: str, direction: str = "asc"):
|
|
12
|
+
self.field = field
|
|
13
|
+
self.direction = direction
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OrderBy(Operation):
|
|
17
|
+
"""Represents an ORDER BY operation that sorts results.
|
|
18
|
+
|
|
19
|
+
Can be attached to a RETURN operation (sorting its results),
|
|
20
|
+
or used as a standalone accumulating operation after a non-aggregate WITH.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
RETURN x ORDER BY x DESC
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, fields: List[SortField]):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._fields = fields
|
|
29
|
+
self._results: List[Dict[str, Any]] = []
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def fields(self) -> List[SortField]:
|
|
33
|
+
return self._fields
|
|
34
|
+
|
|
35
|
+
def sort(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
36
|
+
"""Sorts an array of records according to the sort fields."""
|
|
37
|
+
import functools
|
|
38
|
+
|
|
39
|
+
def compare(a: Dict[str, Any], b: Dict[str, Any]) -> int:
|
|
40
|
+
for sf in self._fields:
|
|
41
|
+
a_val = a.get(sf.field)
|
|
42
|
+
b_val = b.get(sf.field)
|
|
43
|
+
cmp = 0
|
|
44
|
+
if a_val is None and b_val is None:
|
|
45
|
+
cmp = 0
|
|
46
|
+
elif a_val is None:
|
|
47
|
+
cmp = -1
|
|
48
|
+
elif b_val is None:
|
|
49
|
+
cmp = 1
|
|
50
|
+
elif a_val < b_val:
|
|
51
|
+
cmp = -1
|
|
52
|
+
elif a_val > b_val:
|
|
53
|
+
cmp = 1
|
|
54
|
+
if cmp != 0:
|
|
55
|
+
return -cmp if sf.direction == "desc" else cmp
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
return sorted(records, key=functools.cmp_to_key(compare))
|
|
59
|
+
|
|
60
|
+
async def run(self) -> None:
|
|
61
|
+
"""When used as a standalone operation, passes through to next."""
|
|
62
|
+
if self.next:
|
|
63
|
+
await self.next.run()
|
|
64
|
+
|
|
65
|
+
async def initialize(self) -> None:
|
|
66
|
+
self._results = []
|
|
67
|
+
if self.next:
|
|
68
|
+
await self.next.initialize()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def results(self) -> List[Dict[str, Any]]:
|
|
72
|
+
return self._results
|
|
@@ -4,6 +4,8 @@ import copy
|
|
|
4
4
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
5
5
|
|
|
6
6
|
from ..ast_node import ASTNode
|
|
7
|
+
from .limit import Limit
|
|
8
|
+
from .order_by import OrderBy
|
|
7
9
|
from .projection import Projection
|
|
8
10
|
|
|
9
11
|
if TYPE_CHECKING:
|
|
@@ -24,6 +26,8 @@ class Return(Projection):
|
|
|
24
26
|
super().__init__(expressions)
|
|
25
27
|
self._where: Optional['Where'] = None
|
|
26
28
|
self._results: List[Dict[str, Any]] = []
|
|
29
|
+
self._limit: Optional[Limit] = None
|
|
30
|
+
self._order_by: Optional[OrderBy] = None
|
|
27
31
|
|
|
28
32
|
@property
|
|
29
33
|
def where(self) -> Any:
|
|
@@ -35,9 +39,29 @@ class Return(Projection):
|
|
|
35
39
|
def where(self, where: 'Where') -> None:
|
|
36
40
|
self._where = where
|
|
37
41
|
|
|
42
|
+
@property
|
|
43
|
+
def limit(self) -> Optional[Limit]:
|
|
44
|
+
return self._limit
|
|
45
|
+
|
|
46
|
+
@limit.setter
|
|
47
|
+
def limit(self, limit: Limit) -> None:
|
|
48
|
+
self._limit = limit
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def order_by(self) -> Optional[OrderBy]:
|
|
52
|
+
return self._order_by
|
|
53
|
+
|
|
54
|
+
@order_by.setter
|
|
55
|
+
def order_by(self, order_by: OrderBy) -> None:
|
|
56
|
+
self._order_by = order_by
|
|
57
|
+
|
|
38
58
|
async def run(self) -> None:
|
|
39
59
|
if not self.where:
|
|
40
60
|
return
|
|
61
|
+
# When ORDER BY is present, skip limit during accumulation;
|
|
62
|
+
# limit will be applied after sorting in results property
|
|
63
|
+
if self._order_by is None and self._limit is not None and self._limit.is_limit_reached:
|
|
64
|
+
return
|
|
41
65
|
record: Dict[str, Any] = {}
|
|
42
66
|
for expression, alias in self.expressions():
|
|
43
67
|
raw = expression.value()
|
|
@@ -45,10 +69,17 @@ class Return(Projection):
|
|
|
45
69
|
value = copy.deepcopy(raw) if isinstance(raw, (dict, list)) else raw
|
|
46
70
|
record[alias] = value
|
|
47
71
|
self._results.append(record)
|
|
72
|
+
if self._order_by is None and self._limit is not None:
|
|
73
|
+
self._limit.increment()
|
|
48
74
|
|
|
49
75
|
async def initialize(self) -> None:
|
|
50
76
|
self._results = []
|
|
51
77
|
|
|
52
78
|
@property
|
|
53
79
|
def results(self) -> List[Dict[str, Any]]:
|
|
54
|
-
|
|
80
|
+
result = self._results
|
|
81
|
+
if self._order_by is not None:
|
|
82
|
+
result = self._order_by.sort(result)
|
|
83
|
+
if self._order_by is not None and self._limit is not None:
|
|
84
|
+
result = result[:self._limit.limit_value]
|
|
85
|
+
return result
|
|
@@ -61,6 +61,7 @@ from .operations.limit import Limit
|
|
|
61
61
|
from .operations.load import Load
|
|
62
62
|
from .operations.match import Match
|
|
63
63
|
from .operations.operation import Operation
|
|
64
|
+
from .operations.order_by import OrderBy, SortField
|
|
64
65
|
from .operations.return_op import Return
|
|
65
66
|
from .operations.union import Union
|
|
66
67
|
from .operations.union_all import UnionAll
|
|
@@ -116,6 +117,9 @@ class Parser(BaseParser):
|
|
|
116
117
|
if self.token.is_union():
|
|
117
118
|
break
|
|
118
119
|
|
|
120
|
+
if self.token.is_eof():
|
|
121
|
+
break
|
|
122
|
+
|
|
119
123
|
operation = self._parse_operation()
|
|
120
124
|
if operation is None and not is_sub_query:
|
|
121
125
|
raise ValueError("Expected one of WITH, UNWIND, RETURN, LOAD, OR CALL")
|
|
@@ -143,10 +147,21 @@ class Parser(BaseParser):
|
|
|
143
147
|
operation.add_sibling(where)
|
|
144
148
|
operation = where
|
|
145
149
|
|
|
150
|
+
order_by = self._parse_order_by()
|
|
151
|
+
if order_by is not None:
|
|
152
|
+
if isinstance(operation, Return):
|
|
153
|
+
operation.order_by = order_by
|
|
154
|
+
else:
|
|
155
|
+
operation.add_sibling(order_by)
|
|
156
|
+
operation = order_by
|
|
157
|
+
|
|
146
158
|
limit = self._parse_limit()
|
|
147
159
|
if limit is not None:
|
|
148
|
-
operation
|
|
149
|
-
|
|
160
|
+
if isinstance(operation, Return):
|
|
161
|
+
operation.limit = limit
|
|
162
|
+
else:
|
|
163
|
+
operation.add_sibling(limit)
|
|
164
|
+
operation = limit
|
|
150
165
|
|
|
151
166
|
previous = operation
|
|
152
167
|
|
|
@@ -539,13 +554,11 @@ class Parser(BaseParser):
|
|
|
539
554
|
node.properties = dict(self._parse_properties())
|
|
540
555
|
if identifier is not None and identifier in self._state.variables:
|
|
541
556
|
reference = self._state.variables.get(identifier)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
reference = inner
|
|
548
|
-
if reference is None or (not isinstance(reference, Node) and not isinstance(reference, Unwind)):
|
|
557
|
+
if reference is None or (
|
|
558
|
+
not isinstance(reference, Node)
|
|
559
|
+
and not isinstance(reference, Unwind)
|
|
560
|
+
and not isinstance(reference, Expression)
|
|
561
|
+
):
|
|
549
562
|
raise ValueError(f"Undefined node reference: {identifier}")
|
|
550
563
|
node = NodeReference(node, reference)
|
|
551
564
|
elif identifier is not None:
|
|
@@ -690,6 +703,41 @@ class Parser(BaseParser):
|
|
|
690
703
|
self.set_next_token()
|
|
691
704
|
return limit
|
|
692
705
|
|
|
706
|
+
def _parse_order_by(self) -> Optional[OrderBy]:
|
|
707
|
+
self._skip_whitespace_and_comments()
|
|
708
|
+
if not self.token.is_order():
|
|
709
|
+
return None
|
|
710
|
+
self._expect_previous_token_to_be_whitespace_or_comment()
|
|
711
|
+
self.set_next_token()
|
|
712
|
+
self._expect_and_skip_whitespace_and_comments()
|
|
713
|
+
if not self.token.is_by():
|
|
714
|
+
raise ValueError("Expected BY after ORDER")
|
|
715
|
+
self.set_next_token()
|
|
716
|
+
self._expect_and_skip_whitespace_and_comments()
|
|
717
|
+
fields: list[SortField] = []
|
|
718
|
+
while True:
|
|
719
|
+
if not self.token.is_identifier_or_keyword():
|
|
720
|
+
raise ValueError("Expected field name in ORDER BY")
|
|
721
|
+
field = self.token.value
|
|
722
|
+
self.set_next_token()
|
|
723
|
+
self._skip_whitespace_and_comments()
|
|
724
|
+
direction = "asc"
|
|
725
|
+
if self.token.is_asc():
|
|
726
|
+
direction = "asc"
|
|
727
|
+
self.set_next_token()
|
|
728
|
+
self._skip_whitespace_and_comments()
|
|
729
|
+
elif self.token.is_desc():
|
|
730
|
+
direction = "desc"
|
|
731
|
+
self.set_next_token()
|
|
732
|
+
self._skip_whitespace_and_comments()
|
|
733
|
+
fields.append(SortField(field, direction))
|
|
734
|
+
if self.token.is_comma():
|
|
735
|
+
self.set_next_token()
|
|
736
|
+
self._skip_whitespace_and_comments()
|
|
737
|
+
else:
|
|
738
|
+
break
|
|
739
|
+
return OrderBy(fields)
|
|
740
|
+
|
|
693
741
|
def _parse_expressions(
|
|
694
742
|
self, alias_option: AliasOption = AliasOption.NOT_ALLOWED
|
|
695
743
|
) -> Iterator[Expression]:
|
|
@@ -630,6 +630,34 @@ class Token:
|
|
|
630
630
|
def is_all(self) -> bool:
|
|
631
631
|
return self._type == TokenType.KEYWORD and self._value == Keyword.ALL.value
|
|
632
632
|
|
|
633
|
+
@staticmethod
|
|
634
|
+
def ORDER() -> Token:
|
|
635
|
+
return Token(TokenType.KEYWORD, Keyword.ORDER.value)
|
|
636
|
+
|
|
637
|
+
def is_order(self) -> bool:
|
|
638
|
+
return self._type == TokenType.KEYWORD and self._value == Keyword.ORDER.value
|
|
639
|
+
|
|
640
|
+
@staticmethod
|
|
641
|
+
def BY() -> Token:
|
|
642
|
+
return Token(TokenType.KEYWORD, Keyword.BY.value)
|
|
643
|
+
|
|
644
|
+
def is_by(self) -> bool:
|
|
645
|
+
return self._type == TokenType.KEYWORD and self._value == Keyword.BY.value
|
|
646
|
+
|
|
647
|
+
@staticmethod
|
|
648
|
+
def ASC() -> Token:
|
|
649
|
+
return Token(TokenType.KEYWORD, Keyword.ASC.value)
|
|
650
|
+
|
|
651
|
+
def is_asc(self) -> bool:
|
|
652
|
+
return self._type == TokenType.KEYWORD and self._value == Keyword.ASC.value
|
|
653
|
+
|
|
654
|
+
@staticmethod
|
|
655
|
+
def DESC() -> Token:
|
|
656
|
+
return Token(TokenType.KEYWORD, Keyword.DESC.value)
|
|
657
|
+
|
|
658
|
+
def is_desc(self) -> bool:
|
|
659
|
+
return self._type == TokenType.KEYWORD and self._value == Keyword.DESC.value
|
|
660
|
+
|
|
633
661
|
# End of file token
|
|
634
662
|
|
|
635
663
|
@staticmethod
|
|
@@ -925,6 +925,20 @@ class TestRunner:
|
|
|
925
925
|
results = runner.results
|
|
926
926
|
assert len(results) == 50
|
|
927
927
|
|
|
928
|
+
@pytest.mark.asyncio
|
|
929
|
+
async def test_limit_as_last_operation(self):
|
|
930
|
+
"""Test limit as the last operation after return."""
|
|
931
|
+
runner = Runner(
|
|
932
|
+
"""
|
|
933
|
+
unwind range(1, 10) as i
|
|
934
|
+
return i
|
|
935
|
+
limit 5
|
|
936
|
+
"""
|
|
937
|
+
)
|
|
938
|
+
await runner.run()
|
|
939
|
+
results = runner.results
|
|
940
|
+
assert len(results) == 5
|
|
941
|
+
|
|
928
942
|
@pytest.mark.asyncio
|
|
929
943
|
async def test_range_lookup(self):
|
|
930
944
|
"""Test range lookup."""
|
|
@@ -1457,6 +1471,71 @@ class TestRunner:
|
|
|
1457
1471
|
assert results[0] == {"name1": "Person 1", "name2": "Person 2", "name3": "Person 3"}
|
|
1458
1472
|
assert results[1] == {"name1": "Person 2", "name2": "Person 3", "name3": "Person 4"}
|
|
1459
1473
|
|
|
1474
|
+
@pytest.mark.asyncio
|
|
1475
|
+
async def test_match_with_aggregated_with_and_subsequent_match(self):
|
|
1476
|
+
"""Test match with aggregated WITH followed by another match using the same node reference."""
|
|
1477
|
+
await Runner(
|
|
1478
|
+
"""
|
|
1479
|
+
CREATE VIRTUAL (:AggUser) AS {
|
|
1480
|
+
unwind [
|
|
1481
|
+
{id: 1, name: 'Alice'},
|
|
1482
|
+
{id: 2, name: 'Bob'},
|
|
1483
|
+
{id: 3, name: 'Carol'}
|
|
1484
|
+
] as record
|
|
1485
|
+
RETURN record.id as id, record.name as name
|
|
1486
|
+
}
|
|
1487
|
+
"""
|
|
1488
|
+
).run()
|
|
1489
|
+
await Runner(
|
|
1490
|
+
"""
|
|
1491
|
+
CREATE VIRTUAL (:AggUser)-[:KNOWS]-(:AggUser) AS {
|
|
1492
|
+
unwind [
|
|
1493
|
+
{left_id: 1, right_id: 2},
|
|
1494
|
+
{left_id: 1, right_id: 3}
|
|
1495
|
+
] as record
|
|
1496
|
+
RETURN record.left_id as left_id, record.right_id as right_id
|
|
1497
|
+
}
|
|
1498
|
+
"""
|
|
1499
|
+
).run()
|
|
1500
|
+
await Runner(
|
|
1501
|
+
"""
|
|
1502
|
+
CREATE VIRTUAL (:AggProject) AS {
|
|
1503
|
+
unwind [
|
|
1504
|
+
{id: 1, name: 'Project A'},
|
|
1505
|
+
{id: 2, name: 'Project B'}
|
|
1506
|
+
] as record
|
|
1507
|
+
RETURN record.id as id, record.name as name
|
|
1508
|
+
}
|
|
1509
|
+
"""
|
|
1510
|
+
).run()
|
|
1511
|
+
await Runner(
|
|
1512
|
+
"""
|
|
1513
|
+
CREATE VIRTUAL (:AggUser)-[:WORKS_ON]-(:AggProject) AS {
|
|
1514
|
+
unwind [
|
|
1515
|
+
{left_id: 1, right_id: 1},
|
|
1516
|
+
{left_id: 1, right_id: 2}
|
|
1517
|
+
] as record
|
|
1518
|
+
RETURN record.left_id as left_id, record.right_id as right_id
|
|
1519
|
+
}
|
|
1520
|
+
"""
|
|
1521
|
+
).run()
|
|
1522
|
+
match = Runner(
|
|
1523
|
+
"""
|
|
1524
|
+
MATCH (u:AggUser)-[:KNOWS]->(s:AggUser)
|
|
1525
|
+
WITH u, count(s) as acquaintances
|
|
1526
|
+
MATCH (u)-[:WORKS_ON]->(p:AggProject)
|
|
1527
|
+
RETURN u.name as name, acquaintances, collect(p.name) as projects
|
|
1528
|
+
"""
|
|
1529
|
+
)
|
|
1530
|
+
await match.run()
|
|
1531
|
+
results = match.results
|
|
1532
|
+
assert len(results) == 1
|
|
1533
|
+
assert results[0] == {
|
|
1534
|
+
"name": "Alice",
|
|
1535
|
+
"acquaintances": 2,
|
|
1536
|
+
"projects": ["Project A", "Project B"],
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1460
1539
|
@pytest.mark.asyncio
|
|
1461
1540
|
async def test_match_and_return_full_node(self):
|
|
1462
1541
|
"""Test match and return full node."""
|
|
@@ -2719,6 +2798,58 @@ class TestRunner:
|
|
|
2719
2798
|
assert len(results) == 3
|
|
2720
2799
|
assert [r["n"] for r in results] == [10, 15, 20]
|
|
2721
2800
|
|
|
2801
|
+
@pytest.mark.asyncio
|
|
2802
|
+
async def test_where_with_and_before_in(self):
|
|
2803
|
+
"""Test WHERE with AND before IN (IN on right side of AND)."""
|
|
2804
|
+
runner = Runner("""
|
|
2805
|
+
unwind ['expert', 'intermediate', 'beginner'] as proficiency
|
|
2806
|
+
with proficiency where 1=1 and proficiency in ['expert']
|
|
2807
|
+
return proficiency
|
|
2808
|
+
""")
|
|
2809
|
+
await runner.run()
|
|
2810
|
+
results = runner.results
|
|
2811
|
+
assert len(results) == 1
|
|
2812
|
+
assert results[0] == {"proficiency": "expert"}
|
|
2813
|
+
|
|
2814
|
+
@pytest.mark.asyncio
|
|
2815
|
+
async def test_where_with_and_before_not_in(self):
|
|
2816
|
+
"""Test WHERE with AND before NOT IN."""
|
|
2817
|
+
runner = Runner("""
|
|
2818
|
+
unwind ['expert', 'intermediate', 'beginner'] as proficiency
|
|
2819
|
+
with proficiency where 1=1 and proficiency not in ['expert']
|
|
2820
|
+
return proficiency
|
|
2821
|
+
""")
|
|
2822
|
+
await runner.run()
|
|
2823
|
+
results = runner.results
|
|
2824
|
+
assert len(results) == 2
|
|
2825
|
+
assert [r["proficiency"] for r in results] == ["intermediate", "beginner"]
|
|
2826
|
+
|
|
2827
|
+
@pytest.mark.asyncio
|
|
2828
|
+
async def test_where_with_or_before_in(self):
|
|
2829
|
+
"""Test WHERE with OR before IN."""
|
|
2830
|
+
runner = Runner("""
|
|
2831
|
+
unwind range(1, 10) as n
|
|
2832
|
+
with n where 1=0 or n in [3, 7]
|
|
2833
|
+
return n
|
|
2834
|
+
""")
|
|
2835
|
+
await runner.run()
|
|
2836
|
+
results = runner.results
|
|
2837
|
+
assert len(results) == 2
|
|
2838
|
+
assert [r["n"] for r in results] == [3, 7]
|
|
2839
|
+
|
|
2840
|
+
@pytest.mark.asyncio
|
|
2841
|
+
async def test_in_as_return_expression_with_and_in_where(self):
|
|
2842
|
+
"""Test IN as return expression with AND in WHERE."""
|
|
2843
|
+
runner = Runner("""
|
|
2844
|
+
unwind ['expert', 'intermediate', 'beginner'] as proficiency
|
|
2845
|
+
with proficiency where 1=1 and proficiency in ['expert']
|
|
2846
|
+
return proficiency, proficiency in ['expert'] as isExpert
|
|
2847
|
+
""")
|
|
2848
|
+
await runner.run()
|
|
2849
|
+
results = runner.results
|
|
2850
|
+
assert len(results) == 1
|
|
2851
|
+
assert results[0] == {"proficiency": "expert", "isExpert": 1}
|
|
2852
|
+
|
|
2722
2853
|
@pytest.mark.asyncio
|
|
2723
2854
|
async def test_where_with_contains(self):
|
|
2724
2855
|
"""Test WHERE with CONTAINS."""
|
|
@@ -3932,4 +4063,110 @@ class TestRunner:
|
|
|
3932
4063
|
assert d["hours"] == 2
|
|
3933
4064
|
assert d["minutes"] == 30
|
|
3934
4065
|
assert d["totalSeconds"] == 9000
|
|
3935
|
-
assert d["formatted"] == "PT2H30M"
|
|
4066
|
+
assert d["formatted"] == "PT2H30M"
|
|
4067
|
+
|
|
4068
|
+
# ORDER BY tests
|
|
4069
|
+
|
|
4070
|
+
@pytest.mark.asyncio
|
|
4071
|
+
async def test_order_by_ascending(self):
|
|
4072
|
+
"""Test ORDER BY ascending (default)."""
|
|
4073
|
+
runner = Runner("unwind [3, 1, 2] as x return x order by x")
|
|
4074
|
+
await runner.run()
|
|
4075
|
+
results = runner.results
|
|
4076
|
+
assert len(results) == 3
|
|
4077
|
+
assert results[0] == {"x": 1}
|
|
4078
|
+
assert results[1] == {"x": 2}
|
|
4079
|
+
assert results[2] == {"x": 3}
|
|
4080
|
+
|
|
4081
|
+
@pytest.mark.asyncio
|
|
4082
|
+
async def test_order_by_descending(self):
|
|
4083
|
+
"""Test ORDER BY descending."""
|
|
4084
|
+
runner = Runner("unwind [3, 1, 2] as x return x order by x desc")
|
|
4085
|
+
await runner.run()
|
|
4086
|
+
results = runner.results
|
|
4087
|
+
assert len(results) == 3
|
|
4088
|
+
assert results[0] == {"x": 3}
|
|
4089
|
+
assert results[1] == {"x": 2}
|
|
4090
|
+
assert results[2] == {"x": 1}
|
|
4091
|
+
|
|
4092
|
+
@pytest.mark.asyncio
|
|
4093
|
+
async def test_order_by_ascending_explicit(self):
|
|
4094
|
+
"""Test ORDER BY with explicit ASC."""
|
|
4095
|
+
runner = Runner("unwind [3, 1, 2] as x return x order by x asc")
|
|
4096
|
+
await runner.run()
|
|
4097
|
+
results = runner.results
|
|
4098
|
+
assert len(results) == 3
|
|
4099
|
+
assert results[0] == {"x": 1}
|
|
4100
|
+
assert results[1] == {"x": 2}
|
|
4101
|
+
assert results[2] == {"x": 3}
|
|
4102
|
+
|
|
4103
|
+
@pytest.mark.asyncio
|
|
4104
|
+
async def test_order_by_with_multiple_fields(self):
|
|
4105
|
+
"""Test ORDER BY with multiple sort fields."""
|
|
4106
|
+
runner = Runner(
|
|
4107
|
+
"unwind [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}, {name: 'Alice', age: 25}] as person "
|
|
4108
|
+
"return person.name as name, person.age as age "
|
|
4109
|
+
"order by name asc, age asc"
|
|
4110
|
+
)
|
|
4111
|
+
await runner.run()
|
|
4112
|
+
results = runner.results
|
|
4113
|
+
assert len(results) == 3
|
|
4114
|
+
assert results[0] == {"name": "Alice", "age": 25}
|
|
4115
|
+
assert results[1] == {"name": "Alice", "age": 30}
|
|
4116
|
+
assert results[2] == {"name": "Bob", "age": 25}
|
|
4117
|
+
|
|
4118
|
+
@pytest.mark.asyncio
|
|
4119
|
+
async def test_order_by_with_strings(self):
|
|
4120
|
+
"""Test ORDER BY with string values."""
|
|
4121
|
+
runner = Runner(
|
|
4122
|
+
"unwind ['banana', 'apple', 'cherry'] as fruit return fruit order by fruit"
|
|
4123
|
+
)
|
|
4124
|
+
await runner.run()
|
|
4125
|
+
results = runner.results
|
|
4126
|
+
assert len(results) == 3
|
|
4127
|
+
assert results[0] == {"fruit": "apple"}
|
|
4128
|
+
assert results[1] == {"fruit": "banana"}
|
|
4129
|
+
assert results[2] == {"fruit": "cherry"}
|
|
4130
|
+
|
|
4131
|
+
@pytest.mark.asyncio
|
|
4132
|
+
async def test_order_by_with_aggregated_return(self):
|
|
4133
|
+
"""Test ORDER BY with aggregated RETURN."""
|
|
4134
|
+
runner = Runner(
|
|
4135
|
+
"unwind [1, 1, 2, 2, 3, 3] as x "
|
|
4136
|
+
"return x, count(x) as cnt "
|
|
4137
|
+
"order by x desc"
|
|
4138
|
+
)
|
|
4139
|
+
await runner.run()
|
|
4140
|
+
results = runner.results
|
|
4141
|
+
assert len(results) == 3
|
|
4142
|
+
assert results[0] == {"x": 3, "cnt": 2}
|
|
4143
|
+
assert results[1] == {"x": 2, "cnt": 2}
|
|
4144
|
+
assert results[2] == {"x": 1, "cnt": 2}
|
|
4145
|
+
|
|
4146
|
+
@pytest.mark.asyncio
|
|
4147
|
+
async def test_order_by_with_limit(self):
|
|
4148
|
+
"""Test ORDER BY combined with LIMIT."""
|
|
4149
|
+
runner = Runner(
|
|
4150
|
+
"unwind [3, 1, 4, 1, 5, 9, 2, 6] as x return x order by x limit 3"
|
|
4151
|
+
)
|
|
4152
|
+
await runner.run()
|
|
4153
|
+
results = runner.results
|
|
4154
|
+
assert len(results) == 3
|
|
4155
|
+
assert results[0] == {"x": 1}
|
|
4156
|
+
assert results[1] == {"x": 1}
|
|
4157
|
+
assert results[2] == {"x": 2}
|
|
4158
|
+
|
|
4159
|
+
@pytest.mark.asyncio
|
|
4160
|
+
async def test_order_by_with_where(self):
|
|
4161
|
+
"""Test ORDER BY combined with WHERE."""
|
|
4162
|
+
runner = Runner(
|
|
4163
|
+
"unwind [3, 1, 4, 1, 5, 9, 2, 6] as x return x where x > 2 order by x desc"
|
|
4164
|
+
)
|
|
4165
|
+
await runner.run()
|
|
4166
|
+
results = runner.results
|
|
4167
|
+
assert len(results) == 5
|
|
4168
|
+
assert results[0] == {"x": 9}
|
|
4169
|
+
assert results[1] == {"x": 6}
|
|
4170
|
+
assert results[2] == {"x": 5}
|
|
4171
|
+
assert results[3] == {"x": 4}
|
|
4172
|
+
assert results[4] == {"x": 3}
|