flowquery 1.0.38 → 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 (47) 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/limit.d.ts +1 -0
  8. package/dist/parsing/operations/limit.d.ts.map +1 -1
  9. package/dist/parsing/operations/limit.js +3 -0
  10. package/dist/parsing/operations/limit.js.map +1 -1
  11. package/dist/parsing/operations/order_by.d.ts +35 -0
  12. package/dist/parsing/operations/order_by.d.ts.map +1 -0
  13. package/dist/parsing/operations/order_by.js +87 -0
  14. package/dist/parsing/operations/order_by.js.map +1 -0
  15. package/dist/parsing/operations/return.d.ts +3 -0
  16. package/dist/parsing/operations/return.d.ts.map +1 -1
  17. package/dist/parsing/operations/return.js +16 -3
  18. package/dist/parsing/operations/return.js.map +1 -1
  19. package/dist/parsing/parser.d.ts +1 -0
  20. package/dist/parsing/parser.d.ts.map +1 -1
  21. package/dist/parsing/parser.js +54 -0
  22. package/dist/parsing/parser.js.map +1 -1
  23. package/dist/tokenization/token.d.ts +8 -0
  24. package/dist/tokenization/token.d.ts.map +1 -1
  25. package/dist/tokenization/token.js +24 -0
  26. package/dist/tokenization/token.js.map +1 -1
  27. package/docs/flowquery.min.js +1 -1
  28. package/flowquery-py/pyproject.toml +1 -1
  29. package/flowquery-py/src/parsing/expressions/operator.py +4 -4
  30. package/flowquery-py/src/parsing/operations/__init__.py +3 -0
  31. package/flowquery-py/src/parsing/operations/aggregated_return.py +4 -1
  32. package/flowquery-py/src/parsing/operations/limit.py +4 -0
  33. package/flowquery-py/src/parsing/operations/order_by.py +72 -0
  34. package/flowquery-py/src/parsing/operations/return_op.py +20 -3
  35. package/flowquery-py/src/parsing/parser.py +44 -0
  36. package/flowquery-py/src/tokenization/token.py +28 -0
  37. package/flowquery-py/tests/compute/test_runner.py +159 -1
  38. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  39. package/package.json +1 -1
  40. package/src/parsing/expressions/operator.ts +4 -4
  41. package/src/parsing/operations/aggregated_return.ts +9 -5
  42. package/src/parsing/operations/limit.ts +3 -0
  43. package/src/parsing/operations/order_by.ts +75 -0
  44. package/src/parsing/operations/return.ts +17 -3
  45. package/src/parsing/parser.ts +52 -0
  46. package/src/tokenization/token.ts +32 -0
  47. package/tests/compute/runner.test.ts +144 -0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.28"
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
@@ -15,6 +15,10 @@ class Limit(Operation):
15
15
  def is_limit_reached(self) -> bool:
16
16
  return self._count >= self._limit
17
17
 
18
+ @property
19
+ def limit_value(self) -> int:
20
+ return self._limit
21
+
18
22
  def increment(self) -> None:
19
23
  self._count += 1
20
24
 
@@ -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
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
5
5
 
6
6
  from ..ast_node import ASTNode
7
7
  from .limit import Limit
8
+ from .order_by import OrderBy
8
9
  from .projection import Projection
9
10
 
10
11
  if TYPE_CHECKING:
@@ -26,6 +27,7 @@ class Return(Projection):
26
27
  self._where: Optional['Where'] = None
27
28
  self._results: List[Dict[str, Any]] = []
28
29
  self._limit: Optional[Limit] = None
30
+ self._order_by: Optional[OrderBy] = None
29
31
 
30
32
  @property
31
33
  def where(self) -> Any:
@@ -45,10 +47,20 @@ class Return(Projection):
45
47
  def limit(self, limit: Limit) -> None:
46
48
  self._limit = limit
47
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
+
48
58
  async def run(self) -> None:
49
59
  if not self.where:
50
60
  return
51
- if self._limit is not None and self._limit.is_limit_reached:
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:
52
64
  return
53
65
  record: Dict[str, Any] = {}
54
66
  for expression, alias in self.expressions():
@@ -57,7 +69,7 @@ class Return(Projection):
57
69
  value = copy.deepcopy(raw) if isinstance(raw, (dict, list)) else raw
58
70
  record[alias] = value
59
71
  self._results.append(record)
60
- if self._limit is not None:
72
+ if self._order_by is None and self._limit is not None:
61
73
  self._limit.increment()
62
74
 
63
75
  async def initialize(self) -> None:
@@ -65,4 +77,9 @@ class Return(Projection):
65
77
 
66
78
  @property
67
79
  def results(self) -> List[Dict[str, Any]]:
68
- 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
@@ -146,6 +147,14 @@ class Parser(BaseParser):
146
147
  operation.add_sibling(where)
147
148
  operation = where
148
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
+
149
158
  limit = self._parse_limit()
150
159
  if limit is not None:
151
160
  if isinstance(operation, Return):
@@ -694,6 +703,41 @@ class Parser(BaseParser):
694
703
  self.set_next_token()
695
704
  return limit
696
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
+
697
741
  def _parse_expressions(
698
742
  self, alias_option: AliasOption = AliasOption.NOT_ALLOWED
699
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
@@ -2798,6 +2798,58 @@ class TestRunner:
2798
2798
  assert len(results) == 3
2799
2799
  assert [r["n"] for r in results] == [10, 15, 20]
2800
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
+
2801
2853
  @pytest.mark.asyncio
2802
2854
  async def test_where_with_contains(self):
2803
2855
  """Test WHERE with CONTAINS."""
@@ -4011,4 +4063,110 @@ class TestRunner:
4011
4063
  assert d["hours"] == 2
4012
4064
  assert d["minutes"] == 30
4013
4065
  assert d["totalSeconds"] == 9000
4014
- 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}