flowquery 1.0.30 → 1.0.32

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 (41) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/graph/node_reference.d.ts +3 -2
  3. package/dist/graph/node_reference.d.ts.map +1 -1
  4. package/dist/graph/node_reference.js.map +1 -1
  5. package/dist/graph/relationship.d.ts +2 -1
  6. package/dist/graph/relationship.d.ts.map +1 -1
  7. package/dist/graph/relationship.js +15 -15
  8. package/dist/graph/relationship.js.map +1 -1
  9. package/dist/graph/relationship_data.d.ts +1 -2
  10. package/dist/graph/relationship_data.d.ts.map +1 -1
  11. package/dist/graph/relationship_data.js +2 -5
  12. package/dist/graph/relationship_data.js.map +1 -1
  13. package/dist/graph/relationship_match_collector.d.ts +2 -2
  14. package/dist/graph/relationship_match_collector.d.ts.map +1 -1
  15. package/dist/graph/relationship_match_collector.js +6 -7
  16. package/dist/graph/relationship_match_collector.js.map +1 -1
  17. package/dist/parsing/operations/group_by.d.ts.map +1 -1
  18. package/dist/parsing/operations/group_by.js +8 -4
  19. package/dist/parsing/operations/group_by.js.map +1 -1
  20. package/dist/parsing/parser.d.ts.map +1 -1
  21. package/dist/parsing/parser.js +2 -1
  22. package/dist/parsing/parser.js.map +1 -1
  23. package/docs/flowquery.min.js +1 -1
  24. package/flowquery-py/pyproject.toml +1 -1
  25. package/flowquery-py/src/graph/node_reference.py +5 -4
  26. package/flowquery-py/src/graph/relationship.py +20 -22
  27. package/flowquery-py/src/graph/relationship_data.py +4 -7
  28. package/flowquery-py/src/graph/relationship_match_collector.py +5 -7
  29. package/flowquery-py/src/parsing/operations/group_by.py +16 -2
  30. package/flowquery-py/src/parsing/parser.py +1 -1
  31. package/flowquery-py/tests/compute/test_runner.py +61 -5
  32. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  33. package/package.json +1 -1
  34. package/src/graph/node_reference.ts +4 -3
  35. package/src/graph/relationship.ts +15 -15
  36. package/src/graph/relationship_data.ts +2 -5
  37. package/src/graph/relationship_match_collector.ts +6 -7
  38. package/src/parsing/operations/group_by.ts +27 -19
  39. package/src/parsing/parser.ts +4 -1
  40. package/tests/compute/runner.test.ts +123 -4
  41. package/tests/parsing/parser.test.ts +2 -2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.20"
3
+ version = "1.0.22"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,27 +2,28 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, Optional
4
4
 
5
+ from ..parsing.ast_node import ASTNode
5
6
  from .node import Node
6
7
 
7
8
 
8
9
  class NodeReference(Node):
9
10
  """Represents a reference to an existing node variable."""
10
11
 
11
- def __init__(self, base: Node, reference: Node) -> None:
12
+ def __init__(self, base: Node, reference: ASTNode) -> None:
12
13
  super().__init__(base.identifier, base.label)
13
- self._reference: Node = reference
14
+ self._reference: ASTNode = reference
14
15
  # Copy properties from base
15
16
  self._properties = base._properties
16
17
  self._outgoing = base.outgoing
17
18
  self._incoming = base.incoming
18
19
 
19
20
  @property
20
- def reference(self) -> Node:
21
+ def reference(self) -> ASTNode:
21
22
  return self._reference
22
23
 
23
24
  # Keep referred as alias for backward compatibility
24
25
  @property
25
- def referred(self) -> Node:
26
+ def referred(self) -> ASTNode:
26
27
  return self._reference
27
28
 
28
29
  def value(self) -> Optional[Any]:
@@ -123,9 +123,9 @@ class Relationship(ASTNode):
123
123
  def get_data(self) -> Optional['RelationshipData']:
124
124
  return self._data
125
125
 
126
- def set_value(self, relationship: 'Relationship') -> None:
126
+ def set_value(self, relationship: 'Relationship', traversal_id: str = "") -> None:
127
127
  """Set value by pushing match to collector."""
128
- self._matches.push(relationship)
128
+ self._matches.push(relationship, traversal_id)
129
129
  self._value = self._matches.value()
130
130
 
131
131
  def value(self) -> Optional[Union[RelationshipMatchRecord, List[RelationshipMatchRecord]]]:
@@ -139,11 +139,13 @@ class Relationship(ASTNode):
139
139
  """Set the end node for the current match."""
140
140
  self._matches.end_node = node
141
141
 
142
+ def _left_id_or_right_id(self) -> str:
143
+ return "left_id" if self._direction == "left" else "right_id"
144
+
142
145
  async def find(self, left_id: str, hop: int = 0) -> None:
143
146
  """Find relationships starting from the given node ID."""
144
147
  # Save original source node
145
148
  original = self._source
146
- is_left = self._direction == "left"
147
149
  if hop > 0:
148
150
  # For hops greater than 0, the source becomes the target of the previous hop
149
151
  self._source = self._target
@@ -158,30 +160,26 @@ class Relationship(ASTNode):
158
160
  # No relationship match is pushed since no edge is traversed
159
161
  await self._target.find(left_id, hop)
160
162
 
161
- def find_match(id_: str, h: int) -> bool:
162
- if self._data is None:
163
- return False
164
- if is_left:
165
- return self._data.find_reverse(id_, h)
166
- return self._data.find(id_, h)
167
- follow_id = 'left_id' if is_left else 'right_id'
168
- while self._data and find_match(left_id, hop):
163
+ while self._data and self._data.find(left_id, hop, self._direction):
169
164
  data = self._data.current(hop)
170
- if data and self._hops and hop + 1 >= self._hops.min:
171
- self.set_value(self)
165
+ if data is None:
166
+ continue
167
+ id = data[self._left_id_or_right_id()]
168
+ if hop + 1 >= self._hops.min:
169
+ self.set_value(self, left_id)
172
170
  if not self._matches_properties(hop):
173
171
  continue
174
- if self._target and follow_id in data:
175
- await self._target.find(data[follow_id], hop)
176
- if self._matches.is_circular():
177
- raise ValueError("Circular relationship detected")
178
- if self._hops and hop + 1 < self._hops.max:
179
- await self.find(data[follow_id], hop + 1)
172
+ if self._target:
173
+ await self._target.find(id, hop)
174
+ if hop + 1 < self._hops.max:
175
+ if self._matches.is_circular(id):
176
+ self._matches.pop()
177
+ continue
178
+ await self.find(id, hop + 1)
180
179
  self._matches.pop()
181
- elif data and self._hops:
180
+ else:
182
181
  # Below minimum hops: traverse the edge without yielding a match
183
- if follow_id in data:
184
- await self.find(data[follow_id], hop + 1)
182
+ await self.find(id, hop + 1)
185
183
 
186
184
  # Restore original source node
187
185
  self._source = original
@@ -19,13 +19,10 @@ class RelationshipData(Data):
19
19
  self._build_index("left_id")
20
20
  self._build_index("right_id")
21
21
 
22
- def find(self, left_id: str, hop: int = 0) -> bool:
23
- """Find a relationship by start node ID."""
24
- return self._find(left_id, hop, "left_id")
25
-
26
- def find_reverse(self, right_id: str, hop: int = 0) -> bool:
27
- """Find a relationship by end node ID (reverse direction)."""
28
- return self._find(right_id, hop, "right_id")
22
+ def find(self, id: str, hop: int = 0, direction: str = "right") -> bool:
23
+ """Find a relationship by node ID and direction."""
24
+ key = "right_id" if direction == "left" else "left_id"
25
+ return self._find(id, hop, key)
29
26
 
30
27
  def properties(self) -> Optional[Dict[str, Any]]:
31
28
  """Get properties of current relationship, excluding left_id and right_id."""
@@ -24,7 +24,7 @@ class RelationshipMatchCollector:
24
24
  self._matches: List[RelationshipMatchRecord] = []
25
25
  self._node_ids: List[str] = []
26
26
 
27
- def push(self, relationship: 'Relationship') -> RelationshipMatchRecord:
27
+ def push(self, relationship: 'Relationship', traversal_id: str = "") -> RelationshipMatchRecord:
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()
@@ -36,8 +36,7 @@ class RelationshipMatchCollector:
36
36
  "properties": rel_props,
37
37
  }
38
38
  self._matches.append(match)
39
- if isinstance(start_node_value, dict):
40
- self._node_ids.append(start_node_value.get("id", ""))
39
+ self._node_ids.append(traversal_id)
41
40
  return match
42
41
 
43
42
  @property
@@ -76,7 +75,6 @@ class RelationshipMatchCollector:
76
75
  """Get all matches."""
77
76
  return self._matches
78
77
 
79
- def is_circular(self) -> bool:
80
- """Check if the collected relationships form a circular pattern."""
81
- seen = set(self._node_ids)
82
- return len(seen) < len(self._node_ids)
78
+ def is_circular(self, next_id: str = "") -> bool:
79
+ """Check if traversing to the given node id would form a cycle."""
80
+ return next_id in self._node_ids
@@ -1,5 +1,6 @@
1
1
  """GroupBy implementation for aggregate operations."""
2
2
 
3
+ import json
3
4
  from typing import Any, Dict, Generator, List, Optional
4
5
 
5
6
  from ..ast_node import ASTNode
@@ -8,6 +9,15 @@ from ..functions.reducer_element import ReducerElement
8
9
  from .projection import Projection
9
10
 
10
11
 
12
+ def _make_hashable(value: Any) -> Any:
13
+ """Convert a value to a hashable form for use as a dict key."""
14
+ if isinstance(value, dict):
15
+ return json.dumps(value, sort_keys=True, default=str)
16
+ if isinstance(value, list):
17
+ return json.dumps(value, sort_keys=True, default=str)
18
+ return value
19
+
20
+
11
21
  class GroupByNode:
12
22
  """Represents a node in the group-by tree."""
13
23
 
@@ -60,10 +70,11 @@ class GroupBy(Projection):
60
70
  node = self._current
61
71
  for mapper in self.mappers:
62
72
  value = mapper.value()
63
- child = node.children.get(value)
73
+ key = _make_hashable(value)
74
+ child = node.children.get(key)
64
75
  if child is None:
65
76
  child = GroupByNode(value)
66
- node.children[value] = child
77
+ node.children[key] = child
67
78
  node = child
68
79
  self._current = node
69
80
 
@@ -103,6 +114,9 @@ class GroupBy(Projection):
103
114
  if node is None:
104
115
  node = self._root
105
116
 
117
+ if mapper_index == 0 and len(node.children) == 0 and len(self.mappers) > 0:
118
+ return
119
+
106
120
  if len(node.children) > 0:
107
121
  for child in node.children.values():
108
122
  self.mappers[mapper_index].overridden = child.value
@@ -499,7 +499,7 @@ class Parser(BaseParser):
499
499
  inner = ref_child.referred
500
500
  if isinstance(inner, Node):
501
501
  reference = inner
502
- if reference is None or not isinstance(reference, Node):
502
+ if reference is None or (not isinstance(reference, Node) and not isinstance(reference, Unwind)):
503
503
  raise ValueError(f"Undefined node reference: {identifier}")
504
504
  node = NodeReference(node, reference)
505
505
  elif identifier is not None:
@@ -378,6 +378,21 @@ class TestRunner:
378
378
  assert results[0] == {"i": 1, "sum": 12}
379
379
  assert results[1] == {"i": 2, "sum": 12}
380
380
 
381
+ @pytest.mark.asyncio
382
+ async def test_aggregated_with_on_empty_result_set(self):
383
+ """Test aggregated with on empty result set does not crash."""
384
+ runner = Runner(
385
+ """
386
+ unwind [] as i
387
+ unwind [1, 2] as j
388
+ with i, count(j) as cnt
389
+ return i, cnt
390
+ """
391
+ )
392
+ await runner.run()
393
+ results = runner.results
394
+ assert len(results) == 0
395
+
381
396
  @pytest.mark.asyncio
382
397
  async def test_aggregated_with_using_collect_and_return(self):
383
398
  """Test aggregated with using collect and return."""
@@ -1279,8 +1294,8 @@ class TestRunner:
1279
1294
  assert len(results) == 2
1280
1295
 
1281
1296
  @pytest.mark.asyncio
1282
- async def test_circular_graph_pattern_with_variable_length_should_throw_error(self):
1283
- """Test circular graph pattern with variable length should throw error."""
1297
+ async def test_circular_graph_pattern_with_variable_length_should_not_revisit_nodes(self):
1298
+ """Test circular graph pattern with variable length should not revisit nodes."""
1284
1299
  await Runner(
1285
1300
  """
1286
1301
  CREATE VIRTUAL (:CircularVarPerson) AS {
@@ -1309,8 +1324,10 @@ class TestRunner:
1309
1324
  RETURN p AS pattern
1310
1325
  """
1311
1326
  )
1312
- with pytest.raises(ValueError, match="Circular relationship detected"):
1313
- await match.run()
1327
+ await match.run()
1328
+ results = match.results
1329
+ # Circular graph 1↔2: cycles are skipped, only acyclic paths are returned
1330
+ assert len(results) == 6
1314
1331
 
1315
1332
  @pytest.mark.asyncio
1316
1333
  async def test_multi_hop_match_with_min_hops_constraint_1(self):
@@ -2212,4 +2229,43 @@ class TestRunner:
2212
2229
  await runner.run()
2213
2230
  results = runner.results
2214
2231
  assert len(results) == 1
2215
- assert results[0]["fruit"] == "pineapple"
2232
+ assert results[0]["fruit"] == "pineapple"
2233
+
2234
+ @pytest.mark.asyncio
2235
+ async def test_collected_nodes_and_re_matching(self):
2236
+ """Test that collected nodes can be unwound and used as node references in subsequent MATCH."""
2237
+ await Runner("""
2238
+ CREATE VIRTUAL (:Person) AS {
2239
+ unwind [
2240
+ {id: 1, name: 'Person 1'},
2241
+ {id: 2, name: 'Person 2'},
2242
+ {id: 3, name: 'Person 3'},
2243
+ {id: 4, name: 'Person 4'}
2244
+ ] as record
2245
+ RETURN record.id as id, record.name as name
2246
+ }
2247
+ """).run()
2248
+ await Runner("""
2249
+ CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
2250
+ unwind [
2251
+ {left_id: 1, right_id: 2},
2252
+ {left_id: 2, right_id: 3},
2253
+ {left_id: 3, right_id: 4}
2254
+ ] as record
2255
+ RETURN record.left_id as left_id, record.right_id as right_id
2256
+ }
2257
+ """).run()
2258
+ runner = Runner("""
2259
+ MATCH (a:Person)-[:KNOWS*0..3]->(b:Person)
2260
+ WITH collect(a) AS persons, b
2261
+ UNWIND persons AS p
2262
+ match (p)-[:KNOWS]->(:Person)
2263
+ return p.name AS name
2264
+ """)
2265
+ await runner.run()
2266
+ results = runner.results
2267
+ assert len(results) == 9
2268
+ names = [r["name"] for r in results]
2269
+ assert "Person 1" in names
2270
+ assert "Person 2" in names
2271
+ assert "Person 3" in names