flowquery 1.0.34 → 1.0.35

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 (66) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/graph/database.d.ts +1 -0
  3. package/dist/graph/database.d.ts.map +1 -1
  4. package/dist/graph/database.js +43 -6
  5. package/dist/graph/database.js.map +1 -1
  6. package/dist/graph/relationship.d.ts +3 -1
  7. package/dist/graph/relationship.d.ts.map +1 -1
  8. package/dist/graph/relationship.js +12 -4
  9. package/dist/graph/relationship.js.map +1 -1
  10. package/dist/graph/relationship_data.js +1 -1
  11. package/dist/graph/relationship_data.js.map +1 -1
  12. package/dist/graph/relationship_match_collector.d.ts.map +1 -1
  13. package/dist/graph/relationship_match_collector.js +6 -3
  14. package/dist/graph/relationship_match_collector.js.map +1 -1
  15. package/dist/graph/relationship_reference.js +1 -1
  16. package/dist/graph/relationship_reference.js.map +1 -1
  17. package/dist/parsing/functions/function_factory.d.ts +1 -0
  18. package/dist/parsing/functions/function_factory.d.ts.map +1 -1
  19. package/dist/parsing/functions/function_factory.js +1 -0
  20. package/dist/parsing/functions/function_factory.js.map +1 -1
  21. package/dist/parsing/functions/predicate_sum.d.ts.map +1 -1
  22. package/dist/parsing/functions/predicate_sum.js +13 -10
  23. package/dist/parsing/functions/predicate_sum.js.map +1 -1
  24. package/dist/parsing/functions/schema.d.ts +5 -2
  25. package/dist/parsing/functions/schema.d.ts.map +1 -1
  26. package/dist/parsing/functions/schema.js +7 -4
  27. package/dist/parsing/functions/schema.js.map +1 -1
  28. package/dist/parsing/functions/trim.d.ts +7 -0
  29. package/dist/parsing/functions/trim.d.ts.map +1 -0
  30. package/dist/parsing/functions/trim.js +37 -0
  31. package/dist/parsing/functions/trim.js.map +1 -0
  32. package/dist/parsing/operations/group_by.d.ts.map +1 -1
  33. package/dist/parsing/operations/group_by.js +4 -2
  34. package/dist/parsing/operations/group_by.js.map +1 -1
  35. package/dist/parsing/parser.d.ts.map +1 -1
  36. package/dist/parsing/parser.js +15 -2
  37. package/dist/parsing/parser.js.map +1 -1
  38. package/docs/flowquery.min.js +1 -1
  39. package/flowquery-py/pyproject.toml +1 -1
  40. package/flowquery-py/src/graph/database.py +44 -11
  41. package/flowquery-py/src/graph/relationship.py +11 -3
  42. package/flowquery-py/src/graph/relationship_data.py +2 -1
  43. package/flowquery-py/src/graph/relationship_match_collector.py +7 -1
  44. package/flowquery-py/src/graph/relationship_reference.py +2 -2
  45. package/flowquery-py/src/parsing/functions/__init__.py +2 -0
  46. package/flowquery-py/src/parsing/functions/predicate_sum.py +3 -6
  47. package/flowquery-py/src/parsing/functions/schema.py +9 -5
  48. package/flowquery-py/src/parsing/functions/trim.py +35 -0
  49. package/flowquery-py/src/parsing/operations/group_by.py +2 -0
  50. package/flowquery-py/src/parsing/parser.py +12 -2
  51. package/flowquery-py/tests/compute/test_runner.py +249 -4
  52. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  53. package/package.json +1 -1
  54. package/src/graph/database.ts +42 -4
  55. package/src/graph/relationship.ts +12 -4
  56. package/src/graph/relationship_data.ts +1 -1
  57. package/src/graph/relationship_match_collector.ts +6 -2
  58. package/src/graph/relationship_reference.ts +1 -1
  59. package/src/parsing/functions/function_factory.ts +1 -0
  60. package/src/parsing/functions/predicate_sum.ts +17 -12
  61. package/src/parsing/functions/schema.ts +7 -4
  62. package/src/parsing/functions/trim.ts +25 -0
  63. package/src/parsing/operations/group_by.ts +4 -1
  64. package/src/parsing/parser.ts +15 -2
  65. package/tests/compute/runner.test.ts +279 -3
  66. package/tests/parsing/parser.test.ts +37 -0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.24"
3
+ version = "1.0.25"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Dict, Optional, Union
5
+ from typing import Any, AsyncIterator, Dict, List, Optional, Union
6
6
 
7
7
  from ..parsing.ast_node import ASTNode
8
8
  from .node import Node
@@ -48,35 +48,57 @@ class Database:
48
48
  physical = PhysicalRelationship()
49
49
  physical.type = relationship.type
50
50
  physical.statement = statement
51
+ if relationship.source is not None:
52
+ physical.source = relationship.source
53
+ if relationship.target is not None:
54
+ physical.target = relationship.target
51
55
  Database._relationships[relationship.type] = physical
52
56
 
53
57
  def get_relationship(self, relationship: 'Relationship') -> Optional['PhysicalRelationship']:
54
58
  """Gets a relationship from the database."""
55
59
  return Database._relationships.get(relationship.type) if relationship.type else None
56
60
 
57
- async def schema(self) -> list[dict[str, Any]]:
61
+ def get_relationships(self, relationship: 'Relationship') -> list['PhysicalRelationship']:
62
+ """Gets multiple physical relationships for ORed types."""
63
+ result = []
64
+ for rel_type in relationship.types:
65
+ physical = Database._relationships.get(rel_type)
66
+ if physical:
67
+ result.append(physical)
68
+ return result
69
+
70
+ async def schema(self) -> List[Dict[str, Any]]:
58
71
  """Returns the graph schema with node/relationship labels and sample data."""
59
- result: list[dict[str, Any]] = []
72
+ return [item async for item in self._schema()]
60
73
 
74
+ async def _schema(self) -> AsyncIterator[Dict[str, Any]]:
75
+ """Async generator for graph schema with node/relationship labels and sample data."""
61
76
  for label, physical_node in Database._nodes.items():
62
77
  records = await physical_node.data()
63
- entry: dict[str, Any] = {"kind": "node", "label": label}
78
+ entry: Dict[str, Any] = {"kind": "Node", "label": label}
64
79
  if records:
65
80
  sample = {k: v for k, v in records[0].items() if k != "id"}
66
- if sample:
81
+ properties = list(sample.keys())
82
+ if properties:
83
+ entry["properties"] = properties
67
84
  entry["sample"] = sample
68
- result.append(entry)
85
+ yield entry
69
86
 
70
87
  for rel_type, physical_rel in Database._relationships.items():
71
88
  records = await physical_rel.data()
72
- entry_rel: dict[str, Any] = {"kind": "relationship", "type": rel_type}
89
+ entry_rel: Dict[str, Any] = {
90
+ "kind": "Relationship",
91
+ "type": rel_type,
92
+ "from_label": physical_rel.source.label if physical_rel.source else None,
93
+ "to_label": physical_rel.target.label if physical_rel.target else None,
94
+ }
73
95
  if records:
74
96
  sample = {k: v for k, v in records[0].items() if k not in ("left_id", "right_id")}
75
- if sample:
97
+ properties = list(sample.keys())
98
+ if properties:
99
+ entry_rel["properties"] = properties
76
100
  entry_rel["sample"] = sample
77
- result.append(entry_rel)
78
-
79
- return result
101
+ yield entry_rel
80
102
 
81
103
  async def get_data(self, element: Union['Node', 'Relationship']) -> Union['NodeData', 'RelationshipData']:
82
104
  """Gets data for a node or relationship."""
@@ -87,6 +109,17 @@ class Database:
87
109
  data = await node.data()
88
110
  return NodeData(data)
89
111
  elif isinstance(element, Relationship):
112
+ if len(element.types) > 1:
113
+ physicals = self.get_relationships(element)
114
+ if not physicals:
115
+ raise ValueError(f"No physical relationships found for types {', '.join(element.types)}")
116
+ all_records = []
117
+ for i, physical in enumerate(physicals):
118
+ records = await physical.data()
119
+ type_name = element.types[i]
120
+ for record in records:
121
+ all_records.append({**record, "_type": type_name})
122
+ return RelationshipData(all_records)
90
123
  relationship = self.get_relationship(element)
91
124
  if relationship is None:
92
125
  raise ValueError(f"Physical relationship not found for type {element.type}")
@@ -19,7 +19,7 @@ class Relationship(ASTNode):
19
19
  def __init__(self) -> None:
20
20
  super().__init__()
21
21
  self._identifier: Optional[str] = None
22
- self._type: Optional[str] = None
22
+ self._types: List[str] = []
23
23
  self._hops: Hops = Hops()
24
24
  self._source: Optional['Node'] = None
25
25
  self._target: Optional['Node'] = None
@@ -39,11 +39,19 @@ class Relationship(ASTNode):
39
39
 
40
40
  @property
41
41
  def type(self) -> Optional[str]:
42
- return self._type
42
+ return self._types[0] if self._types else None
43
43
 
44
44
  @type.setter
45
45
  def type(self, value: str) -> None:
46
- self._type = value
46
+ self._types = [value]
47
+
48
+ @property
49
+ def types(self) -> List[str]:
50
+ return self._types
51
+
52
+ @types.setter
53
+ def types(self, value: List[str]) -> None:
54
+ self._types = value
47
55
 
48
56
  @property
49
57
  def hops(self) -> Hops:
@@ -25,11 +25,12 @@ class RelationshipData(Data):
25
25
  return self._find(id, hop, key)
26
26
 
27
27
  def properties(self) -> Optional[Dict[str, Any]]:
28
- """Get properties of current relationship, excluding left_id and right_id."""
28
+ """Get properties of current relationship, excluding left_id, right_id, and _type."""
29
29
  current = self.current()
30
30
  if current:
31
31
  props = dict(current)
32
32
  props.pop("left_id", None)
33
33
  props.pop("right_id", None)
34
+ props.pop("_type", None)
34
35
  return props
35
36
  return None
@@ -28,9 +28,15 @@ class RelationshipMatchCollector:
28
28
  """Push a new match onto the collector."""
29
29
  start_node_value = relationship.source.value() if relationship.source else None
30
30
  rel_data = relationship.get_data()
31
+ current_record = rel_data.current() if rel_data else None
32
+ default_type = relationship.type or ""
33
+ if current_record and isinstance(current_record, dict):
34
+ actual_type = current_record.get('_type', default_type)
35
+ else:
36
+ actual_type = default_type
31
37
  rel_props: Dict[str, Any] = (rel_data.properties() or {}) if rel_data else {}
32
38
  match: RelationshipMatchRecord = {
33
- "type": relationship.type or "",
39
+ "type": actual_type,
34
40
  "startNode": start_node_value or {},
35
41
  "endNode": None,
36
42
  "properties": rel_props,
@@ -10,8 +10,8 @@ class RelationshipReference(Relationship):
10
10
  def __init__(self, relationship: Relationship, referred: ASTNode) -> None:
11
11
  super().__init__()
12
12
  self._referred = referred
13
- if relationship.type:
14
- self.type = relationship.type
13
+ if relationship.types:
14
+ self.types = relationship.types
15
15
 
16
16
  @property
17
17
  def referred(self) -> ASTNode:
@@ -39,6 +39,7 @@ from .sum import Sum
39
39
  from .to_json import ToJson
40
40
  from .to_lower import ToLower
41
41
  from .to_string import ToString
42
+ from .trim import Trim
42
43
  from .type_ import Type
43
44
  from .value_holder import ValueHolder
44
45
 
@@ -78,6 +79,7 @@ __all__ = [
78
79
  "ToJson",
79
80
  "ToLower",
80
81
  "ToString",
82
+ "Trim",
81
83
  "Type",
82
84
  "Functions",
83
85
  "Schema",
@@ -1,6 +1,6 @@
1
1
  """PredicateSum function."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  from .function_metadata import FunctionDef
6
6
  from .predicate_function import PredicateFunction
@@ -41,12 +41,9 @@ class PredicateSum(PredicateFunction):
41
41
  if array is None or not isinstance(array, list):
42
42
  raise ValueError("Invalid array for sum function")
43
43
 
44
- _sum: Optional[Any] = None
44
+ _sum: int = 0
45
45
  for item in array:
46
46
  self._value_holder.holder = item
47
47
  if self.where is None or self.where.value():
48
- if _sum is None:
49
- _sum = self._return.value()
50
- else:
51
- _sum += self._return.value()
48
+ _sum += self._return.value()
52
49
  return _sum
@@ -9,23 +9,27 @@ from .function_metadata import FunctionDef
9
9
  @FunctionDef({
10
10
  "description": (
11
11
  "Returns the graph schema listing all nodes and relationships "
12
- "with a sample of their data."
12
+ "with their properties and a sample of their data."
13
13
  ),
14
14
  "category": "async",
15
15
  "parameters": [],
16
16
  "output": {
17
- "description": "Schema entry with kind, label/type, and optional sample data",
17
+ "description": "Schema entry with label/type, properties, and optional sample data",
18
18
  "type": "object",
19
19
  },
20
20
  "examples": [
21
- "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample",
21
+ "CALL schema() YIELD label, type, from_label, to_label, properties, sample "
22
+ "RETURN label, type, from_label, to_label, properties, sample",
22
23
  ],
23
24
  })
24
25
  class Schema(AsyncFunction):
25
26
  """Returns the graph schema of the database.
26
27
 
27
- Lists all nodes and relationships with their labels/types and a sample
28
- of their data (excluding id from nodes, left_id and right_id from relationships).
28
+ Lists all nodes and relationships with their labels/types, properties,
29
+ and a sample of their data (excluding id from nodes, left_id and right_id from relationships).
30
+
31
+ Nodes: {label, properties, sample}
32
+ Relationships: {type, from_label, to_label, properties, sample}
29
33
  """
30
34
 
31
35
  async def generate(self) -> AsyncGenerator[Any, None]:
@@ -0,0 +1,35 @@
1
+ """Trim function."""
2
+
3
+ from typing import Any
4
+
5
+ from .function import Function
6
+ from .function_metadata import FunctionDef
7
+
8
+
9
+ @FunctionDef({
10
+ "description": "Removes leading and trailing whitespace from a string",
11
+ "category": "scalar",
12
+ "parameters": [
13
+ {"name": "text", "description": "String to trim", "type": "string"}
14
+ ],
15
+ "output": {"description": "Trimmed string", "type": "string", "example": "hello"},
16
+ "examples": [
17
+ "WITH ' hello ' AS s RETURN trim(s)",
18
+ "WITH '\\tfoo\\n' AS s RETURN trim(s)"
19
+ ]
20
+ })
21
+ class Trim(Function):
22
+ """Trim function.
23
+
24
+ Removes leading and trailing whitespace from a string.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__("trim")
29
+ self._expected_parameter_count = 1
30
+
31
+ def value(self) -> Any:
32
+ val = self.get_children()[0].value()
33
+ if not isinstance(val, str):
34
+ raise ValueError("Invalid argument for trim function: expected a string")
35
+ return val.strip()
@@ -122,6 +122,8 @@ class GroupBy(Projection):
122
122
  self.mappers[mapper_index].overridden = child.value
123
123
  yield from self.generate_results(mapper_index + 1, child)
124
124
  else:
125
+ if node.elements is None:
126
+ node.elements = [reducer.element() for reducer in self.reducers]
125
127
  if node.elements:
126
128
  for i, element in enumerate(node.elements):
127
129
  self.reducers[i].overridden = element.value
@@ -398,6 +398,8 @@ class Parser(BaseParser):
398
398
  raise ValueError("Expected target node definition")
399
399
  relationship = Relationship()
400
400
  relationship.type = rel_type
401
+ relationship.source = node
402
+ relationship.target = target
401
403
 
402
404
  self._expect_and_skip_whitespace_and_comments()
403
405
  if not self.token.is_as():
@@ -576,8 +578,16 @@ class Parser(BaseParser):
576
578
  self.set_next_token()
577
579
  if not self.token.is_identifier_or_keyword():
578
580
  raise ValueError("Expected relationship type identifier")
579
- rel_type: str = self.token.value or ""
581
+ rel_types: List[str] = [self.token.value or ""]
580
582
  self.set_next_token()
583
+ while self.token.is_pipe():
584
+ self.set_next_token()
585
+ if self.token.is_colon():
586
+ self.set_next_token()
587
+ if not self.token.is_identifier_or_keyword():
588
+ raise ValueError("Expected relationship type identifier after '|'")
589
+ rel_types.append(self.token.value or "")
590
+ self.set_next_token()
581
591
  hops = self._parse_relationship_hops()
582
592
  properties: Dict[str, Expression] = dict(self._parse_properties())
583
593
  if not self.token.is_closing_bracket():
@@ -607,7 +617,7 @@ class Parser(BaseParser):
607
617
  self._state.variables[variable] = relationship
608
618
  if hops is not None:
609
619
  relationship.hops = hops
610
- relationship.type = rel_type
620
+ relationship.types = rel_types
611
621
  return relationship
612
622
 
613
623
  def _parse_properties(self) -> Iterator[Tuple[str, Expression]]:
@@ -681,6 +681,42 @@ class TestRunner:
681
681
  assert len(results) == 1
682
682
  assert results[0] == {"result": "foo bar"}
683
683
 
684
+ @pytest.mark.asyncio
685
+ async def test_trim_function(self):
686
+ """Test trim function."""
687
+ runner = Runner('RETURN trim(" hello ") as result')
688
+ await runner.run()
689
+ results = runner.results
690
+ assert len(results) == 1
691
+ assert results[0] == {"result": "hello"}
692
+
693
+ @pytest.mark.asyncio
694
+ async def test_trim_function_with_tabs_and_newlines(self):
695
+ """Test trim function with tabs and newlines."""
696
+ runner = Runner('WITH "\tfoo\n" AS s RETURN trim(s) as result')
697
+ await runner.run()
698
+ results = runner.results
699
+ assert len(results) == 1
700
+ assert results[0] == {"result": "foo"}
701
+
702
+ @pytest.mark.asyncio
703
+ async def test_trim_function_with_no_whitespace(self):
704
+ """Test trim function with no whitespace."""
705
+ runner = Runner('RETURN trim("hello") as result')
706
+ await runner.run()
707
+ results = runner.results
708
+ assert len(results) == 1
709
+ assert results[0] == {"result": "hello"}
710
+
711
+ @pytest.mark.asyncio
712
+ async def test_trim_function_with_empty_string(self):
713
+ """Test trim function with empty string."""
714
+ runner = Runner('RETURN trim("") as result')
715
+ await runner.run()
716
+ results = runner.results
717
+ assert len(results) == 1
718
+ assert results[0] == {"result": ""}
719
+
684
720
  @pytest.mark.asyncio
685
721
  async def test_associative_array_with_key_which_is_keyword(self):
686
722
  """Test associative array with key which is keyword."""
@@ -2152,20 +2188,24 @@ class TestRunner:
2152
2188
  ).run()
2153
2189
 
2154
2190
  runner = Runner(
2155
- "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample"
2191
+ "CALL schema() YIELD kind, label, type, from_label, to_label, properties, sample RETURN kind, label, type, from_label, to_label, properties, sample"
2156
2192
  )
2157
2193
  await runner.run()
2158
2194
  results = runner.results
2159
2195
 
2160
- animal = next((r for r in results if r.get("kind") == "node" and r.get("label") == "Animal"), None)
2196
+ animal = next((r for r in results if r.get("kind") == "Node" and r.get("label") == "Animal"), None)
2161
2197
  assert animal is not None
2198
+ assert animal["properties"] == ["species", "legs"]
2162
2199
  assert animal["sample"] is not None
2163
2200
  assert "id" not in animal["sample"]
2164
2201
  assert "species" in animal["sample"]
2165
2202
  assert "legs" in animal["sample"]
2166
2203
 
2167
- chases = next((r for r in results if r.get("kind") == "relationship" and r.get("type") == "CHASES"), None)
2204
+ chases = next((r for r in results if r.get("kind") == "Relationship" and r.get("type") == "CHASES"), None)
2168
2205
  assert chases is not None
2206
+ assert chases["from_label"] == "Animal"
2207
+ assert chases["to_label"] == "Animal"
2208
+ assert chases["properties"] == ["speed"]
2169
2209
  assert chases["sample"] is not None
2170
2210
  assert "left_id" not in chases["sample"]
2171
2211
  assert "right_id" not in chases["sample"]
@@ -2549,6 +2589,64 @@ class TestRunner:
2549
2589
  # Add operator tests
2550
2590
  # ============================================================
2551
2591
 
2592
+ @pytest.mark.asyncio
2593
+ async def test_collected_patterns_and_unwind(self):
2594
+ """Test collecting graph patterns and unwinding them."""
2595
+ await Runner("""
2596
+ CREATE VIRTUAL (:Person) AS {
2597
+ unwind [
2598
+ {id: 1, name: 'Person 1'},
2599
+ {id: 2, name: 'Person 2'},
2600
+ {id: 3, name: 'Person 3'},
2601
+ {id: 4, name: 'Person 4'}
2602
+ ] as record
2603
+ RETURN record.id as id, record.name as name
2604
+ }
2605
+ """).run()
2606
+ await Runner("""
2607
+ CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
2608
+ unwind [
2609
+ {left_id: 1, right_id: 2},
2610
+ {left_id: 2, right_id: 3},
2611
+ {left_id: 3, right_id: 4}
2612
+ ] as record
2613
+ RETURN record.left_id as left_id, record.right_id as right_id
2614
+ }
2615
+ """).run()
2616
+ runner = Runner("""
2617
+ MATCH p=(a:Person)-[:KNOWS*0..3]->(b:Person)
2618
+ WITH collect(p) AS patterns
2619
+ UNWIND patterns AS pattern
2620
+ RETURN pattern
2621
+ """)
2622
+ await runner.run()
2623
+ results = runner.results
2624
+ assert len(results) == 10
2625
+ # Index 0: Person 1 zero-hop - pattern = [node1] (single node)
2626
+ assert len(results[0]["pattern"]) == 1
2627
+ assert results[0]["pattern"][0]["id"] == 1
2628
+ # Index 1: Person 1 -> Person 2 (1-hop)
2629
+ assert len(results[1]["pattern"]) == 3
2630
+ # Index 2: Person 1 -> Person 2 -> Person 3 (2-hop)
2631
+ assert len(results[2]["pattern"]) == 5
2632
+ # Index 3: Person 1 -> Person 2 -> Person 3 -> Person 4 (3-hop)
2633
+ assert len(results[3]["pattern"]) == 7
2634
+ # Index 4: Person 2 zero-hop
2635
+ assert len(results[4]["pattern"]) == 1
2636
+ assert results[4]["pattern"][0]["id"] == 2
2637
+ # Index 5: Person 2 -> Person 3 (1-hop)
2638
+ assert len(results[5]["pattern"]) == 3
2639
+ # Index 6: Person 2 -> Person 3 -> Person 4 (2-hop)
2640
+ assert len(results[6]["pattern"]) == 5
2641
+ # Index 7: Person 3 zero-hop
2642
+ assert len(results[7]["pattern"]) == 1
2643
+ assert results[7]["pattern"][0]["id"] == 3
2644
+ # Index 8: Person 3 -> Person 4 (1-hop)
2645
+ assert len(results[8]["pattern"]) == 3
2646
+ # Index 9: Person 4 zero-hop
2647
+ assert len(results[9]["pattern"]) == 1
2648
+ assert results[9]["pattern"][0]["id"] == 4
2649
+
2552
2650
  @pytest.mark.asyncio
2553
2651
  async def test_add_two_integers(self):
2554
2652
  """Test add two integers."""
@@ -2854,4 +2952,151 @@ class TestRunner:
2854
2952
  await runner.run()
2855
2953
  results = runner.results
2856
2954
  assert len(results) == 1
2857
- assert results == [{"x": 1}]
2955
+ assert results == [{"x": 1}]
2956
+
2957
+ @pytest.mark.asyncio
2958
+ async def test_language_name_hits_query_with_virtual_graph(self):
2959
+ """Test full language-name-hits query with virtual graph.
2960
+
2961
+ Reproduces the original bug: collect(distinct ...) on MATCH results,
2962
+ then sum(lang IN langs | ...) in a WITH clause, was throwing
2963
+ "Invalid array for sum function" because collect() returned null
2964
+ instead of [] when no rows entered aggregation.
2965
+ """
2966
+ # Create Language nodes
2967
+ await Runner(
2968
+ """
2969
+ CREATE VIRTUAL (:Language) AS {
2970
+ UNWIND [
2971
+ {id: 1, name: 'Python'},
2972
+ {id: 2, name: 'JavaScript'},
2973
+ {id: 3, name: 'TypeScript'}
2974
+ ] AS record
2975
+ RETURN record.id AS id, record.name AS name
2976
+ }
2977
+ """
2978
+ ).run()
2979
+
2980
+ # Create Chat nodes with messages
2981
+ await Runner(
2982
+ """
2983
+ CREATE VIRTUAL (:Chat) AS {
2984
+ UNWIND [
2985
+ {id: 1, name: 'Dev Discussion', messages: [
2986
+ {From: 'Alice', SentDateTime: '2025-01-01T10:00:00', Content: 'I love Python and JavaScript'},
2987
+ {From: 'Bob', SentDateTime: '2025-01-01T10:05:00', Content: 'What languages do you prefer?'}
2988
+ ]},
2989
+ {id: 2, name: 'General', messages: [
2990
+ {From: 'Charlie', SentDateTime: '2025-01-02T09:00:00', Content: 'The weather is nice today'},
2991
+ {From: 'Alice', SentDateTime: '2025-01-02T09:05:00', Content: 'TypeScript is great for language tooling'}
2992
+ ]}
2993
+ ] AS record
2994
+ RETURN record.id AS id, record.name AS name, record.messages AS messages
2995
+ }
2996
+ """
2997
+ ).run()
2998
+
2999
+ # Create User nodes
3000
+ await Runner(
3001
+ """
3002
+ CREATE VIRTUAL (:User) AS {
3003
+ UNWIND [
3004
+ {id: 1, displayName: 'Alice'},
3005
+ {id: 2, displayName: 'Bob'},
3006
+ {id: 3, displayName: 'Charlie'}
3007
+ ] AS record
3008
+ RETURN record.id AS id, record.displayName AS displayName
3009
+ }
3010
+ """
3011
+ ).run()
3012
+
3013
+ # Create PARTICIPATES_IN relationships
3014
+ await Runner(
3015
+ """
3016
+ CREATE VIRTUAL (:User)-[:PARTICIPATES_IN]-(:Chat) AS {
3017
+ UNWIND [
3018
+ {left_id: 1, right_id: 1},
3019
+ {left_id: 2, right_id: 1},
3020
+ {left_id: 3, right_id: 2},
3021
+ {left_id: 1, right_id: 2}
3022
+ ] AS record
3023
+ RETURN record.left_id AS left_id, record.right_id AS right_id
3024
+ }
3025
+ """
3026
+ ).run()
3027
+
3028
+ # Run the original query (using 'sender' alias since 'from' is a reserved keyword)
3029
+ runner = Runner(
3030
+ """
3031
+ MATCH (l:Language)
3032
+ WITH collect(distinct l.name) AS langs
3033
+ MATCH (c:Chat)
3034
+ UNWIND c.messages AS msg
3035
+ WITH c, msg, langs,
3036
+ sum(lang IN langs | 1 where toLower(msg.Content) CONTAINS toLower(lang)) AS langNameHits
3037
+ WHERE toLower(msg.Content) CONTAINS "language"
3038
+ OR toLower(msg.Content) CONTAINS "languages"
3039
+ OR langNameHits > 0
3040
+ OPTIONAL MATCH (u:User)-[:PARTICIPATES_IN]->(c)
3041
+ RETURN
3042
+ c.name AS chat,
3043
+ collect(distinct u.displayName) AS participants,
3044
+ msg.From AS sender,
3045
+ msg.SentDateTime AS sentDateTime,
3046
+ msg.Content AS message
3047
+ """
3048
+ )
3049
+ await runner.run()
3050
+ results = runner.results
3051
+
3052
+ # Messages that mention a language name or the word "language(s)":
3053
+ # 1. "I love Python and JavaScript" - langNameHits=2
3054
+ # 2. "What languages do you prefer?" - contains "languages"
3055
+ # 3. "TypeScript is great for language tooling" - langNameHits=1, also "language"
3056
+ assert len(results) == 3
3057
+ assert results[0]["chat"] == "Dev Discussion"
3058
+ assert results[0]["message"] == "I love Python and JavaScript"
3059
+ assert results[0]["sender"] == "Alice"
3060
+ assert results[1]["chat"] == "Dev Discussion"
3061
+ assert results[1]["message"] == "What languages do you prefer?"
3062
+ assert results[1]["sender"] == "Bob"
3063
+ assert results[2]["chat"] == "General"
3064
+ assert results[2]["message"] == "TypeScript is great for language tooling"
3065
+ assert results[2]["sender"] == "Alice"
3066
+
3067
+ @pytest.mark.asyncio
3068
+ async def test_sum_with_empty_collected_array(self):
3069
+ """Reproduces the original bug: collect on empty input should yield []
3070
+ and sum over that empty array should return 0, not throw."""
3071
+ runner = Runner(
3072
+ """
3073
+ UNWIND [] AS lang
3074
+ WITH collect(distinct lang) AS langs
3075
+ UNWIND ['hello', 'world'] AS msg
3076
+ WITH msg, langs, sum(l IN langs | 1 where toLower(msg) CONTAINS toLower(l)) AS hits
3077
+ RETURN msg, hits
3078
+ """
3079
+ )
3080
+ await runner.run()
3081
+ results = runner.results
3082
+ assert len(results) == 2
3083
+ assert results[0] == {"msg": "hello", "hits": 0}
3084
+ assert results[1] == {"msg": "world", "hits": 0}
3085
+
3086
+ @pytest.mark.asyncio
3087
+ async def test_sum_where_all_elements_filtered_returns_0(self):
3088
+ """Test sum returns 0 when where clause filters everything."""
3089
+ runner = Runner("RETURN sum(n in [1, 2, 3] | n where n > 100) as sum")
3090
+ await runner.run()
3091
+ results = runner.results
3092
+ assert len(results) == 1
3093
+ assert results[0] == {"sum": 0}
3094
+
3095
+ @pytest.mark.asyncio
3096
+ async def test_sum_over_empty_array_returns_0(self):
3097
+ """Test sum over empty array returns 0."""
3098
+ runner = Runner("WITH [] AS arr RETURN sum(n in arr | n) as sum")
3099
+ await runner.run()
3100
+ results = runner.results
3101
+ assert len(results) == 1
3102
+ assert results[0] == {"sum": 0}