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.
- {rushdb-2.3.0 → rushdb-2.9.0}/PKG-INFO +25 -2
- {rushdb-2.3.0 → rushdb-2.9.0}/README.md +24 -1
- {rushdb-2.3.0 → rushdb-2.9.0}/pyproject.toml +1 -1
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/__init__.py +6 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/ai.py +12 -12
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/records.py +5 -1
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/relationship_patterns.py +3 -3
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/transactions.py +2 -1
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/client.py +14 -3
- rushdb-2.9.0/src/rushdb/common.py +33 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/__init__.py +6 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/api_response.py +1 -1
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/property.py +3 -2
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/result.py +7 -2
- rushdb-2.9.0/src/rushdb/models/search_query.py +93 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_create_import.py +9 -3
- {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_search_query.py +224 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/uv.lock +1 -1
- rushdb-2.3.0/src/rushdb/common.py +0 -20
- rushdb-2.3.0/src/rushdb/models/search_query.py +0 -37
- {rushdb-2.3.0 → rushdb-2.9.0}/.env.example +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/.github/workflows/ci.yml +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/.gitignore +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/CODE_OF_CONDUCT.md +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/CONTRIBUTING.md +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/LICENSE +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/rushdb-logo.svg +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/__init__.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/__init__.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/base.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/labels.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/properties.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/query.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/api/relationships.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/record.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/relationship.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/models/transaction.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/src/rushdb/utils/token_prefix.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/tests/__init__.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_base_setup.py +0 -0
- {rushdb-2.3.0 → rushdb-2.9.0}/tests/test_relationship_patterns.py +0 -0
- {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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
```
|
|
@@ -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
|
|
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
|
-
>>>
|
|
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
|
|
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
|
|
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
|
|
153
|
-
to bypass the 1-hour
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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``, ``
|
|
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
|
|
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,
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|