Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'}})
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
6 changes: 6 additions & 0 deletions src/rushdb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
RelationshipEndpointQuery,
RelationshipSearchQuery,
SearchQuery,
TraversalHops,
TraversalHopsRange,
TraversalRelationOptions,
)
from .models.transaction import Transaction

Expand All @@ -46,6 +49,9 @@
"SearchQuery",
"RelationshipEndpointQuery",
"RelationshipSearchQuery",
"TraversalHops",
"TraversalHopsRange",
"TraversalRelationOptions",
"QueryAPI",
"RelationsAPI",
"RelationshipPatternsAPI",
Expand Down
3 changes: 2 additions & 1 deletion src/rushdb/api/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions src/rushdb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 15 additions & 2 deletions src/rushdb/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions src/rushdb/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
RelationshipEndpointQuery,
RelationshipSearchQuery,
SearchQuery,
TraversalHops,
TraversalHopsRange,
TraversalRelationOptions,
)
from .transaction import Transaction

Expand All @@ -26,5 +29,8 @@
"SearchQuery",
"RelationshipEndpointQuery",
"RelationshipSearchQuery",
"TraversalHops",
"TraversalHopsRange",
"TraversalRelationOptions",
"Transaction",
]
9 changes: 7 additions & 2 deletions src/rushdb/models/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
58 changes: 57 additions & 1 deletion src/rushdb/models/search_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
12 changes: 9 additions & 3 deletions tests/test_create_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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,
Expand All @@ -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"""
Expand Down
Loading
Loading