flowquery 1.0.41 → 1.0.43

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.31"
3
+ version = "1.0.33"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -32,10 +32,11 @@ class NodeReference(Node):
32
32
  async def next(self) -> None:
33
33
  """Process next using the referenced node's value."""
34
34
  ref_value = self._reference.value()
35
- if ref_value is not None:
36
- self.set_value(dict(ref_value))
37
- if self._outgoing and self._value:
38
- await self._outgoing.find(self._value['id'])
35
+ if ref_value is None:
36
+ return
37
+ self.set_value(dict(ref_value))
38
+ if self._outgoing and self._value:
39
+ await self._outgoing.find(self._value['id'])
39
40
  await self.run_todo_next()
40
41
 
41
42
  async def find(self, id_: str, hop: int = 0) -> None:
@@ -2,19 +2,17 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from .node import Node
9
9
  from .relationship import Relationship
10
10
 
11
-
12
- class RelationshipMatchRecord(TypedDict, total=False):
13
- """Represents a matched relationship record."""
14
- type: str
15
- startNode: Any
16
- endNode: Any
17
- properties: Dict[str, Any]
11
+ # A relationship match record is a plain dict with known keys (type,
12
+ # startNode, endNode, properties) plus any extra relationship-property
13
+ # keys spread at the top level – mirroring the TypeScript version that
14
+ # uses an index signature ``[key: string]: any``.
15
+ RelationshipMatchRecord = Dict[str, Any]
18
16
 
19
17
 
20
18
  class RelationshipMatchCollector:
@@ -36,6 +34,7 @@ class RelationshipMatchCollector:
36
34
  actual_type = default_type
37
35
  rel_props: Dict[str, Any] = (rel_data.properties() or {}) if rel_data else {}
38
36
  match: RelationshipMatchRecord = {
37
+ **rel_props,
39
38
  "type": actual_type,
40
39
  "startNode": start_node_value or {},
41
40
  "endNode": None,
@@ -3542,6 +3542,76 @@ class TestRunner:
3542
3542
  assert len(results) == 1
3543
3543
  assert results[0] == {"sum": 0}
3544
3544
 
3545
+ @pytest.mark.asyncio
3546
+ async def test_relationship_properties_direct_dot_notation(self):
3547
+ """Test relationship properties can be accessed directly via dot notation."""
3548
+ await Runner(
3549
+ """
3550
+ CREATE VIRTUAL (:RCity) AS {
3551
+ unwind [
3552
+ {id: 1, name: 'NYC'},
3553
+ {id: 2, name: 'LA'}
3554
+ ] as record
3555
+ RETURN record.id as id, record.name as name
3556
+ }
3557
+ """
3558
+ ).run()
3559
+ await Runner(
3560
+ """
3561
+ CREATE VIRTUAL (:RCity)-[:RFLIGHT]-(:RCity) AS {
3562
+ unwind [
3563
+ {left_id: 1, right_id: 2, airline: 'Delta', duration: 5}
3564
+ ] as record
3565
+ RETURN record.left_id as left_id, record.right_id as right_id, record.airline as airline, record.duration as duration
3566
+ }
3567
+ """
3568
+ ).run()
3569
+ match = Runner(
3570
+ """
3571
+ MATCH (a:RCity)-[r:RFLIGHT]->(b:RCity)
3572
+ RETURN a.name AS from, b.name AS to, r.airline AS airline, r.duration AS duration
3573
+ """
3574
+ )
3575
+ await match.run()
3576
+ results = match.results
3577
+ assert len(results) == 1
3578
+ assert results[0] == {"from": "NYC", "to": "LA", "airline": "Delta", "duration": 5}
3579
+
3580
+ @pytest.mark.asyncio
3581
+ async def test_relationship_properties_direct_and_via_properties_function(self):
3582
+ """Test relationship properties accessible via both direct access and properties()."""
3583
+ await Runner(
3584
+ """
3585
+ CREATE VIRTUAL (:RPerson) AS {
3586
+ unwind [
3587
+ {id: 1, name: 'Alice'},
3588
+ {id: 2, name: 'Bob'}
3589
+ ] as record
3590
+ RETURN record.id as id, record.name as name
3591
+ }
3592
+ """
3593
+ ).run()
3594
+ await Runner(
3595
+ """
3596
+ CREATE VIRTUAL (:RPerson)-[:RKNOWS]-(:RPerson) AS {
3597
+ unwind [
3598
+ {left_id: 1, right_id: 2, since: 2020, strength: 'strong'}
3599
+ ] as record
3600
+ RETURN record.left_id as left_id, record.right_id as right_id, record.since as since, record.strength as strength
3601
+ }
3602
+ """
3603
+ ).run()
3604
+ match = Runner(
3605
+ """
3606
+ MATCH (a:RPerson)-[r:RKNOWS]->(b:RPerson)
3607
+ RETURN a.name AS from, b.name AS to, r.since AS since, r.strength AS strength, properties(r).since AS propSince
3608
+ """
3609
+ )
3610
+ await match.run()
3611
+ results = match.results
3612
+ assert len(results) == 1
3613
+ assert results[0] == {"from": "Alice", "to": "Bob", "since": 2020, "strength": "strong", "propSince": 2020}
3614
+
3545
3615
  @pytest.mark.asyncio
3546
3616
  async def test_coalesce_returns_first_non_null(self):
3547
3617
  """Test coalesce returns first non-null value."""
@@ -4394,4 +4464,161 @@ class TestRunner:
4394
4464
  "mentor": "Bob Jones",
4395
4465
  "mentorJobTitle": "Staff Engineer",
4396
4466
  "mentorDepartment": "Engineering",
4397
- }
4467
+ }
4468
+
4469
+ @pytest.mark.asyncio
4470
+ async def test_chained_optional_match_with_null_intermediate_node(self):
4471
+ """Test chained OPTIONAL MATCH where intermediate node is null doesn't crash."""
4472
+ # Chain: Alice -> Bob -> Charlie (no outgoing)
4473
+ await Runner(
4474
+ """
4475
+ CREATE VIRTUAL (:ChainEmp) AS {
4476
+ unwind [
4477
+ {id: 1, name: 'Alice'},
4478
+ {id: 2, name: 'Bob'},
4479
+ {id: 3, name: 'Charlie'}
4480
+ ] as record
4481
+ RETURN record.id as id, record.name as name
4482
+ }
4483
+ """
4484
+ ).run()
4485
+ await Runner(
4486
+ """
4487
+ CREATE VIRTUAL (:ChainEmp)-[:REPORTS_TO]-(:ChainEmp) AS {
4488
+ unwind [
4489
+ {left_id: 1, right_id: 2},
4490
+ {left_id: 2, right_id: 3}
4491
+ ] as record
4492
+ RETURN record.left_id as left_id, record.right_id as right_id
4493
+ }
4494
+ """
4495
+ ).run()
4496
+
4497
+ # Alice -> Bob -> Charlie -> null -> null
4498
+ runner = Runner(
4499
+ """
4500
+ MATCH (u:ChainEmp)
4501
+ WHERE u.name = "Alice"
4502
+ OPTIONAL MATCH (u)-[:REPORTS_TO]->(m1:ChainEmp)
4503
+ OPTIONAL MATCH (m1)-[:REPORTS_TO]->(m2:ChainEmp)
4504
+ OPTIONAL MATCH (m2)-[:REPORTS_TO]->(m3:ChainEmp)
4505
+ OPTIONAL MATCH (m3)-[:REPORTS_TO]->(m4:ChainEmp)
4506
+ RETURN
4507
+ u.name AS user,
4508
+ m1.name AS manager1,
4509
+ m2.name AS manager2,
4510
+ m3.name AS manager3,
4511
+ m4.name AS manager4
4512
+ """
4513
+ )
4514
+ await runner.run()
4515
+ results = runner.results
4516
+
4517
+ assert len(results) == 1
4518
+ assert results[0]["user"] == "Alice"
4519
+ assert results[0]["manager1"] == "Bob"
4520
+ assert results[0]["manager2"] == "Charlie"
4521
+ assert results[0]["manager3"] is None
4522
+ assert results[0]["manager4"] is None
4523
+
4524
+ @pytest.mark.asyncio
4525
+ async def test_chained_optional_match_all_null_from_first(self):
4526
+ """Test chained OPTIONAL MATCH where first optional returns null propagates nulls."""
4527
+ await Runner(
4528
+ """
4529
+ CREATE VIRTUAL (:ChainWorker) AS {
4530
+ unwind [
4531
+ {id: 1, name: 'Solo'}
4532
+ ] as record
4533
+ RETURN record.id as id, record.name as name
4534
+ }
4535
+ """
4536
+ ).run()
4537
+ await Runner(
4538
+ """
4539
+ CREATE VIRTUAL (:ChainWorker)-[:MANAGES]-(:ChainWorker) AS {
4540
+ unwind [] as record
4541
+ RETURN record.left_id as left_id, record.right_id as right_id
4542
+ }
4543
+ """
4544
+ ).run()
4545
+
4546
+ # Solo has no MANAGES relationship
4547
+ runner = Runner(
4548
+ """
4549
+ MATCH (u:ChainWorker)
4550
+ OPTIONAL MATCH (u)-[:MANAGES]->(m1:ChainWorker)
4551
+ OPTIONAL MATCH (m1)-[:MANAGES]->(m2:ChainWorker)
4552
+ OPTIONAL MATCH (m2)-[:MANAGES]->(m3:ChainWorker)
4553
+ RETURN
4554
+ u.name AS user,
4555
+ m1.name AS mgr1,
4556
+ m2.name AS mgr2,
4557
+ m3.name AS mgr3
4558
+ """
4559
+ )
4560
+ await runner.run()
4561
+ results = runner.results
4562
+
4563
+ assert len(results) == 1
4564
+ assert results[0]["user"] == "Solo"
4565
+ assert results[0]["mgr1"] is None
4566
+ assert results[0]["mgr2"] is None
4567
+ assert results[0]["mgr3"] is None
4568
+
4569
+ @pytest.mark.asyncio
4570
+ async def test_chained_optional_match_mixed_null_and_non_null(self):
4571
+ """Test chained OPTIONAL MATCH with multiple start nodes having different chain depths."""
4572
+ await Runner(
4573
+ """
4574
+ CREATE VIRTUAL (:ChainStaff) AS {
4575
+ unwind [
4576
+ {id: 1, name: 'Dev'},
4577
+ {id: 2, name: 'Lead'},
4578
+ {id: 3, name: 'Director'},
4579
+ {id: 4, name: 'Intern'}
4580
+ ] as record
4581
+ RETURN record.id as id, record.name as name
4582
+ }
4583
+ """
4584
+ ).run()
4585
+ await Runner(
4586
+ """
4587
+ CREATE VIRTUAL (:ChainStaff)-[:REPORTS_TO]-(:ChainStaff) AS {
4588
+ unwind [
4589
+ {left_id: 1, right_id: 2},
4590
+ {left_id: 2, right_id: 3}
4591
+ ] as record
4592
+ RETURN record.left_id as left_id, record.right_id as right_id
4593
+ }
4594
+ """
4595
+ ).run()
4596
+
4597
+ # Dev -> Lead -> Director -> null
4598
+ # Intern -> null -> null -> null
4599
+ runner = Runner(
4600
+ """
4601
+ MATCH (u:ChainStaff)
4602
+ WHERE u.name = "Dev" OR u.name = "Intern"
4603
+ OPTIONAL MATCH (u)-[:REPORTS_TO]->(m1:ChainStaff)
4604
+ OPTIONAL MATCH (m1)-[:REPORTS_TO]->(m2:ChainStaff)
4605
+ OPTIONAL MATCH (m2)-[:REPORTS_TO]->(m3:ChainStaff)
4606
+ RETURN
4607
+ u.name AS user,
4608
+ m1.name AS mgr1,
4609
+ m2.name AS mgr2,
4610
+ m3.name AS mgr3
4611
+ """
4612
+ )
4613
+ await runner.run()
4614
+ results = runner.results
4615
+
4616
+ assert len(results) == 2
4617
+ dev = next(r for r in results if r["user"] == "Dev")
4618
+ assert dev["mgr1"] == "Lead"
4619
+ assert dev["mgr2"] == "Director"
4620
+ assert dev["mgr3"] is None
4621
+ intern = next(r for r in results if r["user"] == "Intern")
4622
+ assert intern["mgr1"] is None
4623
+ assert intern["mgr2"] is None
4624
+ assert intern["mgr3"] is None