flowquery 1.0.37 → 1.0.38

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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.27"
3
+ version = "1.0.28"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -11,6 +11,13 @@ class Limit(Operation):
11
11
  self._count = 0
12
12
  self._limit = limit
13
13
 
14
+ @property
15
+ def is_limit_reached(self) -> bool:
16
+ return self._count >= self._limit
17
+
18
+ def increment(self) -> None:
19
+ self._count += 1
20
+
14
21
  async def run(self) -> None:
15
22
  if self._count >= self._limit:
16
23
  return
@@ -4,6 +4,7 @@ import copy
4
4
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
5
5
 
6
6
  from ..ast_node import ASTNode
7
+ from .limit import Limit
7
8
  from .projection import Projection
8
9
 
9
10
  if TYPE_CHECKING:
@@ -24,6 +25,7 @@ class Return(Projection):
24
25
  super().__init__(expressions)
25
26
  self._where: Optional['Where'] = None
26
27
  self._results: List[Dict[str, Any]] = []
28
+ self._limit: Optional[Limit] = None
27
29
 
28
30
  @property
29
31
  def where(self) -> Any:
@@ -35,9 +37,19 @@ class Return(Projection):
35
37
  def where(self, where: 'Where') -> None:
36
38
  self._where = where
37
39
 
40
+ @property
41
+ def limit(self) -> Optional[Limit]:
42
+ return self._limit
43
+
44
+ @limit.setter
45
+ def limit(self, limit: Limit) -> None:
46
+ self._limit = limit
47
+
38
48
  async def run(self) -> None:
39
49
  if not self.where:
40
50
  return
51
+ if self._limit is not None and self._limit.is_limit_reached:
52
+ return
41
53
  record: Dict[str, Any] = {}
42
54
  for expression, alias in self.expressions():
43
55
  raw = expression.value()
@@ -45,6 +57,8 @@ class Return(Projection):
45
57
  value = copy.deepcopy(raw) if isinstance(raw, (dict, list)) else raw
46
58
  record[alias] = value
47
59
  self._results.append(record)
60
+ if self._limit is not None:
61
+ self._limit.increment()
48
62
 
49
63
  async def initialize(self) -> None:
50
64
  self._results = []
@@ -116,6 +116,9 @@ class Parser(BaseParser):
116
116
  if self.token.is_union():
117
117
  break
118
118
 
119
+ if self.token.is_eof():
120
+ break
121
+
119
122
  operation = self._parse_operation()
120
123
  if operation is None and not is_sub_query:
121
124
  raise ValueError("Expected one of WITH, UNWIND, RETURN, LOAD, OR CALL")
@@ -145,8 +148,11 @@ class Parser(BaseParser):
145
148
 
146
149
  limit = self._parse_limit()
147
150
  if limit is not None:
148
- operation.add_sibling(limit)
149
- operation = limit
151
+ if isinstance(operation, Return):
152
+ operation.limit = limit
153
+ else:
154
+ operation.add_sibling(limit)
155
+ operation = limit
150
156
 
151
157
  previous = operation
152
158
 
@@ -539,13 +545,11 @@ class Parser(BaseParser):
539
545
  node.properties = dict(self._parse_properties())
540
546
  if identifier is not None and identifier in self._state.variables:
541
547
  reference = self._state.variables.get(identifier)
542
- # Resolve through Expression -> Reference -> Node (e.g., after WITH)
543
- ref_child = reference.first_child() if isinstance(reference, Expression) else None
544
- if isinstance(ref_child, Reference):
545
- inner = ref_child.referred
546
- if isinstance(inner, Node):
547
- reference = inner
548
- if reference is None or (not isinstance(reference, Node) and not isinstance(reference, Unwind)):
548
+ if reference is None or (
549
+ not isinstance(reference, Node)
550
+ and not isinstance(reference, Unwind)
551
+ and not isinstance(reference, Expression)
552
+ ):
549
553
  raise ValueError(f"Undefined node reference: {identifier}")
550
554
  node = NodeReference(node, reference)
551
555
  elif identifier is not None:
@@ -925,6 +925,20 @@ class TestRunner:
925
925
  results = runner.results
926
926
  assert len(results) == 50
927
927
 
928
+ @pytest.mark.asyncio
929
+ async def test_limit_as_last_operation(self):
930
+ """Test limit as the last operation after return."""
931
+ runner = Runner(
932
+ """
933
+ unwind range(1, 10) as i
934
+ return i
935
+ limit 5
936
+ """
937
+ )
938
+ await runner.run()
939
+ results = runner.results
940
+ assert len(results) == 5
941
+
928
942
  @pytest.mark.asyncio
929
943
  async def test_range_lookup(self):
930
944
  """Test range lookup."""
@@ -1457,6 +1471,71 @@ class TestRunner:
1457
1471
  assert results[0] == {"name1": "Person 1", "name2": "Person 2", "name3": "Person 3"}
1458
1472
  assert results[1] == {"name1": "Person 2", "name2": "Person 3", "name3": "Person 4"}
1459
1473
 
1474
+ @pytest.mark.asyncio
1475
+ async def test_match_with_aggregated_with_and_subsequent_match(self):
1476
+ """Test match with aggregated WITH followed by another match using the same node reference."""
1477
+ await Runner(
1478
+ """
1479
+ CREATE VIRTUAL (:AggUser) AS {
1480
+ unwind [
1481
+ {id: 1, name: 'Alice'},
1482
+ {id: 2, name: 'Bob'},
1483
+ {id: 3, name: 'Carol'}
1484
+ ] as record
1485
+ RETURN record.id as id, record.name as name
1486
+ }
1487
+ """
1488
+ ).run()
1489
+ await Runner(
1490
+ """
1491
+ CREATE VIRTUAL (:AggUser)-[:KNOWS]-(:AggUser) AS {
1492
+ unwind [
1493
+ {left_id: 1, right_id: 2},
1494
+ {left_id: 1, right_id: 3}
1495
+ ] as record
1496
+ RETURN record.left_id as left_id, record.right_id as right_id
1497
+ }
1498
+ """
1499
+ ).run()
1500
+ await Runner(
1501
+ """
1502
+ CREATE VIRTUAL (:AggProject) AS {
1503
+ unwind [
1504
+ {id: 1, name: 'Project A'},
1505
+ {id: 2, name: 'Project B'}
1506
+ ] as record
1507
+ RETURN record.id as id, record.name as name
1508
+ }
1509
+ """
1510
+ ).run()
1511
+ await Runner(
1512
+ """
1513
+ CREATE VIRTUAL (:AggUser)-[:WORKS_ON]-(:AggProject) AS {
1514
+ unwind [
1515
+ {left_id: 1, right_id: 1},
1516
+ {left_id: 1, right_id: 2}
1517
+ ] as record
1518
+ RETURN record.left_id as left_id, record.right_id as right_id
1519
+ }
1520
+ """
1521
+ ).run()
1522
+ match = Runner(
1523
+ """
1524
+ MATCH (u:AggUser)-[:KNOWS]->(s:AggUser)
1525
+ WITH u, count(s) as acquaintances
1526
+ MATCH (u)-[:WORKS_ON]->(p:AggProject)
1527
+ RETURN u.name as name, acquaintances, collect(p.name) as projects
1528
+ """
1529
+ )
1530
+ await match.run()
1531
+ results = match.results
1532
+ assert len(results) == 1
1533
+ assert results[0] == {
1534
+ "name": "Alice",
1535
+ "acquaintances": 2,
1536
+ "projects": ["Project A", "Project B"],
1537
+ }
1538
+
1460
1539
  @pytest.mark.asyncio
1461
1540
  async def test_match_and_return_full_node(self):
1462
1541
  """Test match and return full node."""