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.
- package/dist/flowquery.min.js +1 -1
- package/dist/graph/node_reference.d.ts +3 -2
- package/dist/graph/node_reference.d.ts.map +1 -1
- package/dist/graph/node_reference.js.map +1 -1
- package/dist/graph/relationship.d.ts +2 -1
- package/dist/graph/relationship.d.ts.map +1 -1
- package/dist/graph/relationship.js +15 -15
- package/dist/graph/relationship.js.map +1 -1
- package/dist/graph/relationship_data.d.ts +1 -2
- package/dist/graph/relationship_data.d.ts.map +1 -1
- package/dist/graph/relationship_data.js +2 -5
- package/dist/graph/relationship_data.js.map +1 -1
- package/dist/graph/relationship_match_collector.d.ts +2 -2
- package/dist/graph/relationship_match_collector.d.ts.map +1 -1
- package/dist/graph/relationship_match_collector.js +6 -7
- package/dist/graph/relationship_match_collector.js.map +1 -1
- package/dist/parsing/operations/group_by.d.ts.map +1 -1
- package/dist/parsing/operations/group_by.js +8 -4
- package/dist/parsing/operations/group_by.js.map +1 -1
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +2 -1
- package/dist/parsing/parser.js.map +1 -1
- package/docs/flowquery.min.js +1 -1
- package/flowquery-py/pyproject.toml +1 -1
- package/flowquery-py/src/graph/node_reference.py +5 -4
- package/flowquery-py/src/graph/relationship.py +20 -22
- package/flowquery-py/src/graph/relationship_data.py +4 -7
- package/flowquery-py/src/graph/relationship_match_collector.py +5 -7
- package/flowquery-py/src/parsing/operations/group_by.py +16 -2
- package/flowquery-py/src/parsing/parser.py +1 -1
- package/flowquery-py/tests/compute/test_runner.py +61 -5
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/graph/node_reference.ts +4 -3
- package/src/graph/relationship.ts +15 -15
- package/src/graph/relationship_data.ts +2 -5
- package/src/graph/relationship_match_collector.ts +6 -7
- package/src/parsing/operations/group_by.ts +27 -19
- package/src/parsing/parser.ts +4 -1
- package/tests/compute/runner.test.ts +123 -4
- package/tests/parsing/parser.test.ts +2 -2
|
@@ -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:
|
|
12
|
+
def __init__(self, base: Node, reference: ASTNode) -> None:
|
|
12
13
|
super().__init__(base.identifier, base.label)
|
|
13
|
-
self._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) ->
|
|
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) ->
|
|
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
|
-
|
|
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
|
|
171
|
-
|
|
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
|
|
175
|
-
await self._target.find(
|
|
176
|
-
if self.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
180
|
+
else:
|
|
182
181
|
# Below minimum hops: traverse the edge without yielding a match
|
|
183
|
-
|
|
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,
|
|
23
|
-
"""Find a relationship by
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
|
1283
|
-
"""Test circular graph pattern with variable length should
|
|
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
|
-
|
|
1313
|
-
|
|
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
|