From 772503882cd20b259b589346ba5598bc93532c4d Mon Sep 17 00:00:00 2001 From: Artemiy Vereshchinskiy Date: Tue, 7 Jul 2026 01:49:24 +0700 Subject: [PATCH 1/2] Add multihop search support --- README.md | 22 +++ pyproject.toml | 2 +- src/rushdb/__init__.py | 6 + src/rushdb/api/transactions.py | 3 +- src/rushdb/client.py | 17 ++- src/rushdb/common.py | 17 ++- src/rushdb/models/__init__.py | 6 + src/rushdb/models/result.py | 9 +- src/rushdb/models/search_query.py | 58 +++++++- tests/test_create_import.py | 12 +- tests/test_search_query.py | 213 ++++++++++++++++++++++++++++++ uv.lock | 2 +- 12 files changed, 353 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index da11420..7cfac3a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,28 @@ authored_posts = db.records.find({ 'limit': 10, }) +# Multi-hop: add hops to $relation — everyone in Alice's reporting chain, up to 4 levels +chain = db.records.find({ + 'labels': ['EMPLOYEE'], + 'where': { + 'EMPLOYEE': { + '$relation': {'type': 'REPORTS_TO', 'direction': 'out', 'hops': {'min': 1, 'max': 4}}, + 'name': {'$contains': 'Alice'}, + } + }, +}) + +# Cycle detection: accounts on a circular transfer ring (fraud rings, circular ownership) +ring_members = db.records.find({ + 'labels': ['ACCOUNT'], + 'where': { + 'RING': { # key is a display name — the $cycle block holds only $relation + '$cycle': True, + '$relation': {'type': 'TRANSFERRED_TO', 'direction': 'out', 'hops': {'min': 2, 'max': 6}}, + } + }, +}) + # Manage relationships explicitly user = db.records.find_uniq({'labels': ['USER'], 'where': {'name': 'Alice'}}) company = db.records.find_uniq({'labels': ['COMPANY'], 'where': {'name': 'Acme Corp'}}) diff --git a/pyproject.toml b/pyproject.toml index dd6e15b..77303eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rushdb" -version = "2.6.0" +version = "2.9.0" description = "RushDB Python SDK — memory layer for AI agents and modern apps" authors = [ {name = "RushDB Team", email = "hi@rushdb.com"} diff --git a/src/rushdb/__init__.py b/src/rushdb/__init__.py index b516b6b..2bb1fc7 100644 --- a/src/rushdb/__init__.py +++ b/src/rushdb/__init__.py @@ -24,6 +24,9 @@ RelationshipEndpointQuery, RelationshipSearchQuery, SearchQuery, + TraversalHops, + TraversalHopsRange, + TraversalRelationOptions, ) from .models.transaction import Transaction @@ -46,6 +49,9 @@ "SearchQuery", "RelationshipEndpointQuery", "RelationshipSearchQuery", + "TraversalHops", + "TraversalHopsRange", + "TraversalRelationOptions", "QueryAPI", "RelationsAPI", "RelationshipPatternsAPI", diff --git a/src/rushdb/api/transactions.py b/src/rushdb/api/transactions.py index 5722c27..cca79cf 100644 --- a/src/rushdb/api/transactions.py +++ b/src/rushdb/api/transactions.py @@ -51,7 +51,8 @@ def begin(self, ttl: Optional[int] = None) -> Transaction: Args: ttl (Optional[int], optional): Time-to-live in milliseconds for the transaction. After this time, the transaction will automatically expire and be rolled back. - If None, defaults to 5000ms (5 seconds). Defaults to None. + If None, defaults to 5000ms (5 seconds). Values above the server-side cap + (60 seconds) are clamped to it. Defaults to None. Returns: Transaction: A Transaction object that can be used with other API operations. diff --git a/src/rushdb/client.py b/src/rushdb/client.py index 0bc6386..e4c91d7 100644 --- a/src/rushdb/client.py +++ b/src/rushdb/client.py @@ -144,7 +144,9 @@ def __init__( else None ) effective_url = url or base_url or self.DEFAULT_BASE_URL - if url and "/api/" not in effective_url: + # Both `url` and its deprecated alias `base_url` are documented to get + # `/api/v1` appended when the path is absent. + if (url or base_url) and "/api/" not in effective_url: effective_url = effective_url.rstrip("/") + "/api/v1" self.base_url = effective_url.rstrip("/") self.api_key = api_key @@ -245,8 +247,17 @@ def _make_request( with urllib.request.urlopen(request) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as e: - error_body = json.loads(e.read().decode("utf-8")) - raise RushDBError(error_body.get("message", str(e)), error_body) + # Error bodies are usually JSON, but a gateway/proxy may return HTML or an + # empty body — without this guard the JSONDecodeError raised here would + # escape as-is instead of surfacing as a RushDBError. + try: + error_body = json.loads(e.read().decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + error_body = {} + message = error_body.get("message") + if isinstance(message, list): + message = "; ".join(str(item) for item in message) + raise RushDBError(message or f"HTTP {e.code}", error_body, status=e.code) except urllib.error.URLError as e: raise RushDBError(f"Connection error: {str(e)}") except json.JSONDecodeError as e: diff --git a/src/rushdb/common.py b/src/rushdb/common.py index 158d3cf..89c8e29 100644 --- a/src/rushdb/common.py +++ b/src/rushdb/common.py @@ -2,11 +2,24 @@ class RushDBError(Exception): - """Custom exception for RushDB client errors.""" + """Custom exception for RushDB client errors. - def __init__(self, message: str, details: Optional[Dict] = None): + Attributes: + details: Parsed error response body from the server, if any. + status: HTTP status code for server errors (e.g. 403 for a write attempt + with a read-only API key, 408 for a server-side transaction timeout). + ``None`` for client-side errors such as connection failures. + """ + + def __init__( + self, + message: str, + details: Optional[Dict] = None, + status: Optional[int] = None, + ): super().__init__(message) self.details = details or {} + self.status = status class NonUniqueResultError(Exception): diff --git a/src/rushdb/models/__init__.py b/src/rushdb/models/__init__.py index 07803b9..a9b047f 100644 --- a/src/rushdb/models/__init__.py +++ b/src/rushdb/models/__init__.py @@ -11,6 +11,9 @@ RelationshipEndpointQuery, RelationshipSearchQuery, SearchQuery, + TraversalHops, + TraversalHopsRange, + TraversalRelationOptions, ) from .transaction import Transaction @@ -26,5 +29,8 @@ "SearchQuery", "RelationshipEndpointQuery", "RelationshipSearchQuery", + "TraversalHops", + "TraversalHopsRange", + "TraversalRelationOptions", "Transaction", ] diff --git a/src/rushdb/models/result.py b/src/rushdb/models/result.py index 27f5c69..4f7ef80 100644 --- a/src/rushdb/models/result.py +++ b/src/rushdb/models/result.py @@ -246,5 +246,10 @@ def to_dataframe(self, exclude_internal: bool = True): return pd.DataFrame(rows) -# Type alias for record search results -RecordSearchResult = SearchResult[Record] +class RecordSearchResult(SearchResult[Record]): + """Search result specialized for ``Record`` items. + + A real subclass (not a ``SearchResult[Record]`` alias) so that + ``isinstance(result, RecordSearchResult)`` works — isinstance checks against + subscripted generics raise ``TypeError`` at runtime. + """ diff --git a/src/rushdb/models/search_query.py b/src/rushdb/models/search_query.py index a4c0fd4..9ad776f 100644 --- a/src/rushdb/models/search_query.py +++ b/src/rushdb/models/search_query.py @@ -7,8 +7,64 @@ class OrderDirection(str, Enum): DESC = "desc" +class TraversalHopsRange(TypedDict, total=False): + """Hop range for variable-length traversal. + + ``min`` defaults to 1 (2 inside a ``$cycle`` block). Omitting ``max`` + requests unbounded traversal, which is only allowed on self-hosted + deployments and projects with a custom Neo4j instance; the shared cloud + connection caps ``max`` per deployment (default 25). + """ + + min: int + max: int + + +TraversalHops = Union[int, TraversalHopsRange] +"""Depth of a variable-length traversal: an exact hop count or a range.""" + + +class TraversalRelationOptions(TypedDict, total=False): + """Value for the ``$relation`` key of a traversal block in ``where``. + + ``type`` and ``direction`` constrain every hop; ``hops`` makes the + traversal variable-length. The nested label constrains only the final + record — intermediate records are anonymous. + + Example:: + + { + "labels": ["EMPLOYEE"], + "where": { + "EMPLOYEE": { + "$alias": "$manager", + "$relation": { + "type": "REPORTS_TO", + "direction": "out", + "hops": {"min": 1, "max": 4}, + }, + "name": {"$contains": "Alice"}, + } + }, + } + """ + + type: str + direction: str # 'in' | 'out' + hops: TraversalHops + + class SearchQuery(TypedDict, total=False): - """TypedDict representing the query structure for finding records.""" + """TypedDict representing the query structure for finding records. + + Inside ``where``, a nested key that is a label name traverses to related + records. Traversal blocks accept ``$alias`` (name the endpoint for + ``select``/``groupBy``), ``$relation`` (a type string or + :class:`TraversalRelationOptions`, including variable-length ``hops``), + and ``$cycle`` (``True`` binds the traversal back to the parent record to + detect rings; requires ``$relation`` with ``hops`` where ``min`` >= 2 and + accepts no other keys). + """ where: Optional[Dict[str, Any]] labels: Optional[List[str]] diff --git a/tests/test_create_import.py b/tests/test_create_import.py index 75c7cf8..d0efa1a 100644 --- a/tests/test_create_import.py +++ b/tests/test_create_import.py @@ -40,7 +40,7 @@ def test_create_with_data(self): # Test new functionality self.assertEqual(record.get("name"), "Google LLC") self.assertEqual(record.get("nonexistent", "default"), "default") - self.assertTrue(record.exists()) + self.assertTrue(record.exists) def test_record_methods(self): """Test Record class methods""" @@ -201,7 +201,11 @@ def test_attach_with_edge_properties_and_find(self): ) def test_create_with_nested_data(self): - """Test creating records with nested data structure""" + """Test importing records with nested data structure. + + ``create_many`` accepts flat rows only (it raises ``ValueError`` for + nested payloads); nested JSON goes through ``import_json``. + """ data = { "name": "Meta Platforms Inc", "rating": 4.6, @@ -220,7 +224,9 @@ def test_create_with_nested_data(self): } ], } - self.client.records.create_many("COMPANY", data) + with self.assertRaises(ValueError): + self.client.records.create_many("COMPANY", data) + self.client.records.import_json(data=data, label="COMPANY") def test_transaction_rollback(self): """Test transaction rollback""" diff --git a/tests/test_search_query.py b/tests/test_search_query.py index 499806f..c622018 100644 --- a/tests/test_search_query.py +++ b/tests/test_search_query.py @@ -1,6 +1,10 @@ """Test cases for RushDB search query functionality.""" +import time import unittest +import uuid + +from src.rushdb import RushDB from .test_base_setup import TestBase @@ -389,5 +393,214 @@ def test_select_math_inside_aggregation(self): self.client.records.find(query) +class TestMultihopAndCycles(TestBase): + """Variable-length traversal ($relation.hops) and cycle detection ($cycle). + + Seeds its own graph, isolated by a unique tenantId: + + Reporting chain (MHEmployee, REPORTS_TO, directed "up"): + E1 -> E2 -> E3 -> E4 + + Transfer ring + linear chain (MHAccount, TRANSFERRED_TO): + A -> B -> C -> A (3-hop directed ring) + X -> Y -> Z (no cycle) + """ + + tenant: str + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tenant = f"multihop-{uuid.uuid4().hex[:8]}" + # TestBase only creates a client per-test (setUp); seeding needs one here. + cls.client = RushDB(cls.token, base_url=cls.base_url) + + cls.client.records.create_many( + "MHEmployee", + [ + {"name": "E1", "managerName": "E2", "tenantId": cls.tenant}, + {"name": "E2", "managerName": "E3", "tenantId": cls.tenant}, + {"name": "E3", "managerName": "E4", "tenantId": cls.tenant}, + {"name": "E4", "tenantId": cls.tenant}, + ], + ) + cls.client.relationships.create_many( + source={ + "label": "MHEmployee", + "key": "managerName", + "where": {"tenantId": cls.tenant}, + }, + target={ + "label": "MHEmployee", + "key": "name", + "where": {"tenantId": cls.tenant}, + }, + type="REPORTS_TO", + direction="out", + ) + + cls.client.records.create_many( + "MHAccount", + [ + {"name": "A", "sendsTo": "B", "tenantId": cls.tenant}, + {"name": "B", "sendsTo": "C", "tenantId": cls.tenant}, + {"name": "C", "sendsTo": "A", "tenantId": cls.tenant}, + {"name": "X", "sendsTo": "Y", "tenantId": cls.tenant}, + {"name": "Y", "sendsTo": "Z", "tenantId": cls.tenant}, + {"name": "Z", "tenantId": cls.tenant}, + ], + ) + cls.client.relationships.create_many( + source={ + "label": "MHAccount", + "key": "sendsTo", + "where": {"tenantId": cls.tenant}, + }, + target={ + "label": "MHAccount", + "key": "name", + "where": {"tenantId": cls.tenant}, + }, + type="TRANSFERRED_TO", + direction="out", # sender -> receiver + ) + + # relationships.create_many is applied via apoc.periodic.iterate — poll + # until both relationship sets are visible. + for _ in range(10): + employee_rels = cls.client.relationships.find( + { + "source": { + "labels": ["MHEmployee"], + "where": {"tenantId": cls.tenant}, + }, + "limit": 100, + } + ) + account_rels = cls.client.relationships.find( + { + "source": { + "labels": ["MHAccount"], + "where": {"tenantId": cls.tenant}, + }, + "limit": 100, + } + ) + if len(employee_rels.data) >= 3 and len(account_rels.data) >= 5: + break + time.sleep(1) + + @classmethod + def tearDownClass(cls): + cls.client.records.delete({"where": {"tenantId": cls.tenant}}) + super().tearDownClass() + + def _names(self, result): + return sorted(record.get("name") for record in result) + + def test_hops_range_reaches_chain_top(self): + """hops {max: 3} reaches E4 from every subordinate""" + result = self.client.records.find( + { + "labels": ["MHEmployee"], + "where": { + "tenantId": self.tenant, + "MHEmployee": { + "$relation": { + "type": "REPORTS_TO", + "direction": "out", + "hops": {"max": 3}, + }, + "name": "E4", + }, + }, + } + ) + self.assertEqual(self._names(result), ["E1", "E2", "E3"]) + + def test_hops_range_is_bounded(self): + """hops {max: 2} does not reach 3 hops away""" + result = self.client.records.find( + { + "labels": ["MHEmployee"], + "where": { + "tenantId": self.tenant, + "MHEmployee": { + "$relation": { + "type": "REPORTS_TO", + "direction": "out", + "hops": {"max": 2}, + }, + "name": "E4", + }, + }, + } + ) + self.assertEqual(self._names(result), ["E2", "E3"]) + + def test_hops_exact_count(self): + """hops: 3 matches exactly 3 hops""" + result = self.client.records.find( + { + "labels": ["MHEmployee"], + "where": { + "tenantId": self.tenant, + "MHEmployee": { + "$relation": { + "type": "REPORTS_TO", + "direction": "out", + "hops": 3, + }, + "name": "E4", + }, + }, + } + ) + self.assertEqual(self._names(result), ["E1"]) + + def test_cycle_flags_ring_members(self): + """$cycle returns exactly the ring participants with a deduplicated total""" + result = self.client.records.find( + { + "labels": ["MHAccount"], + "where": { + "tenantId": self.tenant, + "RING": { + "$cycle": True, + "$relation": { + "type": "TRANSFERRED_TO", + "direction": "out", + "hops": {"min": 2, "max": 6}, + }, + }, + }, + } + ) + self.assertEqual(self._names(result), ["A", "B", "C"]) + self.assertEqual(result.total, 3) + + def test_not_cycle_excludes_ring_members(self): + """$not around a $cycle block finds acyclic accounts""" + result = self.client.records.find( + { + "labels": ["MHAccount"], + "where": { + "tenantId": self.tenant, + "$not": { + "RING": { + "$cycle": True, + "$relation": { + "type": "TRANSFERRED_TO", + "direction": "out", + "hops": {"min": 2, "max": 6}, + }, + } + }, + }, + } + ) + self.assertEqual(self._names(result), ["X", "Y", "Z"]) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 5ddaf01..8187e37 100644 --- a/uv.lock +++ b/uv.lock @@ -785,7 +785,7 @@ wheels = [ [[package]] name = "rushdb" -version = "2.3.0" +version = "2.9.0" source = { editable = "." } dependencies = [ { name = "python-dotenv", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, From ec148cac8b3dc3c090733001ec1e6df6f9189559 Mon Sep 17 00:00:00 2001 From: Artemiy Vereshchinskiy Date: Tue, 7 Jul 2026 01:54:18 +0700 Subject: [PATCH 2/2] Fix test suite --- tests/test_search_query.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_search_query.py b/tests/test_search_query.py index c622018..9009678 100644 --- a/tests/test_search_query.py +++ b/tests/test_search_query.py @@ -4,7 +4,7 @@ import unittest import uuid -from src.rushdb import RushDB +from src.rushdb import RushDB, RushDBError from .test_base_setup import TestBase @@ -415,6 +415,17 @@ def setUpClass(cls): # TestBase only creates a client per-test (setUp); seeding needs one here. cls.client = RushDB(cls.token, base_url=cls.base_url) + # TestBase skips per-test in setUp when the server is unreachable; this + # class seeds data in setUpClass, so it must skip here for the same + # reason (e.g. CI without a running RushDB) instead of erroring. + try: + if not cls.client.ping(): + raise unittest.SkipTest( + f"Could not connect to RushDB at {cls.base_url}" + ) + except RushDBError as e: + raise unittest.SkipTest(f"RushDB connection error: {str(e)}") + cls.client.records.create_many( "MHEmployee", [