flowquery 1.0.39 → 1.0.40
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/database.d.ts +2 -0
- package/dist/graph/database.d.ts.map +1 -1
- package/dist/graph/database.js +12 -0
- package/dist/graph/database.js.map +1 -1
- package/dist/parsing/functions/function_factory.d.ts +1 -0
- package/dist/parsing/functions/function_factory.d.ts.map +1 -1
- package/dist/parsing/functions/function_factory.js +1 -0
- package/dist/parsing/functions/function_factory.js.map +1 -1
- package/dist/parsing/functions/substring.d.ts +9 -0
- package/dist/parsing/functions/substring.d.ts.map +1 -0
- package/dist/parsing/functions/substring.js +62 -0
- package/dist/parsing/functions/substring.js.map +1 -0
- package/dist/parsing/operations/delete_node.d.ts +11 -0
- package/dist/parsing/operations/delete_node.d.ts.map +1 -0
- package/dist/parsing/operations/delete_node.js +46 -0
- package/dist/parsing/operations/delete_node.js.map +1 -0
- package/dist/parsing/operations/delete_relationship.d.ts +11 -0
- package/dist/parsing/operations/delete_relationship.d.ts.map +1 -0
- package/dist/parsing/operations/delete_relationship.js +46 -0
- package/dist/parsing/operations/delete_relationship.js.map +1 -0
- package/dist/parsing/parser.d.ts +1 -0
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +62 -2
- 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/database.py +12 -0
- package/flowquery-py/src/parsing/functions/__init__.py +2 -0
- package/flowquery-py/src/parsing/functions/substring.py +74 -0
- package/flowquery-py/src/parsing/operations/__init__.py +4 -0
- package/flowquery-py/src/parsing/operations/delete_node.py +29 -0
- package/flowquery-py/src/parsing/operations/delete_relationship.py +29 -0
- package/flowquery-py/src/parsing/parser.py +54 -3
- package/flowquery-py/tests/compute/test_runner.py +171 -1
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/graph/database.ts +12 -0
- package/src/parsing/functions/function_factory.ts +1 -0
- package/src/parsing/functions/substring.ts +65 -0
- package/src/parsing/operations/delete_node.ts +33 -0
- package/src/parsing/operations/delete_relationship.ts +32 -0
- package/src/parsing/parser.ts +63 -2
- package/tests/compute/runner.test.ts +147 -0
- package/tests/parsing/parser.test.ts +1 -1
|
@@ -37,6 +37,12 @@ class Database:
|
|
|
37
37
|
physical.statement = statement
|
|
38
38
|
Database._nodes[node.label] = physical
|
|
39
39
|
|
|
40
|
+
def remove_node(self, node: 'Node') -> None:
|
|
41
|
+
"""Removes a node from the database."""
|
|
42
|
+
if node.label is None:
|
|
43
|
+
raise ValueError("Node label is null")
|
|
44
|
+
Database._nodes.pop(node.label, None)
|
|
45
|
+
|
|
40
46
|
def get_node(self, node: 'Node') -> Optional['PhysicalNode']:
|
|
41
47
|
"""Gets a node from the database."""
|
|
42
48
|
return Database._nodes.get(node.label) if node.label else None
|
|
@@ -54,6 +60,12 @@ class Database:
|
|
|
54
60
|
physical.target = relationship.target
|
|
55
61
|
Database._relationships[relationship.type] = physical
|
|
56
62
|
|
|
63
|
+
def remove_relationship(self, relationship: 'Relationship') -> None:
|
|
64
|
+
"""Removes a relationship from the database."""
|
|
65
|
+
if relationship.type is None:
|
|
66
|
+
raise ValueError("Relationship type is null")
|
|
67
|
+
Database._relationships.pop(relationship.type, None)
|
|
68
|
+
|
|
57
69
|
def get_relationship(self, relationship: 'Relationship') -> Optional['PhysicalRelationship']:
|
|
58
70
|
"""Gets a relationship from the database."""
|
|
59
71
|
return Database._relationships.get(relationship.type) if relationship.type else None
|
|
@@ -48,6 +48,7 @@ from .size import Size
|
|
|
48
48
|
from .split import Split
|
|
49
49
|
from .string_distance import StringDistance
|
|
50
50
|
from .stringify import Stringify
|
|
51
|
+
from .substring import Substring
|
|
51
52
|
from .sum import Sum
|
|
52
53
|
from .tail import Tail
|
|
53
54
|
from .time_ import Time
|
|
@@ -107,6 +108,7 @@ __all__ = [
|
|
|
107
108
|
"Split",
|
|
108
109
|
"StringDistance",
|
|
109
110
|
"Stringify",
|
|
111
|
+
"Substring",
|
|
110
112
|
"Tail",
|
|
111
113
|
"Time",
|
|
112
114
|
"Timestamp",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Substring function."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List
|
|
4
|
+
|
|
5
|
+
from ..ast_node import ASTNode
|
|
6
|
+
from .function import Function
|
|
7
|
+
from .function_metadata import FunctionDef
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@FunctionDef({
|
|
11
|
+
"description": "Returns a substring of a string, starting at a 0-based index with an optional length",
|
|
12
|
+
"category": "scalar",
|
|
13
|
+
"parameters": [
|
|
14
|
+
{"name": "original", "description": "The original string", "type": "string"},
|
|
15
|
+
{"name": "start", "description": "The 0-based start index", "type": "integer"},
|
|
16
|
+
{
|
|
17
|
+
"name": "length",
|
|
18
|
+
"description": "The length of the substring (optional)",
|
|
19
|
+
"type": "integer",
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"output": {"description": "The substring", "type": "string", "example": "llo"},
|
|
23
|
+
"examples": [
|
|
24
|
+
"RETURN substring('hello', 1, 3)",
|
|
25
|
+
"RETURN substring('hello', 2)"
|
|
26
|
+
]
|
|
27
|
+
})
|
|
28
|
+
class Substring(Function):
|
|
29
|
+
"""Substring function.
|
|
30
|
+
|
|
31
|
+
Returns a substring of a string, starting at a 0-based index with an optional length.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
super().__init__("substring")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def parameters(self) -> List[ASTNode]:
|
|
39
|
+
return self.get_children()
|
|
40
|
+
|
|
41
|
+
@parameters.setter
|
|
42
|
+
def parameters(self, nodes: List[ASTNode]) -> None:
|
|
43
|
+
if len(nodes) < 2 or len(nodes) > 3:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Function substring expected 2 or 3 parameters, but got {len(nodes)}"
|
|
46
|
+
)
|
|
47
|
+
for node in nodes:
|
|
48
|
+
self.add_child(node)
|
|
49
|
+
|
|
50
|
+
def value(self) -> Any:
|
|
51
|
+
children = self.get_children()
|
|
52
|
+
original = children[0].value()
|
|
53
|
+
start = children[1].value()
|
|
54
|
+
|
|
55
|
+
if not isinstance(original, str):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"Invalid argument for substring function: expected a string as the first argument"
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(start, (int, float)) or (isinstance(start, float) and not start.is_integer()):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"Invalid argument for substring function: expected an integer as the second argument"
|
|
62
|
+
)
|
|
63
|
+
start = int(start)
|
|
64
|
+
|
|
65
|
+
if len(children) == 3:
|
|
66
|
+
length = children[2].value()
|
|
67
|
+
if not isinstance(length, (int, float)) or (isinstance(length, float) and not length.is_integer()):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"Invalid argument for substring function: expected an integer as the third argument"
|
|
70
|
+
)
|
|
71
|
+
length = int(length)
|
|
72
|
+
return original[start:start + length]
|
|
73
|
+
|
|
74
|
+
return original[start:]
|
|
@@ -5,6 +5,8 @@ from .aggregated_with import AggregatedWith
|
|
|
5
5
|
from .call import Call
|
|
6
6
|
from .create_node import CreateNode
|
|
7
7
|
from .create_relationship import CreateRelationship
|
|
8
|
+
from .delete_node import DeleteNode
|
|
9
|
+
from .delete_relationship import DeleteRelationship
|
|
8
10
|
from .group_by import GroupBy
|
|
9
11
|
from .limit import Limit
|
|
10
12
|
from .load import Load
|
|
@@ -35,6 +37,8 @@ __all__ = [
|
|
|
35
37
|
"Match",
|
|
36
38
|
"CreateNode",
|
|
37
39
|
"CreateRelationship",
|
|
40
|
+
"DeleteNode",
|
|
41
|
+
"DeleteRelationship",
|
|
38
42
|
"Union",
|
|
39
43
|
"UnionAll",
|
|
40
44
|
"OrderBy",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Represents a DELETE operation for deleting virtual nodes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from ...graph.database import Database
|
|
6
|
+
from ...graph.node import Node
|
|
7
|
+
from .operation import Operation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeleteNode(Operation):
|
|
11
|
+
"""Represents a DELETE operation for deleting virtual nodes."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, node: Node) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self._node = node
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def node(self) -> Node:
|
|
19
|
+
return self._node
|
|
20
|
+
|
|
21
|
+
async def run(self) -> None:
|
|
22
|
+
if self._node is None:
|
|
23
|
+
raise ValueError("Node is null")
|
|
24
|
+
db = Database.get_instance()
|
|
25
|
+
db.remove_node(self._node)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def results(self) -> List[Dict[str, Any]]:
|
|
29
|
+
return []
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Represents a DELETE operation for deleting virtual relationships."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from ...graph.database import Database
|
|
6
|
+
from ...graph.relationship import Relationship
|
|
7
|
+
from .operation import Operation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeleteRelationship(Operation):
|
|
11
|
+
"""Represents a DELETE operation for deleting virtual relationships."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, relationship: Relationship) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self._relationship = relationship
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def relationship(self) -> Relationship:
|
|
19
|
+
return self._relationship
|
|
20
|
+
|
|
21
|
+
async def run(self) -> None:
|
|
22
|
+
if self._relationship is None:
|
|
23
|
+
raise ValueError("Relationship is null")
|
|
24
|
+
db = Database.get_instance()
|
|
25
|
+
db.remove_relationship(self._relationship)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def results(self) -> List[Dict[str, Any]]:
|
|
29
|
+
return []
|
|
@@ -57,6 +57,8 @@ from .operations.aggregated_with import AggregatedWith
|
|
|
57
57
|
from .operations.call import Call
|
|
58
58
|
from .operations.create_node import CreateNode
|
|
59
59
|
from .operations.create_relationship import CreateRelationship
|
|
60
|
+
from .operations.delete_node import DeleteNode
|
|
61
|
+
from .operations.delete_relationship import DeleteRelationship
|
|
60
62
|
from .operations.limit import Limit
|
|
61
63
|
from .operations.load import Load
|
|
62
64
|
from .operations.match import Match
|
|
@@ -185,8 +187,8 @@ class Parser(BaseParser):
|
|
|
185
187
|
new_root.add_child(union)
|
|
186
188
|
return new_root
|
|
187
189
|
|
|
188
|
-
if not isinstance(operation, (Return, Call, CreateNode, CreateRelationship)):
|
|
189
|
-
raise ValueError("Last statement must be a RETURN, WHERE, CALL, or
|
|
190
|
+
if not isinstance(operation, (Return, Call, CreateNode, CreateRelationship, DeleteNode, DeleteRelationship)):
|
|
191
|
+
raise ValueError("Last statement must be a RETURN, WHERE, CALL, CREATE, or DELETE statement")
|
|
190
192
|
|
|
191
193
|
return root
|
|
192
194
|
|
|
@@ -198,7 +200,8 @@ class Parser(BaseParser):
|
|
|
198
200
|
self._parse_load() or
|
|
199
201
|
self._parse_call() or
|
|
200
202
|
self._parse_match() or
|
|
201
|
-
self._parse_create()
|
|
203
|
+
self._parse_create() or
|
|
204
|
+
self._parse_delete()
|
|
202
205
|
)
|
|
203
206
|
|
|
204
207
|
def _parse_with(self) -> Optional[With]:
|
|
@@ -431,6 +434,54 @@ class Parser(BaseParser):
|
|
|
431
434
|
else:
|
|
432
435
|
return CreateNode(node, query)
|
|
433
436
|
|
|
437
|
+
def _parse_delete(self) -> Optional[Operation]:
|
|
438
|
+
"""Parse DELETE VIRTUAL statement for nodes and relationships."""
|
|
439
|
+
if not self.token.is_delete():
|
|
440
|
+
return None
|
|
441
|
+
self.set_next_token()
|
|
442
|
+
self._expect_and_skip_whitespace_and_comments()
|
|
443
|
+
if not self.token.is_virtual():
|
|
444
|
+
raise ValueError("Expected VIRTUAL")
|
|
445
|
+
self.set_next_token()
|
|
446
|
+
self._expect_and_skip_whitespace_and_comments()
|
|
447
|
+
|
|
448
|
+
node = self._parse_node()
|
|
449
|
+
if node is None:
|
|
450
|
+
raise ValueError("Expected node definition")
|
|
451
|
+
|
|
452
|
+
relationship: Optional[Relationship] = None
|
|
453
|
+
if self.token.is_subtract() and self.peek() and self.peek().is_opening_bracket():
|
|
454
|
+
self.set_next_token() # skip -
|
|
455
|
+
self.set_next_token() # skip [
|
|
456
|
+
if not self.token.is_colon():
|
|
457
|
+
raise ValueError("Expected ':' for relationship type")
|
|
458
|
+
self.set_next_token()
|
|
459
|
+
if not self.token.is_identifier_or_keyword():
|
|
460
|
+
raise ValueError("Expected relationship type identifier")
|
|
461
|
+
rel_type = self.token.value or ""
|
|
462
|
+
self.set_next_token()
|
|
463
|
+
if not self.token.is_closing_bracket():
|
|
464
|
+
raise ValueError("Expected closing bracket for relationship definition")
|
|
465
|
+
self.set_next_token()
|
|
466
|
+
if not self.token.is_subtract():
|
|
467
|
+
raise ValueError("Expected '-' for relationship definition")
|
|
468
|
+
self.set_next_token()
|
|
469
|
+
# Skip optional direction indicator '>'
|
|
470
|
+
if self.token.is_greater_than():
|
|
471
|
+
self.set_next_token()
|
|
472
|
+
target = self._parse_node()
|
|
473
|
+
if target is None:
|
|
474
|
+
raise ValueError("Expected target node definition")
|
|
475
|
+
relationship = Relationship()
|
|
476
|
+
relationship.type = rel_type
|
|
477
|
+
relationship.source = node
|
|
478
|
+
relationship.target = target
|
|
479
|
+
|
|
480
|
+
if relationship is not None:
|
|
481
|
+
return DeleteRelationship(relationship)
|
|
482
|
+
else:
|
|
483
|
+
return DeleteNode(node)
|
|
484
|
+
|
|
434
485
|
def _parse_union(self) -> Optional[Union]:
|
|
435
486
|
"""Parse a UNION or UNION ALL keyword."""
|
|
436
487
|
if not self.token.is_union():
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import pytest
|
|
4
4
|
from typing import AsyncIterator
|
|
5
5
|
from flowquery.compute.runner import Runner
|
|
6
|
+
from flowquery.graph.node import Node
|
|
7
|
+
from flowquery.graph.relationship import Relationship
|
|
8
|
+
from flowquery.graph.database import Database
|
|
6
9
|
from flowquery.parsing.functions.async_function import AsyncFunction
|
|
7
10
|
from flowquery.parsing.functions.function_metadata import FunctionDef
|
|
8
11
|
|
|
@@ -810,6 +813,42 @@ class TestRunner:
|
|
|
810
813
|
assert len(results) == 1
|
|
811
814
|
assert results[0] == {"result": ""}
|
|
812
815
|
|
|
816
|
+
@pytest.mark.asyncio
|
|
817
|
+
async def test_substring_function_with_start_and_length(self):
|
|
818
|
+
"""Test substring function with start and length."""
|
|
819
|
+
runner = Runner('RETURN substring("hello", 1, 3) as result')
|
|
820
|
+
await runner.run()
|
|
821
|
+
results = runner.results
|
|
822
|
+
assert len(results) == 1
|
|
823
|
+
assert results[0] == {"result": "ell"}
|
|
824
|
+
|
|
825
|
+
@pytest.mark.asyncio
|
|
826
|
+
async def test_substring_function_with_start_only(self):
|
|
827
|
+
"""Test substring function with start only."""
|
|
828
|
+
runner = Runner('RETURN substring("hello", 2) as result')
|
|
829
|
+
await runner.run()
|
|
830
|
+
results = runner.results
|
|
831
|
+
assert len(results) == 1
|
|
832
|
+
assert results[0] == {"result": "llo"}
|
|
833
|
+
|
|
834
|
+
@pytest.mark.asyncio
|
|
835
|
+
async def test_substring_function_with_zero_start(self):
|
|
836
|
+
"""Test substring function with zero start."""
|
|
837
|
+
runner = Runner('RETURN substring("hello", 0, 5) as result')
|
|
838
|
+
await runner.run()
|
|
839
|
+
results = runner.results
|
|
840
|
+
assert len(results) == 1
|
|
841
|
+
assert results[0] == {"result": "hello"}
|
|
842
|
+
|
|
843
|
+
@pytest.mark.asyncio
|
|
844
|
+
async def test_substring_function_with_zero_length(self):
|
|
845
|
+
"""Test substring function with zero length."""
|
|
846
|
+
runner = Runner('RETURN substring("hello", 1, 0) as result')
|
|
847
|
+
await runner.run()
|
|
848
|
+
results = runner.results
|
|
849
|
+
assert len(results) == 1
|
|
850
|
+
assert results[0] == {"result": ""}
|
|
851
|
+
|
|
813
852
|
@pytest.mark.asyncio
|
|
814
853
|
async def test_associative_array_with_key_which_is_keyword(self):
|
|
815
854
|
"""Test associative array with key which is keyword."""
|
|
@@ -4169,4 +4208,135 @@ class TestRunner:
|
|
|
4169
4208
|
assert results[1] == {"x": 6}
|
|
4170
4209
|
assert results[2] == {"x": 5}
|
|
4171
4210
|
assert results[3] == {"x": 4}
|
|
4172
|
-
assert results[4] == {"x": 3}
|
|
4211
|
+
assert results[4] == {"x": 3}
|
|
4212
|
+
|
|
4213
|
+
@pytest.mark.asyncio
|
|
4214
|
+
async def test_delete_virtual_node_operation(self):
|
|
4215
|
+
"""Test delete virtual node operation."""
|
|
4216
|
+
db = Database.get_instance()
|
|
4217
|
+
# Create a virtual node first
|
|
4218
|
+
create = Runner(
|
|
4219
|
+
"""
|
|
4220
|
+
CREATE VIRTUAL (:PyDeleteTestPerson) AS {
|
|
4221
|
+
unwind [
|
|
4222
|
+
{id: 1, name: 'Person 1'},
|
|
4223
|
+
{id: 2, name: 'Person 2'}
|
|
4224
|
+
] as record
|
|
4225
|
+
RETURN record.id as id, record.name as name
|
|
4226
|
+
}
|
|
4227
|
+
"""
|
|
4228
|
+
)
|
|
4229
|
+
await create.run()
|
|
4230
|
+
assert db.get_node(Node(None, "PyDeleteTestPerson")) is not None
|
|
4231
|
+
|
|
4232
|
+
# Delete the virtual node
|
|
4233
|
+
del_runner = Runner("DELETE VIRTUAL (:PyDeleteTestPerson)")
|
|
4234
|
+
await del_runner.run()
|
|
4235
|
+
assert len(del_runner.results) == 0
|
|
4236
|
+
assert db.get_node(Node(None, "PyDeleteTestPerson")) is None
|
|
4237
|
+
|
|
4238
|
+
@pytest.mark.asyncio
|
|
4239
|
+
async def test_delete_virtual_node_then_match_throws(self):
|
|
4240
|
+
"""Test that matching a deleted virtual node throws."""
|
|
4241
|
+
# Create a virtual node
|
|
4242
|
+
create = Runner(
|
|
4243
|
+
"""
|
|
4244
|
+
CREATE VIRTUAL (:PyDeleteMatchPerson) AS {
|
|
4245
|
+
unwind [{id: 1, name: 'Alice'}] as record
|
|
4246
|
+
RETURN record.id as id, record.name as name
|
|
4247
|
+
}
|
|
4248
|
+
"""
|
|
4249
|
+
)
|
|
4250
|
+
await create.run()
|
|
4251
|
+
|
|
4252
|
+
# Verify it can be matched
|
|
4253
|
+
match1 = Runner("MATCH (n:PyDeleteMatchPerson) RETURN n")
|
|
4254
|
+
await match1.run()
|
|
4255
|
+
assert len(match1.results) == 1
|
|
4256
|
+
|
|
4257
|
+
# Delete the virtual node
|
|
4258
|
+
del_runner = Runner("DELETE VIRTUAL (:PyDeleteMatchPerson)")
|
|
4259
|
+
await del_runner.run()
|
|
4260
|
+
|
|
4261
|
+
# Matching should now throw since the node is gone
|
|
4262
|
+
match2 = Runner("MATCH (n:PyDeleteMatchPerson) RETURN n")
|
|
4263
|
+
with pytest.raises(ValueError):
|
|
4264
|
+
await match2.run()
|
|
4265
|
+
|
|
4266
|
+
@pytest.mark.asyncio
|
|
4267
|
+
async def test_delete_virtual_relationship_operation(self):
|
|
4268
|
+
"""Test delete virtual relationship operation."""
|
|
4269
|
+
db = Database.get_instance()
|
|
4270
|
+
# Create virtual nodes and relationship
|
|
4271
|
+
await Runner(
|
|
4272
|
+
"""
|
|
4273
|
+
CREATE VIRTUAL (:PyDelRelUser) AS {
|
|
4274
|
+
unwind [
|
|
4275
|
+
{id: 1, name: 'Alice'},
|
|
4276
|
+
{id: 2, name: 'Bob'}
|
|
4277
|
+
] as record
|
|
4278
|
+
RETURN record.id as id, record.name as name
|
|
4279
|
+
}
|
|
4280
|
+
"""
|
|
4281
|
+
).run()
|
|
4282
|
+
|
|
4283
|
+
await Runner(
|
|
4284
|
+
"""
|
|
4285
|
+
CREATE VIRTUAL (:PyDelRelUser)-[:PY_DEL_KNOWS]-(:PyDelRelUser) AS {
|
|
4286
|
+
unwind [
|
|
4287
|
+
{left_id: 1, right_id: 2}
|
|
4288
|
+
] as record
|
|
4289
|
+
RETURN record.left_id as left_id, record.right_id as right_id
|
|
4290
|
+
}
|
|
4291
|
+
"""
|
|
4292
|
+
).run()
|
|
4293
|
+
|
|
4294
|
+
# Verify relationship exists
|
|
4295
|
+
rel = Relationship()
|
|
4296
|
+
rel.type = "PY_DEL_KNOWS"
|
|
4297
|
+
assert db.get_relationship(rel) is not None
|
|
4298
|
+
|
|
4299
|
+
# Delete the virtual relationship
|
|
4300
|
+
del_runner = Runner("DELETE VIRTUAL (:PyDelRelUser)-[:PY_DEL_KNOWS]-(:PyDelRelUser)")
|
|
4301
|
+
await del_runner.run()
|
|
4302
|
+
assert len(del_runner.results) == 0
|
|
4303
|
+
assert db.get_relationship(rel) is None
|
|
4304
|
+
|
|
4305
|
+
@pytest.mark.asyncio
|
|
4306
|
+
async def test_delete_virtual_node_leaves_other_nodes_intact(self):
|
|
4307
|
+
"""Test that deleting one virtual node leaves others intact."""
|
|
4308
|
+
db = Database.get_instance()
|
|
4309
|
+
# Create two virtual node types
|
|
4310
|
+
await Runner(
|
|
4311
|
+
"""
|
|
4312
|
+
CREATE VIRTUAL (:PyKeepNode) AS {
|
|
4313
|
+
unwind [{id: 1, name: 'Keep'}] as record
|
|
4314
|
+
RETURN record.id as id, record.name as name
|
|
4315
|
+
}
|
|
4316
|
+
"""
|
|
4317
|
+
).run()
|
|
4318
|
+
|
|
4319
|
+
await Runner(
|
|
4320
|
+
"""
|
|
4321
|
+
CREATE VIRTUAL (:PyRemoveNode) AS {
|
|
4322
|
+
unwind [{id: 2, name: 'Remove'}] as record
|
|
4323
|
+
RETURN record.id as id, record.name as name
|
|
4324
|
+
}
|
|
4325
|
+
"""
|
|
4326
|
+
).run()
|
|
4327
|
+
|
|
4328
|
+
assert db.get_node(Node(None, "PyKeepNode")) is not None
|
|
4329
|
+
assert db.get_node(Node(None, "PyRemoveNode")) is not None
|
|
4330
|
+
|
|
4331
|
+
# Delete only one
|
|
4332
|
+
await Runner("DELETE VIRTUAL (:PyRemoveNode)").run()
|
|
4333
|
+
|
|
4334
|
+
# The other should still exist
|
|
4335
|
+
assert db.get_node(Node(None, "PyKeepNode")) is not None
|
|
4336
|
+
assert db.get_node(Node(None, "PyRemoveNode")) is None
|
|
4337
|
+
|
|
4338
|
+
# The remaining node can still be matched
|
|
4339
|
+
match = Runner("MATCH (n:PyKeepNode) RETURN n")
|
|
4340
|
+
await match.run()
|
|
4341
|
+
assert len(match.results) == 1
|
|
4342
|
+
assert match.results[0]["n"]["name"] == "Keep"
|