flowquery 1.0.39 → 1.0.41
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 +8 -0
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +105 -31
- 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 +75 -10
- package/flowquery-py/tests/compute/test_runner.py +226 -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 +110 -33
- package/tests/compute/runner.test.ts +194 -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]:
|
|
@@ -211,7 +214,7 @@ class Parser(BaseParser):
|
|
|
211
214
|
distinct = True
|
|
212
215
|
self.set_next_token()
|
|
213
216
|
self._expect_and_skip_whitespace_and_comments()
|
|
214
|
-
expressions =
|
|
217
|
+
expressions = self._parse_expressions(AliasOption.REQUIRED)
|
|
215
218
|
if len(expressions) == 0:
|
|
216
219
|
raise ValueError("Expected expression")
|
|
217
220
|
if distinct or any(expr.has_reducers() for expr in expressions):
|
|
@@ -251,7 +254,7 @@ class Parser(BaseParser):
|
|
|
251
254
|
distinct = True
|
|
252
255
|
self.set_next_token()
|
|
253
256
|
self._expect_and_skip_whitespace_and_comments()
|
|
254
|
-
expressions =
|
|
257
|
+
expressions = self._parse_expressions(AliasOption.OPTIONAL)
|
|
255
258
|
if len(expressions) == 0:
|
|
256
259
|
raise ValueError("Expected expression")
|
|
257
260
|
if distinct or any(expr.has_reducers() for expr in expressions):
|
|
@@ -350,7 +353,7 @@ class Parser(BaseParser):
|
|
|
350
353
|
self._expect_previous_token_to_be_whitespace_or_comment()
|
|
351
354
|
self.set_next_token()
|
|
352
355
|
self._expect_and_skip_whitespace_and_comments()
|
|
353
|
-
expressions =
|
|
356
|
+
expressions = self._parse_expressions(AliasOption.OPTIONAL)
|
|
354
357
|
if len(expressions) == 0:
|
|
355
358
|
raise ValueError("Expected at least one expression")
|
|
356
359
|
call.yielded = expressions # type: ignore[assignment]
|
|
@@ -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():
|
|
@@ -740,16 +791,30 @@ class Parser(BaseParser):
|
|
|
740
791
|
|
|
741
792
|
def _parse_expressions(
|
|
742
793
|
self, alias_option: AliasOption = AliasOption.NOT_ALLOWED
|
|
743
|
-
) ->
|
|
794
|
+
) -> List[Expression]:
|
|
795
|
+
"""Parse a comma-separated list of expressions with deferred variable
|
|
796
|
+
registration. Aliases set by earlier expressions in the same clause
|
|
797
|
+
won't shadow variables needed by later expressions
|
|
798
|
+
(e.g. ``RETURN a.x AS a, a.y AS b``)."""
|
|
799
|
+
parsed = list(self.__parse_expressions(alias_option))
|
|
800
|
+
for expression, variable_name in parsed:
|
|
801
|
+
if variable_name is not None:
|
|
802
|
+
self._state.variables[variable_name] = expression
|
|
803
|
+
return [expression for expression, _ in parsed]
|
|
804
|
+
|
|
805
|
+
def __parse_expressions(
|
|
806
|
+
self, alias_option: AliasOption
|
|
807
|
+
) -> Iterator[Tuple[Expression, Optional[str]]]:
|
|
744
808
|
while True:
|
|
745
809
|
expression = self._parse_expression()
|
|
746
810
|
if expression is not None:
|
|
811
|
+
variable_name: Optional[str] = None
|
|
747
812
|
alias = self._parse_alias()
|
|
748
813
|
if isinstance(expression.first_child(), Reference) and alias is None:
|
|
749
814
|
reference = expression.first_child()
|
|
750
815
|
assert isinstance(reference, Reference) # For type narrowing
|
|
751
816
|
expression.set_alias(reference.identifier)
|
|
752
|
-
|
|
817
|
+
variable_name = reference.identifier
|
|
753
818
|
elif (alias_option == AliasOption.REQUIRED and
|
|
754
819
|
alias is None and
|
|
755
820
|
not isinstance(expression.first_child(), Reference)):
|
|
@@ -758,8 +823,8 @@ class Parser(BaseParser):
|
|
|
758
823
|
raise ValueError("Alias not allowed")
|
|
759
824
|
elif alias_option in (AliasOption.OPTIONAL, AliasOption.REQUIRED) and alias is not None:
|
|
760
825
|
expression.set_alias(alias.get_alias())
|
|
761
|
-
|
|
762
|
-
yield expression
|
|
826
|
+
variable_name = alias.get_alias()
|
|
827
|
+
yield expression, variable_name
|
|
763
828
|
else:
|
|
764
829
|
break
|
|
765
830
|
self._skip_whitespace_and_comments()
|
|
@@ -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,190 @@ 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"
|
|
4343
|
+
|
|
4344
|
+
@pytest.mark.asyncio
|
|
4345
|
+
async def test_return_alias_shadowing_graph_variable(self):
|
|
4346
|
+
"""Test that RETURN alias doesn't shadow graph variable in same clause.
|
|
4347
|
+
|
|
4348
|
+
When RETURN mentor.displayName AS mentor is followed by mentor.jobTitle,
|
|
4349
|
+
the alias 'mentor' should not overwrite the graph node variable before
|
|
4350
|
+
subsequent expressions are parsed.
|
|
4351
|
+
"""
|
|
4352
|
+
await Runner(
|
|
4353
|
+
"""
|
|
4354
|
+
CREATE VIRTUAL (:PyMentorUser) AS {
|
|
4355
|
+
UNWIND [
|
|
4356
|
+
{id: 1, displayName: 'Alice Smith', jobTitle: 'Senior Engineer', department: 'Engineering'},
|
|
4357
|
+
{id: 2, displayName: 'Bob Jones', jobTitle: 'Staff Engineer', department: 'Engineering'},
|
|
4358
|
+
{id: 3, displayName: 'Chloe Dubois', jobTitle: 'Junior Engineer', department: 'Engineering'}
|
|
4359
|
+
] AS record
|
|
4360
|
+
RETURN record.id AS id, record.displayName AS displayName, record.jobTitle AS jobTitle, record.department AS department
|
|
4361
|
+
}
|
|
4362
|
+
"""
|
|
4363
|
+
).run()
|
|
4364
|
+
|
|
4365
|
+
await Runner(
|
|
4366
|
+
"""
|
|
4367
|
+
CREATE VIRTUAL (:PyMentorUser)-[:PY_MENTORS]-(:PyMentorUser) AS {
|
|
4368
|
+
UNWIND [
|
|
4369
|
+
{left_id: 1, right_id: 3},
|
|
4370
|
+
{left_id: 2, right_id: 3}
|
|
4371
|
+
] AS record
|
|
4372
|
+
RETURN record.left_id AS left_id, record.right_id AS right_id
|
|
4373
|
+
}
|
|
4374
|
+
"""
|
|
4375
|
+
).run()
|
|
4376
|
+
|
|
4377
|
+
runner = Runner(
|
|
4378
|
+
"""
|
|
4379
|
+
MATCH (mentor:PyMentorUser)-[:PY_MENTORS]->(mentee:PyMentorUser)
|
|
4380
|
+
WHERE mentee.displayName = "Chloe Dubois"
|
|
4381
|
+
RETURN mentor.displayName AS mentor, mentor.jobTitle AS mentorJobTitle, mentor.department AS mentorDepartment
|
|
4382
|
+
"""
|
|
4383
|
+
)
|
|
4384
|
+
await runner.run()
|
|
4385
|
+
results = runner.results
|
|
4386
|
+
|
|
4387
|
+
assert len(results) == 2
|
|
4388
|
+
assert results[0] == {
|
|
4389
|
+
"mentor": "Alice Smith",
|
|
4390
|
+
"mentorJobTitle": "Senior Engineer",
|
|
4391
|
+
"mentorDepartment": "Engineering",
|
|
4392
|
+
}
|
|
4393
|
+
assert results[1] == {
|
|
4394
|
+
"mentor": "Bob Jones",
|
|
4395
|
+
"mentorJobTitle": "Staff Engineer",
|
|
4396
|
+
"mentorDepartment": "Engineering",
|
|
4397
|
+
}
|