rushdb 2.3.0__tar.gz → 2.9.0__tar.gz

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 (42) hide show
  1. {rushdb-2.3.0 → rushdb-2.9.0}/PKG-INFO +25 -2
  2. {rushdb-2.3.0 → rushdb-2.9.0}/README.md +24 -1
  3. {rushdb-2.3.0 → rushdb-2.9.0}/pyproject.toml +1 -1
  4. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/__init__.py +6 -0
  5. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/ai.py +12 -12
  6. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/records.py +5 -1
  7. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/relationship_patterns.py +3 -3
  8. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/transactions.py +2 -1
  9. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/client.py +14 -3
  10. rushdb-2.9.0/src/rushdb/common.py +33 -0
  11. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/__init__.py +6 -0
  12. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/api_response.py +1 -1
  13. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/property.py +3 -2
  14. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/result.py +7 -2
  15. rushdb-2.9.0/src/rushdb/models/search_query.py +93 -0
  16. {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_create_import.py +9 -3
  17. {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_search_query.py +224 -0
  18. {rushdb-2.3.0 → rushdb-2.9.0}/uv.lock +1 -1
  19. rushdb-2.3.0/src/rushdb/common.py +0 -20
  20. rushdb-2.3.0/src/rushdb/models/search_query.py +0 -37
  21. {rushdb-2.3.0 → rushdb-2.9.0}/.env.example +0 -0
  22. {rushdb-2.3.0 → rushdb-2.9.0}/.github/workflows/ci.yml +0 -0
  23. {rushdb-2.3.0 → rushdb-2.9.0}/.gitignore +0 -0
  24. {rushdb-2.3.0 → rushdb-2.9.0}/CODE_OF_CONDUCT.md +0 -0
  25. {rushdb-2.3.0 → rushdb-2.9.0}/CONTRIBUTING.md +0 -0
  26. {rushdb-2.3.0 → rushdb-2.9.0}/LICENSE +0 -0
  27. {rushdb-2.3.0 → rushdb-2.9.0}/rushdb-logo.svg +0 -0
  28. {rushdb-2.3.0 → rushdb-2.9.0}/src/__init__.py +0 -0
  29. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/__init__.py +0 -0
  30. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/base.py +0 -0
  31. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/labels.py +0 -0
  32. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/properties.py +0 -0
  33. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/query.py +0 -0
  34. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/relationships.py +0 -0
  35. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/record.py +0 -0
  36. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/relationship.py +0 -0
  37. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/transaction.py +0 -0
  38. {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/utils/token_prefix.py +0 -0
  39. {rushdb-2.3.0 → rushdb-2.9.0}/tests/__init__.py +0 -0
  40. {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_base_setup.py +0 -0
  41. {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_relationship_patterns.py +0 -0
  42. {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_search_result.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rushdb
3
- Version: 2.3.0
3
+ Version: 2.9.0
4
4
  Summary: RushDB Python SDK — memory layer for AI agents and modern apps
5
5
  Project-URL: Homepage, https://github.com/rush-db/rushdb-python
6
6
  Project-URL: Repository, https://github.com/rush-db/rushdb-python
@@ -135,6 +135,28 @@ authored_posts = db.records.find({
135
135
  'limit': 10,
136
136
  })
137
137
 
138
+ # Multi-hop: add hops to $relation — everyone in Alice's reporting chain, up to 4 levels
139
+ chain = db.records.find({
140
+ 'labels': ['EMPLOYEE'],
141
+ 'where': {
142
+ 'EMPLOYEE': {
143
+ '$relation': {'type': 'REPORTS_TO', 'direction': 'out', 'hops': {'min': 1, 'max': 4}},
144
+ 'name': {'$contains': 'Alice'},
145
+ }
146
+ },
147
+ })
148
+
149
+ # Cycle detection: accounts on a circular transfer ring (fraud rings, circular ownership)
150
+ ring_members = db.records.find({
151
+ 'labels': ['ACCOUNT'],
152
+ 'where': {
153
+ 'RING': { # key is a display name — the $cycle block holds only $relation
154
+ '$cycle': True,
155
+ '$relation': {'type': 'TRANSFERRED_TO', 'direction': 'out', 'hops': {'min': 2, 'max': 6}},
156
+ }
157
+ },
158
+ })
159
+
138
160
  # Manage relationships explicitly
139
161
  user = db.records.find_uniq({'labels': ['USER'], 'where': {'name': 'Alice'}})
140
162
  company = db.records.find_uniq({'labels': ['COMPANY'], 'where': {'name': 'Acme Corp'}})
@@ -161,7 +183,8 @@ csv_data = "name,email,age\nJohn,john@example.com,30\nJane,jane@example.com,25"
161
183
  db.records.import_csv(
162
184
  label='USER',
163
185
  data=csv_data,
164
- options={'returnResult': True, 'suggestTypes': True},
186
+ # skipEmptyValues: treat empty cells ("" / []) as unset instead of storing them (0/False are kept)
187
+ options={'returnResult': True, 'suggestTypes': True, 'skipEmptyValues': True},
165
188
  parse_config={'header': True, 'skipEmptyLines': True, 'dynamicTyping': True},
166
189
  )
167
190
  ```
@@ -110,6 +110,28 @@ authored_posts = db.records.find({
110
110
  'limit': 10,
111
111
  })
112
112
 
113
+ # Multi-hop: add hops to $relation — everyone in Alice's reporting chain, up to 4 levels
114
+ chain = db.records.find({
115
+ 'labels': ['EMPLOYEE'],
116
+ 'where': {
117
+ 'EMPLOYEE': {
118
+ '$relation': {'type': 'REPORTS_TO', 'direction': 'out', 'hops': {'min': 1, 'max': 4}},
119
+ 'name': {'$contains': 'Alice'},
120
+ }
121
+ },
122
+ })
123
+
124
+ # Cycle detection: accounts on a circular transfer ring (fraud rings, circular ownership)
125
+ ring_members = db.records.find({
126
+ 'labels': ['ACCOUNT'],
127
+ 'where': {
128
+ 'RING': { # key is a display name — the $cycle block holds only $relation
129
+ '$cycle': True,
130
+ '$relation': {'type': 'TRANSFERRED_TO', 'direction': 'out', 'hops': {'min': 2, 'max': 6}},
131
+ }
132
+ },
133
+ })
134
+
113
135
  # Manage relationships explicitly
114
136
  user = db.records.find_uniq({'labels': ['USER'], 'where': {'name': 'Alice'}})
115
137
  company = db.records.find_uniq({'labels': ['COMPANY'], 'where': {'name': 'Acme Corp'}})
@@ -136,7 +158,8 @@ csv_data = "name,email,age\nJohn,john@example.com,30\nJane,jane@example.com,25"
136
158
  db.records.import_csv(
137
159
  label='USER',
138
160
  data=csv_data,
139
- options={'returnResult': True, 'suggestTypes': True},
161
+ # skipEmptyValues: treat empty cells ("" / []) as unset instead of storing them (0/False are kept)
162
+ options={'returnResult': True, 'suggestTypes': True, 'skipEmptyValues': True},
140
163
  parse_config={'header': True, 'skipEmptyLines': True, 'dynamicTyping': True},
141
164
  )
142
165
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rushdb"
3
- version = "2.3.0"
3
+ version = "2.9.0"
4
4
  description = "RushDB Python SDK — memory layer for AI agents and modern apps"
5
5
  authors = [
6
6
  {name = "RushDB Team", email = "hi@rushdb.com"}
@@ -24,6 +24,9 @@ from .models.search_query import (
24
24
  RelationshipEndpointQuery,
25
25
  RelationshipSearchQuery,
26
26
  SearchQuery,
27
+ TraversalHops,
28
+ TraversalHopsRange,
29
+ TraversalRelationOptions,
27
30
  )
28
31
  from .models.transaction import Transaction
29
32
 
@@ -46,6 +49,9 @@ __all__ = [
46
49
  "SearchQuery",
47
50
  "RelationshipEndpointQuery",
48
51
  "RelationshipSearchQuery",
52
+ "TraversalHops",
53
+ "TraversalHopsRange",
54
+ "TraversalRelationOptions",
49
55
  "QueryAPI",
50
56
  "RelationsAPI",
51
57
  "RelationshipPatternsAPI",
@@ -1,6 +1,6 @@
1
1
  """AI API for RushDB Python SDK.
2
2
 
3
- Provides methods for graph ontology exploration, semantic vector search,
3
+ Provides methods for graph schema exploration, semantic vector search,
4
4
  and embedding index management.
5
5
  """
6
6
 
@@ -123,7 +123,7 @@ class AIAPI(BaseAPI):
123
123
  Example::
124
124
 
125
125
  >>> db = RushDB(api_key="...")
126
- >>> ontology = db.ai.get_ontology()
126
+ >>> schema = db.ai.get_schema()
127
127
  >>> results = db.ai.search({"query": "fast cars", "propertyName": "description"})
128
128
  >>> db.ai.indexes.create({"propertyName": "description", "label": "Book"})
129
129
  """
@@ -132,12 +132,12 @@ class AIAPI(BaseAPI):
132
132
  super().__init__(client)
133
133
  self.indexes = _AIIndexesNamespace(client)
134
134
 
135
- def get_ontology(
135
+ def get_schema(
136
136
  self,
137
137
  params: Optional[Dict[str, Any]] = None,
138
138
  transaction: Optional[Union[Transaction, str]] = None,
139
139
  ) -> ApiResponse:
140
- """Return the full graph ontology as structured JSON.
140
+ """Return the full graph schema as structured JSON.
141
141
 
142
142
  Each item contains the label name, record count, properties with value
143
143
  ranges/samples, and cross-label relationships with direction.
@@ -149,13 +149,13 @@ class AIAPI(BaseAPI):
149
149
 
150
150
  Args:
151
151
  params: Optional filter. Pass ``{"labels": ["Label1"]}`` to scope
152
- the ontology to specific labels only. Pass ``{"force": True}``
153
- to bypass the 1-hour ontology cache and trigger a full
152
+ the schema to specific labels only. Pass ``{"force": True}``
153
+ to bypass the 1-hour schema cache and trigger a full
154
154
  recalculation.
155
155
  transaction: Optional transaction context.
156
156
 
157
157
  Returns:
158
- ApiResponse: Response whose ``data`` is a list of ontology items.
158
+ ApiResponse: Response whose ``data`` is a list of schema items.
159
159
  """
160
160
  headers: Dict[str, str] = {}
161
161
  if transaction is not None:
@@ -165,7 +165,7 @@ class AIAPI(BaseAPI):
165
165
  headers["x-transaction-id"] = tx_id
166
166
 
167
167
  response = self.client._make_request(
168
- "POST", "/ai/ontology", params or {}, headers=headers or None
168
+ "POST", "/ai/schema", params or {}, headers=headers or None
169
169
  )
170
170
  return ApiResponse(
171
171
  data=response.get("data"),
@@ -173,12 +173,12 @@ class AIAPI(BaseAPI):
173
173
  total=response.get("total"),
174
174
  )
175
175
 
176
- def get_ontology_markdown(
176
+ def get_schema_markdown(
177
177
  self,
178
178
  params: Optional[Dict[str, Any]] = None,
179
179
  transaction: Optional[Union[Transaction, str]] = None,
180
180
  ) -> ApiResponse:
181
- """Return the full graph ontology as compact Markdown tables.
181
+ """Return the full graph schema as compact Markdown tables.
182
182
 
183
183
  Token-efficient representation intended for direct LLM consumption.
184
184
  Includes labels with counts, properties with types and value
@@ -191,7 +191,7 @@ class AIAPI(BaseAPI):
191
191
  Args:
192
192
  params: Optional filter. Pass ``{"labels": ["Label1"]}`` to scope
193
193
  the output to specific labels only. Pass ``{"force": True}``
194
- to bypass the 1-hour ontology cache and trigger a full
194
+ to bypass the 1-hour schema cache and trigger a full
195
195
  recalculation.
196
196
  transaction: Optional transaction context.
197
197
 
@@ -206,7 +206,7 @@ class AIAPI(BaseAPI):
206
206
  headers["x-transaction-id"] = tx_id
207
207
 
208
208
  response = self.client._make_request(
209
- "POST", "/ai/ontology/md", params or {}, headers=headers or None
209
+ "POST", "/ai/schema/md", params or {}, headers=headers or None
210
210
  )
211
211
  return ApiResponse(
212
212
  data=response.get("data"),
@@ -172,6 +172,8 @@ class RecordsAPI(BaseAPI):
172
172
  Available options:
173
173
  - returnResult (bool): Whether to return the created record data. Defaults to True.
174
174
  - suggestTypes (bool): Whether to automatically suggest data types. Defaults to True.
175
+ - skipEmptyValues (bool): Treat empty strings ("") and empty arrays ([]) as unset —
176
+ no property is created. 0 and False are kept. Defaults to False.
175
177
  vectors (Optional[List[Dict[str, Any]]]): Optional pre-computed embedding vectors
176
178
  to write alongside the record. Each entry must contain at least
177
179
  ``propertyName`` and ``vector``; ``similarityFunction`` is required when
@@ -231,7 +233,9 @@ class RecordsAPI(BaseAPI):
231
233
  record. Nested objects/arrays are not supported here — raises
232
234
  ``ValueError`` if any item contains a nested object or list.
233
235
  options: Optional write options forwarded as-is to the server
234
- (e.g. ``suggestTypes``, ``mergeBy``, ``mergeStrategy``, etc.).
236
+ (e.g. ``suggestTypes``, ``skipEmptyValues``, ``mergeBy``,
237
+ ``mergeStrategy``, etc.). ``skipEmptyValues`` treats empty strings
238
+ and empty arrays as unset (``0``/``False`` are kept); defaults to False.
235
239
  vectors: Optional per-row inline vectors for external embedding indexes.
236
240
  ``vectors[i]`` is applied to ``data[i]``. Each element is a list of
237
241
  vector entry dicts: ``[{"propertyName": str, "vector": List[float],
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
10
10
 
11
11
 
12
12
  class RelationshipPatternsAPI(BaseAPI):
13
- """Review and manage relationship patterns inferred from project ontology.
13
+ """Review and manage relationship patterns inferred from project schema.
14
14
 
15
15
  Accessed via ``db.relationships.patterns``.
16
16
  """
@@ -27,12 +27,12 @@ class RelationshipPatternsAPI(BaseAPI):
27
27
  )
28
28
 
29
29
  def list(self) -> ApiResponse:
30
- """List inferred patterns, ontology relationships, and analysis status."""
30
+ """List inferred patterns, schema relationships, and analysis status."""
31
31
  response = self.client._make_request("GET", "/relationships/patterns")
32
32
  return self._wrap(response)
33
33
 
34
34
  def analyze(self) -> ApiResponse:
35
- """Queue ontology analysis to generate relationship pattern suggestions."""
35
+ """Queue schema analysis to generate relationship pattern suggestions."""
36
36
  response = self.client._make_request(
37
37
  "POST", "/relationships/patterns/analyze", {}
38
38
  )
@@ -51,7 +51,8 @@ class TransactionsAPI(BaseAPI):
51
51
  Args:
52
52
  ttl (Optional[int], optional): Time-to-live in milliseconds for the transaction.
53
53
  After this time, the transaction will automatically expire and be rolled back.
54
- If None, defaults to 5000ms (5 seconds). Defaults to None.
54
+ If None, defaults to 5000ms (5 seconds). Values above the server-side cap
55
+ (60 seconds) are clamped to it. Defaults to None.
55
56
 
56
57
  Returns:
57
58
  Transaction: A Transaction object that can be used with other API operations.
@@ -144,7 +144,9 @@ class RushDB:
144
144
  else None
145
145
  )
146
146
  effective_url = url or base_url or self.DEFAULT_BASE_URL
147
- if url and "/api/" not in effective_url:
147
+ # Both `url` and its deprecated alias `base_url` are documented to get
148
+ # `/api/v1` appended when the path is absent.
149
+ if (url or base_url) and "/api/" not in effective_url:
148
150
  effective_url = effective_url.rstrip("/") + "/api/v1"
149
151
  self.base_url = effective_url.rstrip("/")
150
152
  self.api_key = api_key
@@ -245,8 +247,17 @@ class RushDB:
245
247
  with urllib.request.urlopen(request) as response:
246
248
  return json.loads(response.read().decode("utf-8"))
247
249
  except urllib.error.HTTPError as e:
248
- error_body = json.loads(e.read().decode("utf-8"))
249
- raise RushDBError(error_body.get("message", str(e)), error_body)
250
+ # Error bodies are usually JSON, but a gateway/proxy may return HTML or an
251
+ # empty body — without this guard the JSONDecodeError raised here would
252
+ # escape as-is instead of surfacing as a RushDBError.
253
+ try:
254
+ error_body = json.loads(e.read().decode("utf-8"))
255
+ except (json.JSONDecodeError, UnicodeDecodeError):
256
+ error_body = {}
257
+ message = error_body.get("message")
258
+ if isinstance(message, list):
259
+ message = "; ".join(str(item) for item in message)
260
+ raise RushDBError(message or f"HTTP {e.code}", error_body, status=e.code)
250
261
  except urllib.error.URLError as e:
251
262
  raise RushDBError(f"Connection error: {str(e)}")
252
263
  except json.JSONDecodeError as e:
@@ -0,0 +1,33 @@
1
+ from typing import Dict, Optional
2
+
3
+
4
+ class RushDBError(Exception):
5
+ """Custom exception for RushDB client errors.
6
+
7
+ Attributes:
8
+ details: Parsed error response body from the server, if any.
9
+ status: HTTP status code for server errors (e.g. 403 for a write attempt
10
+ with a read-only API key, 408 for a server-side transaction timeout).
11
+ ``None`` for client-side errors such as connection failures.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ details: Optional[Dict] = None,
18
+ status: Optional[int] = None,
19
+ ):
20
+ super().__init__(message)
21
+ self.details = details or {}
22
+ self.status = status
23
+
24
+
25
+ class NonUniqueResultError(Exception):
26
+ """Raised by ``records.find_uniq()`` when more than one record matches the query."""
27
+
28
+ def __init__(self, count: int):
29
+ super().__init__(
30
+ f"Expected at most one record but found {count}. "
31
+ "Refine your search query or use records.find() instead."
32
+ )
33
+ self.count = count
@@ -11,6 +11,9 @@ from .search_query import (
11
11
  RelationshipEndpointQuery,
12
12
  RelationshipSearchQuery,
13
13
  SearchQuery,
14
+ TraversalHops,
15
+ TraversalHopsRange,
16
+ TraversalRelationOptions,
14
17
  )
15
18
  from .transaction import Transaction
16
19
 
@@ -26,5 +29,8 @@ __all__ = [
26
29
  "SearchQuery",
27
30
  "RelationshipEndpointQuery",
28
31
  "RelationshipSearchQuery",
32
+ "TraversalHops",
33
+ "TraversalHopsRange",
34
+ "TraversalRelationOptions",
29
35
  "Transaction",
30
36
  ]
@@ -19,7 +19,7 @@ class ApiResponse(Generic[T]):
19
19
  total: Optional total count (e.g. for list endpoints).
20
20
 
21
21
  Example:
22
- >>> response = db.ai.get_ontology_markdown()
22
+ >>> response = db.ai.get_schema_markdown()
23
23
  >>> schema_md = response.data # str
24
24
  >>> response = db.ai.indexes.find()
25
25
  >>> indexes = response.data # list[dict]
@@ -21,8 +21,9 @@ BooleanValue = bool
21
21
  NumberValue = Union[float, int]
22
22
  StringValue = str
23
23
 
24
- # Property types
25
- PropertyType = Literal["boolean", "datetime", "null", "number", "string"]
24
+ # Property types. A None/null value means the field is unset; `None` is still accepted as an
25
+ # input value in `PropertyWithValue.value` below.
26
+ PropertyType = Literal["boolean", "datetime", "number", "string"]
26
27
 
27
28
 
28
29
  class Property(TypedDict):
@@ -246,5 +246,10 @@ class SearchResult(Generic[T]):
246
246
  return pd.DataFrame(rows)
247
247
 
248
248
 
249
- # Type alias for record search results
250
- RecordSearchResult = SearchResult[Record]
249
+ class RecordSearchResult(SearchResult[Record]):
250
+ """Search result specialized for ``Record`` items.
251
+
252
+ A real subclass (not a ``SearchResult[Record]`` alias) so that
253
+ ``isinstance(result, RecordSearchResult)`` works — isinstance checks against
254
+ subscripted generics raise ``TypeError`` at runtime.
255
+ """
@@ -0,0 +1,93 @@
1
+ from enum import Enum
2
+ from typing import Any, Dict, List, Optional, TypedDict, Union
3
+
4
+
5
+ class OrderDirection(str, Enum):
6
+ ASC = "asc"
7
+ DESC = "desc"
8
+
9
+
10
+ class TraversalHopsRange(TypedDict, total=False):
11
+ """Hop range for variable-length traversal.
12
+
13
+ ``min`` defaults to 1 (2 inside a ``$cycle`` block). Omitting ``max``
14
+ requests unbounded traversal, which is only allowed on self-hosted
15
+ deployments and projects with a custom Neo4j instance; the shared cloud
16
+ connection caps ``max`` per deployment (default 25).
17
+ """
18
+
19
+ min: int
20
+ max: int
21
+
22
+
23
+ TraversalHops = Union[int, TraversalHopsRange]
24
+ """Depth of a variable-length traversal: an exact hop count or a range."""
25
+
26
+
27
+ class TraversalRelationOptions(TypedDict, total=False):
28
+ """Value for the ``$relation`` key of a traversal block in ``where``.
29
+
30
+ ``type`` and ``direction`` constrain every hop; ``hops`` makes the
31
+ traversal variable-length. The nested label constrains only the final
32
+ record — intermediate records are anonymous.
33
+
34
+ Example::
35
+
36
+ {
37
+ "labels": ["EMPLOYEE"],
38
+ "where": {
39
+ "EMPLOYEE": {
40
+ "$alias": "$manager",
41
+ "$relation": {
42
+ "type": "REPORTS_TO",
43
+ "direction": "out",
44
+ "hops": {"min": 1, "max": 4},
45
+ },
46
+ "name": {"$contains": "Alice"},
47
+ }
48
+ },
49
+ }
50
+ """
51
+
52
+ type: str
53
+ direction: str # 'in' | 'out'
54
+ hops: TraversalHops
55
+
56
+
57
+ class SearchQuery(TypedDict, total=False):
58
+ """TypedDict representing the query structure for finding records.
59
+
60
+ Inside ``where``, a nested key that is a label name traverses to related
61
+ records. Traversal blocks accept ``$alias`` (name the endpoint for
62
+ ``select``/``groupBy``), ``$relation`` (a type string or
63
+ :class:`TraversalRelationOptions`, including variable-length ``hops``),
64
+ and ``$cycle`` (``True`` binds the traversal back to the parent record to
65
+ detect rings; requires ``$relation`` with ``hops`` where ``min`` >= 2 and
66
+ accepts no other keys).
67
+ """
68
+
69
+ where: Optional[Dict[str, Any]]
70
+ labels: Optional[List[str]]
71
+ skip: Optional[int]
72
+ limit: Optional[int]
73
+ orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
74
+ select: Optional[Dict[str, Any]]
75
+ groupBy: Optional[List[str]]
76
+
77
+
78
+ class RelationshipEndpointQuery(TypedDict, total=False):
79
+ """Endpoint record predicates for relationship search."""
80
+
81
+ where: Optional[Dict[str, Any]]
82
+ labels: Optional[List[str]]
83
+
84
+
85
+ class RelationshipSearchQuery(TypedDict, total=False):
86
+ """TypedDict representing the query structure for finding relationship edges."""
87
+
88
+ where: Optional[Dict[str, Any]]
89
+ source: Optional[RelationshipEndpointQuery]
90
+ target: Optional[RelationshipEndpointQuery]
91
+ skip: Optional[int]
92
+ limit: Optional[int]
93
+ orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
@@ -40,7 +40,7 @@ class TestCreateImport(TestBase):
40
40
  # Test new functionality
41
41
  self.assertEqual(record.get("name"), "Google LLC")
42
42
  self.assertEqual(record.get("nonexistent", "default"), "default")
43
- self.assertTrue(record.exists())
43
+ self.assertTrue(record.exists)
44
44
 
45
45
  def test_record_methods(self):
46
46
  """Test Record class methods"""
@@ -201,7 +201,11 @@ class TestCreateImport(TestBase):
201
201
  )
202
202
 
203
203
  def test_create_with_nested_data(self):
204
- """Test creating records with nested data structure"""
204
+ """Test importing records with nested data structure.
205
+
206
+ ``create_many`` accepts flat rows only (it raises ``ValueError`` for
207
+ nested payloads); nested JSON goes through ``import_json``.
208
+ """
205
209
  data = {
206
210
  "name": "Meta Platforms Inc",
207
211
  "rating": 4.6,
@@ -220,7 +224,9 @@ class TestCreateImport(TestBase):
220
224
  }
221
225
  ],
222
226
  }
223
- self.client.records.create_many("COMPANY", data)
227
+ with self.assertRaises(ValueError):
228
+ self.client.records.create_many("COMPANY", data)
229
+ self.client.records.import_json(data=data, label="COMPANY")
224
230
 
225
231
  def test_transaction_rollback(self):
226
232
  """Test transaction rollback"""
@@ -1,6 +1,10 @@
1
1
  """Test cases for RushDB search query functionality."""
2
2
 
3
+ import time
3
4
  import unittest
5
+ import uuid
6
+
7
+ from src.rushdb import RushDB, RushDBError
4
8
 
5
9
  from .test_base_setup import TestBase
6
10
 
@@ -389,5 +393,225 @@ class TestSearchQuery(TestBase):
389
393
  self.client.records.find(query)
390
394
 
391
395
 
396
+ class TestMultihopAndCycles(TestBase):
397
+ """Variable-length traversal ($relation.hops) and cycle detection ($cycle).
398
+
399
+ Seeds its own graph, isolated by a unique tenantId:
400
+
401
+ Reporting chain (MHEmployee, REPORTS_TO, directed "up"):
402
+ E1 -> E2 -> E3 -> E4
403
+
404
+ Transfer ring + linear chain (MHAccount, TRANSFERRED_TO):
405
+ A -> B -> C -> A (3-hop directed ring)
406
+ X -> Y -> Z (no cycle)
407
+ """
408
+
409
+ tenant: str
410
+
411
+ @classmethod
412
+ def setUpClass(cls):
413
+ super().setUpClass()
414
+ cls.tenant = f"multihop-{uuid.uuid4().hex[:8]}"
415
+ # TestBase only creates a client per-test (setUp); seeding needs one here.
416
+ cls.client = RushDB(cls.token, base_url=cls.base_url)
417
+
418
+ # TestBase skips per-test in setUp when the server is unreachable; this
419
+ # class seeds data in setUpClass, so it must skip here for the same
420
+ # reason (e.g. CI without a running RushDB) instead of erroring.
421
+ try:
422
+ if not cls.client.ping():
423
+ raise unittest.SkipTest(
424
+ f"Could not connect to RushDB at {cls.base_url}"
425
+ )
426
+ except RushDBError as e:
427
+ raise unittest.SkipTest(f"RushDB connection error: {str(e)}")
428
+
429
+ cls.client.records.create_many(
430
+ "MHEmployee",
431
+ [
432
+ {"name": "E1", "managerName": "E2", "tenantId": cls.tenant},
433
+ {"name": "E2", "managerName": "E3", "tenantId": cls.tenant},
434
+ {"name": "E3", "managerName": "E4", "tenantId": cls.tenant},
435
+ {"name": "E4", "tenantId": cls.tenant},
436
+ ],
437
+ )
438
+ cls.client.relationships.create_many(
439
+ source={
440
+ "label": "MHEmployee",
441
+ "key": "managerName",
442
+ "where": {"tenantId": cls.tenant},
443
+ },
444
+ target={
445
+ "label": "MHEmployee",
446
+ "key": "name",
447
+ "where": {"tenantId": cls.tenant},
448
+ },
449
+ type="REPORTS_TO",
450
+ direction="out",
451
+ )
452
+
453
+ cls.client.records.create_many(
454
+ "MHAccount",
455
+ [
456
+ {"name": "A", "sendsTo": "B", "tenantId": cls.tenant},
457
+ {"name": "B", "sendsTo": "C", "tenantId": cls.tenant},
458
+ {"name": "C", "sendsTo": "A", "tenantId": cls.tenant},
459
+ {"name": "X", "sendsTo": "Y", "tenantId": cls.tenant},
460
+ {"name": "Y", "sendsTo": "Z", "tenantId": cls.tenant},
461
+ {"name": "Z", "tenantId": cls.tenant},
462
+ ],
463
+ )
464
+ cls.client.relationships.create_many(
465
+ source={
466
+ "label": "MHAccount",
467
+ "key": "sendsTo",
468
+ "where": {"tenantId": cls.tenant},
469
+ },
470
+ target={
471
+ "label": "MHAccount",
472
+ "key": "name",
473
+ "where": {"tenantId": cls.tenant},
474
+ },
475
+ type="TRANSFERRED_TO",
476
+ direction="out", # sender -> receiver
477
+ )
478
+
479
+ # relationships.create_many is applied via apoc.periodic.iterate — poll
480
+ # until both relationship sets are visible.
481
+ for _ in range(10):
482
+ employee_rels = cls.client.relationships.find(
483
+ {
484
+ "source": {
485
+ "labels": ["MHEmployee"],
486
+ "where": {"tenantId": cls.tenant},
487
+ },
488
+ "limit": 100,
489
+ }
490
+ )
491
+ account_rels = cls.client.relationships.find(
492
+ {
493
+ "source": {
494
+ "labels": ["MHAccount"],
495
+ "where": {"tenantId": cls.tenant},
496
+ },
497
+ "limit": 100,
498
+ }
499
+ )
500
+ if len(employee_rels.data) >= 3 and len(account_rels.data) >= 5:
501
+ break
502
+ time.sleep(1)
503
+
504
+ @classmethod
505
+ def tearDownClass(cls):
506
+ cls.client.records.delete({"where": {"tenantId": cls.tenant}})
507
+ super().tearDownClass()
508
+
509
+ def _names(self, result):
510
+ return sorted(record.get("name") for record in result)
511
+
512
+ def test_hops_range_reaches_chain_top(self):
513
+ """hops {max: 3} reaches E4 from every subordinate"""
514
+ result = self.client.records.find(
515
+ {
516
+ "labels": ["MHEmployee"],
517
+ "where": {
518
+ "tenantId": self.tenant,
519
+ "MHEmployee": {
520
+ "$relation": {
521
+ "type": "REPORTS_TO",
522
+ "direction": "out",
523
+ "hops": {"max": 3},
524
+ },
525
+ "name": "E4",
526
+ },
527
+ },
528
+ }
529
+ )
530
+ self.assertEqual(self._names(result), ["E1", "E2", "E3"])
531
+
532
+ def test_hops_range_is_bounded(self):
533
+ """hops {max: 2} does not reach 3 hops away"""
534
+ result = self.client.records.find(
535
+ {
536
+ "labels": ["MHEmployee"],
537
+ "where": {
538
+ "tenantId": self.tenant,
539
+ "MHEmployee": {
540
+ "$relation": {
541
+ "type": "REPORTS_TO",
542
+ "direction": "out",
543
+ "hops": {"max": 2},
544
+ },
545
+ "name": "E4",
546
+ },
547
+ },
548
+ }
549
+ )
550
+ self.assertEqual(self._names(result), ["E2", "E3"])
551
+
552
+ def test_hops_exact_count(self):
553
+ """hops: 3 matches exactly 3 hops"""
554
+ result = self.client.records.find(
555
+ {
556
+ "labels": ["MHEmployee"],
557
+ "where": {
558
+ "tenantId": self.tenant,
559
+ "MHEmployee": {
560
+ "$relation": {
561
+ "type": "REPORTS_TO",
562
+ "direction": "out",
563
+ "hops": 3,
564
+ },
565
+ "name": "E4",
566
+ },
567
+ },
568
+ }
569
+ )
570
+ self.assertEqual(self._names(result), ["E1"])
571
+
572
+ def test_cycle_flags_ring_members(self):
573
+ """$cycle returns exactly the ring participants with a deduplicated total"""
574
+ result = self.client.records.find(
575
+ {
576
+ "labels": ["MHAccount"],
577
+ "where": {
578
+ "tenantId": self.tenant,
579
+ "RING": {
580
+ "$cycle": True,
581
+ "$relation": {
582
+ "type": "TRANSFERRED_TO",
583
+ "direction": "out",
584
+ "hops": {"min": 2, "max": 6},
585
+ },
586
+ },
587
+ },
588
+ }
589
+ )
590
+ self.assertEqual(self._names(result), ["A", "B", "C"])
591
+ self.assertEqual(result.total, 3)
592
+
593
+ def test_not_cycle_excludes_ring_members(self):
594
+ """$not around a $cycle block finds acyclic accounts"""
595
+ result = self.client.records.find(
596
+ {
597
+ "labels": ["MHAccount"],
598
+ "where": {
599
+ "tenantId": self.tenant,
600
+ "$not": {
601
+ "RING": {
602
+ "$cycle": True,
603
+ "$relation": {
604
+ "type": "TRANSFERRED_TO",
605
+ "direction": "out",
606
+ "hops": {"min": 2, "max": 6},
607
+ },
608
+ }
609
+ },
610
+ },
611
+ }
612
+ )
613
+ self.assertEqual(self._names(result), ["X", "Y", "Z"])
614
+
615
+
392
616
  if __name__ == "__main__":
393
617
  unittest.main()
@@ -785,7 +785,7 @@ wheels = [
785
785
 
786
786
  [[package]]
787
787
  name = "rushdb"
788
- version = "2.3.0"
788
+ version = "2.9.0"
789
789
  source = { editable = "." }
790
790
  dependencies = [
791
791
  { name = "python-dotenv", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -1,20 +0,0 @@
1
- from typing import Dict, Optional
2
-
3
-
4
- class RushDBError(Exception):
5
- """Custom exception for RushDB client errors."""
6
-
7
- def __init__(self, message: str, details: Optional[Dict] = None):
8
- super().__init__(message)
9
- self.details = details or {}
10
-
11
-
12
- class NonUniqueResultError(Exception):
13
- """Raised by ``records.find_uniq()`` when more than one record matches the query."""
14
-
15
- def __init__(self, count: int):
16
- super().__init__(
17
- f"Expected at most one record but found {count}. "
18
- "Refine your search query or use records.find() instead."
19
- )
20
- self.count = count
@@ -1,37 +0,0 @@
1
- from enum import Enum
2
- from typing import Any, Dict, List, Optional, TypedDict, Union
3
-
4
-
5
- class OrderDirection(str, Enum):
6
- ASC = "asc"
7
- DESC = "desc"
8
-
9
-
10
- class SearchQuery(TypedDict, total=False):
11
- """TypedDict representing the query structure for finding records."""
12
-
13
- where: Optional[Dict[str, Any]]
14
- labels: Optional[List[str]]
15
- skip: Optional[int]
16
- limit: Optional[int]
17
- orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
18
- select: Optional[Dict[str, Any]]
19
- groupBy: Optional[List[str]]
20
-
21
-
22
- class RelationshipEndpointQuery(TypedDict, total=False):
23
- """Endpoint record predicates for relationship search."""
24
-
25
- where: Optional[Dict[str, Any]]
26
- labels: Optional[List[str]]
27
-
28
-
29
- class RelationshipSearchQuery(TypedDict, total=False):
30
- """TypedDict representing the query structure for finding relationship edges."""
31
-
32
- where: Optional[Dict[str, Any]]
33
- source: Optional[RelationshipEndpointQuery]
34
- target: Optional[RelationshipEndpointQuery]
35
- skip: Optional[int]
36
- limit: Optional[int]
37
- orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes