flowquery 1.0.43 → 1.0.45

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 (94) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/parsing/functions/join.d.ts.map +1 -1
  3. package/dist/parsing/functions/join.js +6 -3
  4. package/dist/parsing/functions/join.js.map +1 -1
  5. package/dist/parsing/functions/keys.d.ts.map +1 -1
  6. package/dist/parsing/functions/keys.js +3 -5
  7. package/dist/parsing/functions/keys.js.map +1 -1
  8. package/dist/parsing/functions/range.d.ts.map +1 -1
  9. package/dist/parsing/functions/range.js +11 -3
  10. package/dist/parsing/functions/range.js.map +1 -1
  11. package/dist/parsing/functions/replace.d.ts.map +1 -1
  12. package/dist/parsing/functions/replace.js +8 -3
  13. package/dist/parsing/functions/replace.js.map +1 -1
  14. package/dist/parsing/functions/round.d.ts.map +1 -1
  15. package/dist/parsing/functions/round.js +5 -4
  16. package/dist/parsing/functions/round.js.map +1 -1
  17. package/dist/parsing/functions/size.d.ts.map +1 -1
  18. package/dist/parsing/functions/size.js +5 -4
  19. package/dist/parsing/functions/size.js.map +1 -1
  20. package/dist/parsing/functions/split.d.ts.map +1 -1
  21. package/dist/parsing/functions/split.js +12 -4
  22. package/dist/parsing/functions/split.js.map +1 -1
  23. package/dist/parsing/functions/string_distance.d.ts.map +1 -1
  24. package/dist/parsing/functions/string_distance.js +3 -0
  25. package/dist/parsing/functions/string_distance.js.map +1 -1
  26. package/dist/parsing/functions/stringify.d.ts.map +1 -1
  27. package/dist/parsing/functions/stringify.js +7 -6
  28. package/dist/parsing/functions/stringify.js.map +1 -1
  29. package/dist/parsing/functions/substring.d.ts.map +1 -1
  30. package/dist/parsing/functions/substring.js +3 -0
  31. package/dist/parsing/functions/substring.js.map +1 -1
  32. package/dist/parsing/functions/to_json.d.ts.map +1 -1
  33. package/dist/parsing/functions/to_json.js +5 -4
  34. package/dist/parsing/functions/to_json.js.map +1 -1
  35. package/dist/parsing/functions/to_lower.d.ts.map +1 -1
  36. package/dist/parsing/functions/to_lower.js +3 -0
  37. package/dist/parsing/functions/to_lower.js.map +1 -1
  38. package/dist/parsing/functions/to_string.js +1 -1
  39. package/dist/parsing/functions/to_string.js.map +1 -1
  40. package/dist/parsing/functions/trim.d.ts.map +1 -1
  41. package/dist/parsing/functions/trim.js +3 -0
  42. package/dist/parsing/functions/trim.js.map +1 -1
  43. package/dist/parsing/operations/order_by.d.ts +22 -2
  44. package/dist/parsing/operations/order_by.d.ts.map +1 -1
  45. package/dist/parsing/operations/order_by.js +54 -6
  46. package/dist/parsing/operations/order_by.js.map +1 -1
  47. package/dist/parsing/operations/return.d.ts.map +1 -1
  48. package/dist/parsing/operations/return.js +4 -0
  49. package/dist/parsing/operations/return.js.map +1 -1
  50. package/dist/parsing/parser.d.ts.map +1 -1
  51. package/dist/parsing/parser.js +4 -5
  52. package/dist/parsing/parser.js.map +1 -1
  53. package/docs/flowquery.min.js +1 -1
  54. package/flowquery-py/pyproject.toml +1 -1
  55. package/flowquery-py/src/parsing/functions/join.py +2 -0
  56. package/flowquery-py/src/parsing/functions/keys.py +1 -1
  57. package/flowquery-py/src/parsing/functions/range_.py +2 -0
  58. package/flowquery-py/src/parsing/functions/replace.py +2 -0
  59. package/flowquery-py/src/parsing/functions/round_.py +2 -0
  60. package/flowquery-py/src/parsing/functions/size.py +2 -0
  61. package/flowquery-py/src/parsing/functions/split.py +2 -0
  62. package/flowquery-py/src/parsing/functions/string_distance.py +5 -1
  63. package/flowquery-py/src/parsing/functions/stringify.py +2 -0
  64. package/flowquery-py/src/parsing/functions/substring.py +2 -0
  65. package/flowquery-py/src/parsing/functions/to_json.py +2 -0
  66. package/flowquery-py/src/parsing/functions/to_lower.py +2 -0
  67. package/flowquery-py/src/parsing/functions/to_string.py +1 -1
  68. package/flowquery-py/src/parsing/functions/trim.py +2 -0
  69. package/flowquery-py/src/parsing/operations/order_by.py +55 -13
  70. package/flowquery-py/src/parsing/operations/return_op.py +3 -0
  71. package/flowquery-py/src/parsing/parser.py +4 -5
  72. package/flowquery-py/tests/compute/test_runner.py +255 -0
  73. package/flowquery-py/tests/parsing/test_parser.py +63 -0
  74. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  75. package/package.json +1 -1
  76. package/src/parsing/functions/join.ts +8 -5
  77. package/src/parsing/functions/keys.ts +4 -6
  78. package/src/parsing/functions/range.ts +12 -4
  79. package/src/parsing/functions/replace.ts +11 -4
  80. package/src/parsing/functions/round.ts +6 -5
  81. package/src/parsing/functions/size.ts +6 -5
  82. package/src/parsing/functions/split.ts +14 -6
  83. package/src/parsing/functions/string_distance.ts +3 -0
  84. package/src/parsing/functions/stringify.ts +9 -8
  85. package/src/parsing/functions/substring.ts +3 -0
  86. package/src/parsing/functions/to_json.ts +6 -5
  87. package/src/parsing/functions/to_lower.ts +3 -0
  88. package/src/parsing/functions/to_string.ts +1 -1
  89. package/src/parsing/functions/trim.ts +3 -0
  90. package/src/parsing/operations/order_by.ts +58 -7
  91. package/src/parsing/operations/return.ts +4 -0
  92. package/src/parsing/parser.ts +4 -5
  93. package/tests/compute/runner.test.ts +234 -0
  94. package/tests/parsing/parser.test.ts +56 -0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.33"
3
+ version = "1.0.35"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -42,6 +42,8 @@ class Join(Function):
42
42
  def value(self) -> Any:
43
43
  array = self.get_children()[0].value()
44
44
  delimiter = self.get_children()[1].value()
45
+ if array is None:
46
+ return None
45
47
  if not isinstance(array, list) or not isinstance(delimiter, str):
46
48
  raise ValueError("Invalid arguments for join function")
47
49
  return delimiter.join(str(item) for item in array)
@@ -28,7 +28,7 @@ class Keys(Function):
28
28
  def value(self) -> Any:
29
29
  obj = self.get_children()[0].value()
30
30
  if obj is None:
31
- return []
31
+ return None
32
32
  if not isinstance(obj, dict):
33
33
  raise ValueError("keys() expects an object, not an array or primitive")
34
34
  return list(obj.keys())
@@ -34,6 +34,8 @@ class Range(Function):
34
34
  def value(self) -> Any:
35
35
  start = self.get_children()[0].value()
36
36
  end = self.get_children()[1].value()
37
+ if start is None or end is None:
38
+ return None
37
39
  if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
38
40
  raise ValueError("Invalid arguments for range function")
39
41
  return list(range(int(start), int(end) + 1))
@@ -32,6 +32,8 @@ class Replace(Function):
32
32
  text = self.get_children()[0].value()
33
33
  pattern = self.get_children()[1].value()
34
34
  replacement = self.get_children()[2].value()
35
+ if text is None:
36
+ return None
35
37
  if not isinstance(text, str) or not isinstance(pattern, str) or not isinstance(replacement, str):
36
38
  raise ValueError("Invalid arguments for replace function")
37
39
  return re.sub(re.escape(pattern), replacement, text)
@@ -27,6 +27,8 @@ class Round(Function):
27
27
 
28
28
  def value(self) -> Any:
29
29
  val = self.get_children()[0].value()
30
+ if val is None:
31
+ return None
30
32
  if not isinstance(val, (int, float)):
31
33
  raise ValueError("Invalid argument for round function")
32
34
  return round(val)
@@ -27,6 +27,8 @@ class Size(Function):
27
27
 
28
28
  def value(self) -> Any:
29
29
  val = self.get_children()[0].value()
30
+ if val is None:
31
+ return None
30
32
  if not isinstance(val, (list, str)):
31
33
  raise ValueError("Invalid argument for size function")
32
34
  return len(val)
@@ -47,6 +47,8 @@ class Split(Function):
47
47
  def value(self) -> Any:
48
48
  text = self.get_children()[0].value()
49
49
  delimiter = self.get_children()[1].value()
50
+ if text is None:
51
+ return None
50
52
  if not isinstance(text, str) or not isinstance(delimiter, str):
51
53
  raise ValueError("Invalid arguments for split function")
52
54
  return text.split(delimiter)
@@ -1,5 +1,7 @@
1
1
  """String distance function using Levenshtein distance."""
2
2
 
3
+ from typing import Optional
4
+
3
5
  from .function import Function
4
6
  from .function_metadata import FunctionDef
5
7
 
@@ -80,9 +82,11 @@ class StringDistance(Function):
80
82
  super().__init__("string_distance")
81
83
  self._expected_parameter_count = 2
82
84
 
83
- def value(self) -> float:
85
+ def value(self) -> Optional[float]:
84
86
  str1 = self.get_children()[0].value()
85
87
  str2 = self.get_children()[1].value()
88
+ if str1 is None or str2 is None:
89
+ return None
86
90
  if not isinstance(str1, str) or not isinstance(str2, str):
87
91
  raise ValueError("Invalid arguments for string_distance function: both arguments must be strings")
88
92
  return _levenshtein_distance(str1, str2)
@@ -42,6 +42,8 @@ class Stringify(Function):
42
42
  def value(self) -> Any:
43
43
  val = self.get_children()[0].value()
44
44
  indent = int(self.get_children()[1].value())
45
+ if val is None:
46
+ return None
45
47
  if not isinstance(val, (dict, list)):
46
48
  raise ValueError("Invalid argument for stringify function")
47
49
  return json.dumps(val, indent=indent, default=str)
@@ -52,6 +52,8 @@ class Substring(Function):
52
52
  original = children[0].value()
53
53
  start = children[1].value()
54
54
 
55
+ if original is None:
56
+ return None
55
57
  if not isinstance(original, str):
56
58
  raise ValueError(
57
59
  "Invalid argument for substring function: expected a string as the first argument"
@@ -28,6 +28,8 @@ class ToJson(Function):
28
28
 
29
29
  def value(self) -> Any:
30
30
  text = self.get_children()[0].value()
31
+ if text is None:
32
+ return None
31
33
  if not isinstance(text, str):
32
34
  raise ValueError("Invalid arguments for tojson function")
33
35
  return json.loads(text)
@@ -30,6 +30,8 @@ class ToLower(Function):
30
30
 
31
31
  def value(self) -> Any:
32
32
  val = self.get_children()[0].value()
33
+ if val is None:
34
+ return None
33
35
  if not isinstance(val, str):
34
36
  raise ValueError("Invalid argument for toLower function: expected a string")
35
37
  return val.lower()
@@ -33,7 +33,7 @@ class ToString(Function):
33
33
  def value(self) -> Any:
34
34
  val = self.get_children()[0].value()
35
35
  if val is None:
36
- return "null"
36
+ return None
37
37
  if isinstance(val, bool):
38
38
  return str(val).lower()
39
39
  if isinstance(val, (dict, list)):
@@ -30,6 +30,8 @@ class Trim(Function):
30
30
 
31
31
  def value(self) -> Any:
32
32
  val = self.get_children()[0].value()
33
+ if val is None:
34
+ return None
33
35
  if not isinstance(val, str):
34
36
  raise ValueError("Invalid argument for trim function: expected a string")
35
37
  return val.strip()
@@ -1,15 +1,19 @@
1
1
  """Represents an ORDER BY operation that sorts results."""
2
2
 
3
- from typing import Any, Dict, List
3
+ import functools
4
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
4
5
 
5
6
  from .operation import Operation
6
7
 
8
+ if TYPE_CHECKING:
9
+ from ..expressions.expression import Expression
10
+
7
11
 
8
12
  class SortField:
9
- """A single sort specification: field name and direction."""
13
+ """A single sort specification: expression and direction."""
10
14
 
11
- def __init__(self, field: str, direction: str = "asc"):
12
- self.field = field
15
+ def __init__(self, expression: 'Expression', direction: str = "asc"):
16
+ self.expression = expression
13
17
  self.direction = direction
14
18
 
15
19
 
@@ -19,27 +23,63 @@ class OrderBy(Operation):
19
23
  Can be attached to a RETURN operation (sorting its results),
20
24
  or used as a standalone accumulating operation after a non-aggregate WITH.
21
25
 
22
- Example:
26
+ Supports both simple field references and arbitrary expressions:
27
+
28
+ Example::
29
+
23
30
  RETURN x ORDER BY x DESC
31
+ RETURN x ORDER BY toLower(x.name) ASC
32
+ RETURN x ORDER BY string_distance(toLower(x.name), toLower('Thomas')) ASC
24
33
  """
25
34
 
26
35
  def __init__(self, fields: List[SortField]):
27
36
  super().__init__()
28
37
  self._fields = fields
29
38
  self._results: List[Dict[str, Any]] = []
39
+ self._sort_keys: List[List[Any]] = []
30
40
 
31
41
  @property
32
42
  def fields(self) -> List[SortField]:
33
43
  return self._fields
34
44
 
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
45
+ def capture_sort_keys(self) -> None:
46
+ """Evaluate every sort-field expression against the current runtime
47
+ context and store the resulting values. Must be called once per
48
+ accumulated row (from ``Return.run()``)."""
49
+ self._sort_keys.append([f.expression.value() for f in self._fields])
38
50
 
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)
51
+ def sort(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
52
+ """Sort records using pre-computed sort keys captured during
53
+ accumulation. When no keys have been captured (e.g. aggregated
54
+ returns), falls back to looking up simple reference identifiers
55
+ in each record."""
56
+ from ..expressions.reference import Reference
57
+
58
+ use_keys = len(self._sort_keys) == len(records)
59
+ keys = self._sort_keys
60
+
61
+ # Pre-compute fallback field names for when sort keys aren't
62
+ # available (aggregated returns).
63
+ fallback_fields: List[Optional[str]] = []
64
+ for f in self._fields:
65
+ root = f.expression.first_child()
66
+ if isinstance(root, Reference) and f.expression.child_count() == 1:
67
+ fallback_fields.append(root.identifier)
68
+ else:
69
+ fallback_fields.append(None)
70
+
71
+ indices = list(range(len(records)))
72
+
73
+ def compare(ai: int, bi: int) -> int:
74
+ for f_idx, sf in enumerate(self._fields):
75
+ if use_keys:
76
+ a_val = keys[ai][f_idx]
77
+ b_val = keys[bi][f_idx]
78
+ elif fallback_fields[f_idx] is not None:
79
+ a_val = records[ai].get(fallback_fields[f_idx]) # type: ignore[arg-type]
80
+ b_val = records[bi].get(fallback_fields[f_idx]) # type: ignore[arg-type]
81
+ else:
82
+ continue
43
83
  cmp = 0
44
84
  if a_val is None and b_val is None:
45
85
  cmp = 0
@@ -55,7 +95,8 @@ class OrderBy(Operation):
55
95
  return -cmp if sf.direction == "desc" else cmp
56
96
  return 0
57
97
 
58
- return sorted(records, key=functools.cmp_to_key(compare))
98
+ indices.sort(key=functools.cmp_to_key(compare))
99
+ return [records[i] for i in indices]
59
100
 
60
101
  async def run(self) -> None:
61
102
  """When used as a standalone operation, passes through to next."""
@@ -64,6 +105,7 @@ class OrderBy(Operation):
64
105
 
65
106
  async def initialize(self) -> None:
66
107
  self._results = []
108
+ self._sort_keys = []
67
109
  if self.next:
68
110
  await self.next.initialize()
69
111
 
@@ -68,6 +68,9 @@ class Return(Projection):
68
68
  # Deep copy objects to preserve their state
69
69
  value = copy.deepcopy(raw) if isinstance(raw, (dict, list)) else raw
70
70
  record[alias] = value
71
+ # Capture sort-key values while expression bindings are still live.
72
+ if self._order_by is not None:
73
+ self._order_by.capture_sort_keys()
71
74
  self._results.append(record)
72
75
  if self._order_by is None and self._limit is not None:
73
76
  self._limit.increment()
@@ -767,10 +767,9 @@ class Parser(BaseParser):
767
767
  self._expect_and_skip_whitespace_and_comments()
768
768
  fields: list[SortField] = []
769
769
  while True:
770
- if not self.token.is_identifier_or_keyword():
771
- raise ValueError("Expected field name in ORDER BY")
772
- field = self.token.value
773
- self.set_next_token()
770
+ expression = self._parse_expression()
771
+ if expression is None:
772
+ raise ValueError("Expected expression in ORDER BY")
774
773
  self._skip_whitespace_and_comments()
775
774
  direction = "asc"
776
775
  if self.token.is_asc():
@@ -781,7 +780,7 @@ class Parser(BaseParser):
781
780
  direction = "desc"
782
781
  self.set_next_token()
783
782
  self._skip_whitespace_and_comments()
784
- fields.append(SortField(field, direction))
783
+ fields.append(SortField(expression, direction))
785
784
  if self.token.is_comma():
786
785
  self.set_next_token()
787
786
  self._skip_whitespace_and_comments()
@@ -849,6 +849,134 @@ class TestRunner:
849
849
  assert len(results) == 1
850
850
  assert results[0] == {"result": ""}
851
851
 
852
+ # --- Null propagation tests ---
853
+
854
+ @pytest.mark.asyncio
855
+ async def test_to_lower_with_null_returns_null(self):
856
+ """Test toLower with null returns null."""
857
+ runner = Runner("RETURN toLower(null) as result")
858
+ await runner.run()
859
+ results = runner.results
860
+ assert len(results) == 1
861
+ assert results[0] == {"result": None}
862
+
863
+ @pytest.mark.asyncio
864
+ async def test_trim_with_null_returns_null(self):
865
+ """Test trim with null returns null."""
866
+ runner = Runner("RETURN trim(null) as result")
867
+ await runner.run()
868
+ results = runner.results
869
+ assert len(results) == 1
870
+ assert results[0] == {"result": None}
871
+
872
+ @pytest.mark.asyncio
873
+ async def test_replace_with_null_returns_null(self):
874
+ """Test replace with null returns null."""
875
+ runner = Runner("RETURN replace(null, 'a', 'b') as result")
876
+ await runner.run()
877
+ results = runner.results
878
+ assert len(results) == 1
879
+ assert results[0] == {"result": None}
880
+
881
+ @pytest.mark.asyncio
882
+ async def test_substring_with_null_returns_null(self):
883
+ """Test substring with null returns null."""
884
+ runner = Runner("RETURN substring(null, 0, 3) as result")
885
+ await runner.run()
886
+ results = runner.results
887
+ assert len(results) == 1
888
+ assert results[0] == {"result": None}
889
+
890
+ @pytest.mark.asyncio
891
+ async def test_split_with_null_returns_null(self):
892
+ """Test split with null returns null."""
893
+ runner = Runner("RETURN split(null, ',') as result")
894
+ await runner.run()
895
+ results = runner.results
896
+ assert len(results) == 1
897
+ assert results[0] == {"result": None}
898
+
899
+ @pytest.mark.asyncio
900
+ async def test_size_with_null_returns_null(self):
901
+ """Test size with null returns null."""
902
+ runner = Runner("RETURN size(null) as result")
903
+ await runner.run()
904
+ results = runner.results
905
+ assert len(results) == 1
906
+ assert results[0] == {"result": None}
907
+
908
+ @pytest.mark.asyncio
909
+ async def test_round_with_null_returns_null(self):
910
+ """Test round with null returns null."""
911
+ runner = Runner("RETURN round(null) as result")
912
+ await runner.run()
913
+ results = runner.results
914
+ assert len(results) == 1
915
+ assert results[0] == {"result": None}
916
+
917
+ @pytest.mark.asyncio
918
+ async def test_join_with_null_returns_null(self):
919
+ """Test join with null returns null."""
920
+ runner = Runner("RETURN join(null, ',') as result")
921
+ await runner.run()
922
+ results = runner.results
923
+ assert len(results) == 1
924
+ assert results[0] == {"result": None}
925
+
926
+ @pytest.mark.asyncio
927
+ async def test_string_distance_with_null_returns_null(self):
928
+ """Test string_distance with null returns null."""
929
+ runner = Runner("RETURN string_distance(null, 'hello') as result")
930
+ await runner.run()
931
+ results = runner.results
932
+ assert len(results) == 1
933
+ assert results[0] == {"result": None}
934
+
935
+ @pytest.mark.asyncio
936
+ async def test_stringify_with_null_returns_null(self):
937
+ """Test stringify with null returns null."""
938
+ runner = Runner("RETURN stringify(null) as result")
939
+ await runner.run()
940
+ results = runner.results
941
+ assert len(results) == 1
942
+ assert results[0] == {"result": None}
943
+
944
+ @pytest.mark.asyncio
945
+ async def test_tojson_with_null_returns_null(self):
946
+ """Test tojson with null returns null."""
947
+ runner = Runner("RETURN tojson(null) as result")
948
+ await runner.run()
949
+ results = runner.results
950
+ assert len(results) == 1
951
+ assert results[0] == {"result": None}
952
+
953
+ @pytest.mark.asyncio
954
+ async def test_range_with_null_returns_null(self):
955
+ """Test range with null returns null."""
956
+ runner = Runner("RETURN range(null, 5) as result")
957
+ await runner.run()
958
+ results = runner.results
959
+ assert len(results) == 1
960
+ assert results[0] == {"result": None}
961
+
962
+ @pytest.mark.asyncio
963
+ async def test_to_string_with_null_returns_null(self):
964
+ """Test toString with null returns null."""
965
+ runner = Runner("RETURN toString(null) as result")
966
+ await runner.run()
967
+ results = runner.results
968
+ assert len(results) == 1
969
+ assert results[0] == {"result": None}
970
+
971
+ @pytest.mark.asyncio
972
+ async def test_keys_with_null_returns_null(self):
973
+ """Test keys with null returns null."""
974
+ runner = Runner("RETURN keys(null) as result")
975
+ await runner.run()
976
+ results = runner.results
977
+ assert len(results) == 1
978
+ assert results[0] == {"result": None}
979
+
852
980
  @pytest.mark.asyncio
853
981
  async def test_associative_array_with_key_which_is_keyword(self):
854
982
  """Test associative array with key which is keyword."""
@@ -4280,6 +4408,133 @@ class TestRunner:
4280
4408
  assert results[3] == {"x": 4}
4281
4409
  assert results[4] == {"x": 3}
4282
4410
 
4411
+ @pytest.mark.asyncio
4412
+ async def test_order_by_with_property_access_expression(self):
4413
+ """Test ORDER BY with property access expression."""
4414
+ runner = Runner(
4415
+ "unwind [{name: 'Charlie', age: 30}, {name: 'Alice', age: 25}, {name: 'Bob', age: 35}] as person "
4416
+ "return person.name as name, person.age as age "
4417
+ "order by person.name asc"
4418
+ )
4419
+ await runner.run()
4420
+ results = runner.results
4421
+ assert len(results) == 3
4422
+ assert results[0] == {"name": "Alice", "age": 25}
4423
+ assert results[1] == {"name": "Bob", "age": 35}
4424
+ assert results[2] == {"name": "Charlie", "age": 30}
4425
+
4426
+ @pytest.mark.asyncio
4427
+ async def test_order_by_with_function_expression(self):
4428
+ """Test ORDER BY with function expression."""
4429
+ runner = Runner(
4430
+ "unwind ['BANANA', 'apple', 'Cherry'] as fruit "
4431
+ "return fruit "
4432
+ "order by toLower(fruit)"
4433
+ )
4434
+ await runner.run()
4435
+ results = runner.results
4436
+ assert len(results) == 3
4437
+ assert results[0] == {"fruit": "apple"}
4438
+ assert results[1] == {"fruit": "BANANA"}
4439
+ assert results[2] == {"fruit": "Cherry"}
4440
+
4441
+ @pytest.mark.asyncio
4442
+ async def test_order_by_with_function_expression_descending(self):
4443
+ """Test ORDER BY with function expression descending."""
4444
+ runner = Runner(
4445
+ "unwind ['BANANA', 'apple', 'Cherry'] as fruit "
4446
+ "return fruit "
4447
+ "order by toLower(fruit) desc"
4448
+ )
4449
+ await runner.run()
4450
+ results = runner.results
4451
+ assert len(results) == 3
4452
+ assert results[0] == {"fruit": "Cherry"}
4453
+ assert results[1] == {"fruit": "BANANA"}
4454
+ assert results[2] == {"fruit": "apple"}
4455
+
4456
+ @pytest.mark.asyncio
4457
+ async def test_order_by_with_nested_function_expression(self):
4458
+ """Test ORDER BY with nested function expression."""
4459
+ runner = Runner(
4460
+ "unwind ['Alice', 'Bob', 'ALICE', 'bob'] as name "
4461
+ "return name "
4462
+ "order by string_distance(toLower(name), toLower('alice')) asc"
4463
+ )
4464
+ await runner.run()
4465
+ results = runner.results
4466
+ assert len(results) == 4
4467
+ # 'Alice' and 'ALICE' have distance 0 from 'alice', should come first
4468
+ assert results[0]["name"] == "Alice"
4469
+ assert results[1]["name"] == "ALICE"
4470
+ # 'Bob' and 'bob' have higher distance from 'alice'
4471
+ assert results[2]["name"] == "Bob"
4472
+ assert results[3]["name"] == "bob"
4473
+
4474
+ @pytest.mark.asyncio
4475
+ async def test_order_by_with_arithmetic_expression(self):
4476
+ """Test ORDER BY with arithmetic expression."""
4477
+ runner = Runner(
4478
+ "unwind [{a: 3, b: 1}, {a: 1, b: 5}, {a: 2, b: 2}] as item "
4479
+ "return item.a as a, item.b as b "
4480
+ "order by item.a + item.b asc"
4481
+ )
4482
+ await runner.run()
4483
+ results = runner.results
4484
+ assert len(results) == 3
4485
+ assert results[0] == {"a": 3, "b": 1} # sum = 4
4486
+ assert results[1] == {"a": 2, "b": 2} # sum = 4
4487
+ assert results[2] == {"a": 1, "b": 5} # sum = 6
4488
+
4489
+ @pytest.mark.asyncio
4490
+ async def test_order_by_expression_does_not_leak_synthetic_keys(self):
4491
+ """Test ORDER BY expression does not leak synthetic keys."""
4492
+ runner = Runner(
4493
+ "unwind ['B', 'a', 'C'] as x "
4494
+ "return x "
4495
+ "order by toLower(x) asc"
4496
+ )
4497
+ await runner.run()
4498
+ results = runner.results
4499
+ assert len(results) == 3
4500
+ # Results should only contain 'x', no extra keys
4501
+ for r in results:
4502
+ assert list(r.keys()) == ["x"]
4503
+ assert results[0] == {"x": "a"}
4504
+ assert results[1] == {"x": "B"}
4505
+ assert results[2] == {"x": "C"}
4506
+
4507
+ @pytest.mark.asyncio
4508
+ async def test_order_by_with_expression_and_limit(self):
4509
+ """Test ORDER BY with expression and limit."""
4510
+ runner = Runner(
4511
+ "unwind ['BANANA', 'apple', 'Cherry', 'date', 'ELDERBERRY'] as fruit "
4512
+ "return fruit "
4513
+ "order by toLower(fruit) asc "
4514
+ "limit 3"
4515
+ )
4516
+ await runner.run()
4517
+ results = runner.results
4518
+ assert len(results) == 3
4519
+ assert results[0] == {"fruit": "apple"}
4520
+ assert results[1] == {"fruit": "BANANA"}
4521
+ assert results[2] == {"fruit": "Cherry"}
4522
+
4523
+ @pytest.mark.asyncio
4524
+ async def test_order_by_with_mixed_simple_and_expression_fields(self):
4525
+ """Test ORDER BY with mixed simple and expression fields."""
4526
+ runner = Runner(
4527
+ "unwind [{name: 'Alice', score: 3}, {name: 'Alice', score: 1}, {name: 'Bob', score: 2}] as item "
4528
+ "return item.name as name, item.score as score "
4529
+ "order by name asc, item.score desc"
4530
+ )
4531
+ await runner.run()
4532
+ results = runner.results
4533
+ assert len(results) == 3
4534
+ assert results[0] == {"name": "Alice", "score": 3} # Alice, score 3 desc
4535
+ assert results[1] == {"name": "Alice", "score": 1} # Alice, score 1 desc
4536
+ assert results[2] == {"name": "Bob", "score": 2} # Bob
4537
+
4283
4538
  @pytest.mark.asyncio
4284
4539
  async def test_delete_virtual_node_operation(self):
4285
4540
  """Test delete virtual node operation."""
@@ -1172,3 +1172,66 @@ class TestParser:
1172
1172
  parser = Parser()
1173
1173
  with pytest.raises(Exception, match="Expected MATCH after OPTIONAL"):
1174
1174
  parser.parse("OPTIONAL RETURN 1")
1175
+
1176
+ # ORDER BY expression tests
1177
+
1178
+ def test_order_by_simple_identifier(self):
1179
+ """Test ORDER BY with a simple identifier parses correctly."""
1180
+ parser = Parser()
1181
+ ast = parser.parse("unwind [1, 2] as x return x order by x")
1182
+ assert ast is not None
1183
+
1184
+ def test_order_by_property_access(self):
1185
+ """Test ORDER BY with property access parses correctly."""
1186
+ parser = Parser()
1187
+ ast = parser.parse(
1188
+ "unwind [{name: 'Bob'}, {name: 'Alice'}] as person "
1189
+ "return person.name as name order by person.name asc"
1190
+ )
1191
+ assert ast is not None
1192
+
1193
+ def test_order_by_function_call(self):
1194
+ """Test ORDER BY with function call parses correctly."""
1195
+ parser = Parser()
1196
+ ast = parser.parse(
1197
+ "unwind ['HELLO', 'WORLD'] as word "
1198
+ "return word order by toLower(word) asc"
1199
+ )
1200
+ assert ast is not None
1201
+
1202
+ def test_order_by_nested_function_calls(self):
1203
+ """Test ORDER BY with nested function calls parses correctly."""
1204
+ parser = Parser()
1205
+ ast = parser.parse(
1206
+ "unwind ['Alice', 'Bob'] as name "
1207
+ "return name order by string_distance(toLower(name), toLower('alice')) asc"
1208
+ )
1209
+ assert ast is not None
1210
+
1211
+ def test_order_by_arithmetic_expression(self):
1212
+ """Test ORDER BY with arithmetic expression parses correctly."""
1213
+ parser = Parser()
1214
+ ast = parser.parse(
1215
+ "unwind [{a: 3, b: 1}, {a: 1, b: 5}] as item "
1216
+ "return item.a as a, item.b as b order by item.a + item.b desc"
1217
+ )
1218
+ assert ast is not None
1219
+
1220
+ def test_order_by_multiple_expression_fields(self):
1221
+ """Test ORDER BY with multiple expression fields parses correctly."""
1222
+ parser = Parser()
1223
+ ast = parser.parse(
1224
+ "unwind [{a: 1, b: 2}] as item "
1225
+ "return item.a as a, item.b as b "
1226
+ "order by toLower(item.a) asc, item.b desc"
1227
+ )
1228
+ assert ast is not None
1229
+
1230
+ def test_order_by_expression_with_limit(self):
1231
+ """Test ORDER BY with expression and LIMIT parses correctly."""
1232
+ parser = Parser()
1233
+ ast = parser.parse(
1234
+ "unwind ['c', 'a', 'b'] as x "
1235
+ "return x order by toLower(x) asc limit 2"
1236
+ )
1237
+ assert ast is not None