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.
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 +1 -0
  23. package/dist/parsing/parser.d.ts.map +1 -1
  24. package/dist/parsing/parser.js +62 -2
  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 +54 -3
  35. package/flowquery-py/tests/compute/test_runner.py +171 -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 +63 -2
  44. package/tests/compute/runner.test.ts +147 -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.30"
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]:
@@ -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"