flowquery 1.0.30 → 1.0.31
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/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 +13 -2
- package/flowquery-py/src/parsing/parser.py +1 -1
- package/flowquery-py/tests/compute/test_runner.py +46 -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/parser.ts +4 -1
- package/tests/compute/runner.test.ts +109 -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
|
|
|
@@ -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:
|
|
@@ -1279,8 +1279,8 @@ class TestRunner:
|
|
|
1279
1279
|
assert len(results) == 2
|
|
1280
1280
|
|
|
1281
1281
|
@pytest.mark.asyncio
|
|
1282
|
-
async def
|
|
1283
|
-
"""Test circular graph pattern with variable length should
|
|
1282
|
+
async def test_circular_graph_pattern_with_variable_length_should_not_revisit_nodes(self):
|
|
1283
|
+
"""Test circular graph pattern with variable length should not revisit nodes."""
|
|
1284
1284
|
await Runner(
|
|
1285
1285
|
"""
|
|
1286
1286
|
CREATE VIRTUAL (:CircularVarPerson) AS {
|
|
@@ -1309,8 +1309,10 @@ class TestRunner:
|
|
|
1309
1309
|
RETURN p AS pattern
|
|
1310
1310
|
"""
|
|
1311
1311
|
)
|
|
1312
|
-
|
|
1313
|
-
|
|
1312
|
+
await match.run()
|
|
1313
|
+
results = match.results
|
|
1314
|
+
# Circular graph 1↔2: cycles are skipped, only acyclic paths are returned
|
|
1315
|
+
assert len(results) == 6
|
|
1314
1316
|
|
|
1315
1317
|
@pytest.mark.asyncio
|
|
1316
1318
|
async def test_multi_hop_match_with_min_hops_constraint_1(self):
|
|
@@ -2212,4 +2214,43 @@ class TestRunner:
|
|
|
2212
2214
|
await runner.run()
|
|
2213
2215
|
results = runner.results
|
|
2214
2216
|
assert len(results) == 1
|
|
2215
|
-
assert results[0]["fruit"] == "pineapple"
|
|
2217
|
+
assert results[0]["fruit"] == "pineapple"
|
|
2218
|
+
|
|
2219
|
+
@pytest.mark.asyncio
|
|
2220
|
+
async def test_collected_nodes_and_re_matching(self):
|
|
2221
|
+
"""Test that collected nodes can be unwound and used as node references in subsequent MATCH."""
|
|
2222
|
+
await Runner("""
|
|
2223
|
+
CREATE VIRTUAL (:Person) AS {
|
|
2224
|
+
unwind [
|
|
2225
|
+
{id: 1, name: 'Person 1'},
|
|
2226
|
+
{id: 2, name: 'Person 2'},
|
|
2227
|
+
{id: 3, name: 'Person 3'},
|
|
2228
|
+
{id: 4, name: 'Person 4'}
|
|
2229
|
+
] as record
|
|
2230
|
+
RETURN record.id as id, record.name as name
|
|
2231
|
+
}
|
|
2232
|
+
""").run()
|
|
2233
|
+
await Runner("""
|
|
2234
|
+
CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
|
|
2235
|
+
unwind [
|
|
2236
|
+
{left_id: 1, right_id: 2},
|
|
2237
|
+
{left_id: 2, right_id: 3},
|
|
2238
|
+
{left_id: 3, right_id: 4}
|
|
2239
|
+
] as record
|
|
2240
|
+
RETURN record.left_id as left_id, record.right_id as right_id
|
|
2241
|
+
}
|
|
2242
|
+
""").run()
|
|
2243
|
+
runner = Runner("""
|
|
2244
|
+
MATCH (a:Person)-[:KNOWS*0..3]->(b:Person)
|
|
2245
|
+
WITH collect(a) AS persons, b
|
|
2246
|
+
UNWIND persons AS p
|
|
2247
|
+
match (p)-[:KNOWS]->(:Person)
|
|
2248
|
+
return p.name AS name
|
|
2249
|
+
""")
|
|
2250
|
+
await runner.run()
|
|
2251
|
+
results = runner.results
|
|
2252
|
+
assert len(results) == 9
|
|
2253
|
+
names = [r["name"] for r in results]
|
|
2254
|
+
assert "Person 1" in names
|
|
2255
|
+
assert "Person 2" in names
|
|
2256
|
+
assert "Person 3" in names
|