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.
Files changed (45) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/graph/database.d.ts +2 -0
  3. package/dist/graph/database.d.ts.map +1 -1
  4. package/dist/graph/database.js +12 -0
  5. package/dist/graph/database.js.map +1 -1
  6. package/dist/parsing/functions/function_factory.d.ts +1 -0
  7. package/dist/parsing/functions/function_factory.d.ts.map +1 -1
  8. package/dist/parsing/functions/function_factory.js +1 -0
  9. package/dist/parsing/functions/function_factory.js.map +1 -1
  10. package/dist/parsing/functions/substring.d.ts +9 -0
  11. package/dist/parsing/functions/substring.d.ts.map +1 -0
  12. package/dist/parsing/functions/substring.js +62 -0
  13. package/dist/parsing/functions/substring.js.map +1 -0
  14. package/dist/parsing/operations/delete_node.d.ts +11 -0
  15. package/dist/parsing/operations/delete_node.d.ts.map +1 -0
  16. package/dist/parsing/operations/delete_node.js +46 -0
  17. package/dist/parsing/operations/delete_node.js.map +1 -0
  18. package/dist/parsing/operations/delete_relationship.d.ts +11 -0
  19. package/dist/parsing/operations/delete_relationship.d.ts.map +1 -0
  20. package/dist/parsing/operations/delete_relationship.js +46 -0
  21. package/dist/parsing/operations/delete_relationship.js.map +1 -0
  22. package/dist/parsing/parser.d.ts +8 -0
  23. package/dist/parsing/parser.d.ts.map +1 -1
  24. package/dist/parsing/parser.js +105 -31
  25. package/dist/parsing/parser.js.map +1 -1
  26. package/docs/flowquery.min.js +1 -1
  27. package/flowquery-py/pyproject.toml +1 -1
  28. package/flowquery-py/src/graph/database.py +12 -0
  29. package/flowquery-py/src/parsing/functions/__init__.py +2 -0
  30. package/flowquery-py/src/parsing/functions/substring.py +74 -0
  31. package/flowquery-py/src/parsing/operations/__init__.py +4 -0
  32. package/flowquery-py/src/parsing/operations/delete_node.py +29 -0
  33. package/flowquery-py/src/parsing/operations/delete_relationship.py +29 -0
  34. package/flowquery-py/src/parsing/parser.py +75 -10
  35. package/flowquery-py/tests/compute/test_runner.py +226 -1
  36. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  37. package/package.json +1 -1
  38. package/src/graph/database.ts +12 -0
  39. package/src/parsing/functions/function_factory.ts +1 -0
  40. package/src/parsing/functions/substring.ts +65 -0
  41. package/src/parsing/operations/delete_node.ts +33 -0
  42. package/src/parsing/operations/delete_relationship.ts +32 -0
  43. package/src/parsing/parser.ts +110 -33
  44. package/tests/compute/runner.test.ts +194 -0
  45. package/tests/parsing/parser.test.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.29"
3
+ version = "1.0.31"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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 CREATE statement")
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 = list(self._parse_expressions(AliasOption.REQUIRED))
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 = list(self._parse_expressions(AliasOption.OPTIONAL))
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 = list(self._parse_expressions(AliasOption.OPTIONAL))
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
- ) -> Iterator[Expression]:
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
- self._state.variables[reference.identifier] = expression
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
- self._state.variables[alias.get_alias()] = expression
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
+ }