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.
Files changed (51) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/parsing/expressions/operator.js +4 -4
  3. package/dist/parsing/expressions/operator.js.map +1 -1
  4. package/dist/parsing/operations/aggregated_return.d.ts.map +1 -1
  5. package/dist/parsing/operations/aggregated_return.js +6 -2
  6. package/dist/parsing/operations/aggregated_return.js.map +1 -1
  7. package/dist/parsing/operations/group_by.d.ts.map +1 -1
  8. package/dist/parsing/operations/group_by.js +3 -2
  9. package/dist/parsing/operations/group_by.js.map +1 -1
  10. package/dist/parsing/operations/limit.d.ts +3 -0
  11. package/dist/parsing/operations/limit.d.ts.map +1 -1
  12. package/dist/parsing/operations/limit.js +9 -0
  13. package/dist/parsing/operations/limit.js.map +1 -1
  14. package/dist/parsing/operations/order_by.d.ts +35 -0
  15. package/dist/parsing/operations/order_by.d.ts.map +1 -0
  16. package/dist/parsing/operations/order_by.js +87 -0
  17. package/dist/parsing/operations/order_by.js.map +1 -0
  18. package/dist/parsing/operations/return.d.ts +6 -0
  19. package/dist/parsing/operations/return.d.ts.map +1 -1
  20. package/dist/parsing/operations/return.js +24 -1
  21. package/dist/parsing/operations/return.js.map +1 -1
  22. package/dist/parsing/parser.d.ts +1 -0
  23. package/dist/parsing/parser.d.ts.map +1 -1
  24. package/dist/parsing/parser.js +67 -10
  25. package/dist/parsing/parser.js.map +1 -1
  26. package/dist/tokenization/token.d.ts +8 -0
  27. package/dist/tokenization/token.d.ts.map +1 -1
  28. package/dist/tokenization/token.js +24 -0
  29. package/dist/tokenization/token.js.map +1 -1
  30. package/docs/flowquery.min.js +1 -1
  31. package/flowquery-py/pyproject.toml +1 -1
  32. package/flowquery-py/src/parsing/expressions/operator.py +4 -4
  33. package/flowquery-py/src/parsing/operations/__init__.py +3 -0
  34. package/flowquery-py/src/parsing/operations/aggregated_return.py +4 -1
  35. package/flowquery-py/src/parsing/operations/limit.py +11 -0
  36. package/flowquery-py/src/parsing/operations/order_by.py +72 -0
  37. package/flowquery-py/src/parsing/operations/return_op.py +32 -1
  38. package/flowquery-py/src/parsing/parser.py +57 -9
  39. package/flowquery-py/src/tokenization/token.py +28 -0
  40. package/flowquery-py/tests/compute/test_runner.py +238 -1
  41. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  42. package/package.json +1 -1
  43. package/src/parsing/expressions/operator.ts +4 -4
  44. package/src/parsing/operations/aggregated_return.ts +9 -5
  45. package/src/parsing/operations/group_by.ts +4 -2
  46. package/src/parsing/operations/limit.ts +10 -1
  47. package/src/parsing/operations/order_by.ts +75 -0
  48. package/src/parsing/operations/return.ts +26 -1
  49. package/src/parsing/parser.ts +64 -10
  50. package/src/tokenization/token.ts +32 -0
  51. package/tests/compute/runner.test.ts +211 -0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.27"
3
+ version = "1.0.29"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -163,7 +163,7 @@ class Not(Operator):
163
163
 
164
164
  class Is(Operator):
165
165
  def __init__(self) -> None:
166
- super().__init__(-1, True)
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__(-1, True)
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__(-1, True)
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__(-1, True)
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
- return list(self._group_by.generate_results())
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
- return self._results
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.add_sibling(limit)
149
- operation = limit
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
- # Resolve through Expression -> Reference -> Node (e.g., after WITH)
543
- ref_child = reference.first_child() if isinstance(reference, Expression) else None
544
- if isinstance(ref_child, Reference):
545
- inner = ref_child.referred
546
- if isinstance(inner, Node):
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}