rushdb 3.0.1__tar.gz → 3.2.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 (49) hide show
  1. {rushdb-3.0.1 → rushdb-3.2.0}/PKG-INFO +21 -5
  2. {rushdb-3.0.1 → rushdb-3.2.0}/README.md +20 -4
  3. {rushdb-3.0.1 → rushdb-3.2.0}/pyproject.toml +1 -1
  4. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/ai.py +43 -21
  5. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/query.py +1 -1
  6. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/relationships.py +56 -0
  7. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/direct/capabilities.py +2 -1
  8. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/direct/router.py +36 -1
  9. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/result.py +43 -3
  10. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_ai_vector_search.py +28 -24
  11. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_direct_mode.py +37 -0
  12. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_direct_mode_live.py +21 -0
  13. {rushdb-3.0.1 → rushdb-3.2.0}/uv.lock +1 -1
  14. {rushdb-3.0.1 → rushdb-3.2.0}/.env.example +0 -0
  15. {rushdb-3.0.1 → rushdb-3.2.0}/.github/workflows/ci.yml +0 -0
  16. {rushdb-3.0.1 → rushdb-3.2.0}/.gitignore +0 -0
  17. {rushdb-3.0.1 → rushdb-3.2.0}/CODE_OF_CONDUCT.md +0 -0
  18. {rushdb-3.0.1 → rushdb-3.2.0}/CONTRIBUTING.md +0 -0
  19. {rushdb-3.0.1 → rushdb-3.2.0}/LICENSE +0 -0
  20. {rushdb-3.0.1 → rushdb-3.2.0}/rushdb-logo.svg +0 -0
  21. {rushdb-3.0.1 → rushdb-3.2.0}/src/__init__.py +0 -0
  22. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/__init__.py +0 -0
  23. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/__init__.py +0 -0
  24. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/base.py +0 -0
  25. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/labels.py +0 -0
  26. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/properties.py +0 -0
  27. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/records.py +0 -0
  28. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/relationship_patterns.py +0 -0
  29. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/text_indexes.py +0 -0
  30. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/api/transactions.py +0 -0
  31. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/client.py +0 -0
  32. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/common.py +0 -0
  33. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/direct/__init__.py +0 -0
  34. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/direct/engine.py +0 -0
  35. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/direct/schema.py +0 -0
  36. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/__init__.py +0 -0
  37. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/api_response.py +0 -0
  38. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/property.py +0 -0
  39. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/record.py +0 -0
  40. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/relationship.py +0 -0
  41. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/search_query.py +0 -0
  42. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/models/transaction.py +0 -0
  43. {rushdb-3.0.1 → rushdb-3.2.0}/src/rushdb/utils/token_prefix.py +0 -0
  44. {rushdb-3.0.1 → rushdb-3.2.0}/tests/__init__.py +0 -0
  45. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_base_setup.py +0 -0
  46. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_create_import.py +0 -0
  47. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_relationship_patterns.py +0 -0
  48. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_search_query.py +0 -0
  49. {rushdb-3.0.1 → rushdb-3.2.0}/tests/test_search_result.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rushdb
3
- Version: 3.0.1
3
+ Version: 3.2.0
4
4
  Summary: RushDB SDK for Python: the client for RushDB, the database for AI (RushDB Cloud or self-hosted)
5
5
  Project-URL: Homepage, https://github.com/rush-db/rushdb-python
6
6
  Project-URL: Repository, https://github.com/rush-db/rushdb-python
@@ -93,7 +93,7 @@ What each offers:
93
93
  | Feature | Cloud | Self-hosted engine |
94
94
  | --- | :---: | :---: |
95
95
  | Records: create, read, update, set, upsert, delete, `find` with `where`, `orderBy`, `select`, `groupBy` | ✓ | ✓ |
96
- | Relationships: `attach`/`detach`, `relationships.create_many`/`delete_many`/`find` | ✓ | ✓ |
96
+ | Relationships: `attach`/`detach`, `relationships.create_many`/`delete_many`/`find`/`update` | ✓ | ✓ |
97
97
  | Labels, properties, property values | ✓ | ✓ |
98
98
  | Import JSON and CSV, export CSV | ✓ | ✓ |
99
99
  | External vector indexes; vector search with a `queryVector` you supply | ✓ | ✓ |
@@ -102,7 +102,7 @@ What each offers:
102
102
  | Keyword (BM25) search (`records.text_search`), text indexes (`text_indexes.*`), hybrid search | ✓ | ✓ |
103
103
  | Transactions | ✓ | record creates, updates, sets, deletes by id, attaches and imports are staged; other writes refuse a transaction |
104
104
  | Managed embeddings: managed indexes, `records.vector_search` by text `query` | ✓ | — |
105
- | Natural-language search (`ai.search`) | ✓ | — |
105
+ | Natural-language search on SQL (`ai.search`, `ai.run_sql`) | ✓ | — |
106
106
  | Relationship-pattern suggestions (`relationships.patterns.*`) | ✓ | — |
107
107
  | Project settings (`settings.get`), dashboard, team access | ✓ | — |
108
108
 
@@ -227,6 +227,13 @@ relationships = db.relationships.find({
227
227
  'target': {'labels': ['COMPANY']},
228
228
  'where': {'type': 'WORKS_AT', 'source': 'profile'},
229
229
  })
230
+
231
+ # Change properties on existing relationships (RushDB 3.0.4+): set writes, unset removes
232
+ db.relationships.update(user, company, type='WORKS_AT', set={'source': 'hr'}, unset=['legacy'])
233
+ # types marks values JSON can't type: 'datetime' stores an ISO string as a datetime
234
+ db.relationships.update(
235
+ user, company, type='WORKS_AT', set={'since': '2024-03-01'}, types={'since': 'datetime'}
236
+ )
230
237
  ```
231
238
 
232
239
  ---
@@ -280,6 +287,7 @@ if result:
280
287
  | `total` | `int` | Total matching records in the database |
281
288
  | `has_more` | `bool` | Whether more records exist beyond this page |
282
289
  | `search_query` | `dict` | The query that produced this result |
290
+ | `sql` | `str` | For Smart search, the SQL statement it ran |
283
291
 
284
292
  ---
285
293
 
@@ -302,13 +310,21 @@ for record in results:
302
310
  ```
303
311
 
304
312
  Use `db.ai.search()` when you want RushDB to turn a natural-language request
305
- into a SearchQuery and execute it:
313
+ into one read-only SQL statement over your schema and run it (RushDB Cloud):
306
314
 
307
315
  ```python
308
316
  results = db.ai.search('Find active memories about Q4 results for agent-42')
309
- print(results.search_query)
317
+ print(results.name) # a short title, e.g. to save the query under
318
+ print(results.sql) # the statement it ran
319
+ more = results.next() # the next page of the statement's rows
320
+
321
+ # Run a statement directly, e.g. a saved one:
322
+ rows = db.ai.run_sql('SELECT agent_id, count(*) AS memories FROM "MEMORY" GROUP BY agent_id')
310
323
  ```
311
324
 
325
+ Rows that select a record's `__id` come back as full records; aggregates come
326
+ back as plain rows.
327
+
312
328
  `db.ai.search({...})` still works as a deprecated vector-search alias, but new
313
329
  code should use `db.records.vector_search({...})`.
314
330
 
@@ -79,7 +79,7 @@ What each offers:
79
79
  | Feature | Cloud | Self-hosted engine |
80
80
  | --- | :---: | :---: |
81
81
  | Records: create, read, update, set, upsert, delete, `find` with `where`, `orderBy`, `select`, `groupBy` | ✓ | ✓ |
82
- | Relationships: `attach`/`detach`, `relationships.create_many`/`delete_many`/`find` | ✓ | ✓ |
82
+ | Relationships: `attach`/`detach`, `relationships.create_many`/`delete_many`/`find`/`update` | ✓ | ✓ |
83
83
  | Labels, properties, property values | ✓ | ✓ |
84
84
  | Import JSON and CSV, export CSV | ✓ | ✓ |
85
85
  | External vector indexes; vector search with a `queryVector` you supply | ✓ | ✓ |
@@ -88,7 +88,7 @@ What each offers:
88
88
  | Keyword (BM25) search (`records.text_search`), text indexes (`text_indexes.*`), hybrid search | ✓ | ✓ |
89
89
  | Transactions | ✓ | record creates, updates, sets, deletes by id, attaches and imports are staged; other writes refuse a transaction |
90
90
  | Managed embeddings: managed indexes, `records.vector_search` by text `query` | ✓ | — |
91
- | Natural-language search (`ai.search`) | ✓ | — |
91
+ | Natural-language search on SQL (`ai.search`, `ai.run_sql`) | ✓ | — |
92
92
  | Relationship-pattern suggestions (`relationships.patterns.*`) | ✓ | — |
93
93
  | Project settings (`settings.get`), dashboard, team access | ✓ | — |
94
94
 
@@ -213,6 +213,13 @@ relationships = db.relationships.find({
213
213
  'target': {'labels': ['COMPANY']},
214
214
  'where': {'type': 'WORKS_AT', 'source': 'profile'},
215
215
  })
216
+
217
+ # Change properties on existing relationships (RushDB 3.0.4+): set writes, unset removes
218
+ db.relationships.update(user, company, type='WORKS_AT', set={'source': 'hr'}, unset=['legacy'])
219
+ # types marks values JSON can't type: 'datetime' stores an ISO string as a datetime
220
+ db.relationships.update(
221
+ user, company, type='WORKS_AT', set={'since': '2024-03-01'}, types={'since': 'datetime'}
222
+ )
216
223
  ```
217
224
 
218
225
  ---
@@ -266,6 +273,7 @@ if result:
266
273
  | `total` | `int` | Total matching records in the database |
267
274
  | `has_more` | `bool` | Whether more records exist beyond this page |
268
275
  | `search_query` | `dict` | The query that produced this result |
276
+ | `sql` | `str` | For Smart search, the SQL statement it ran |
269
277
 
270
278
  ---
271
279
 
@@ -288,13 +296,21 @@ for record in results:
288
296
  ```
289
297
 
290
298
  Use `db.ai.search()` when you want RushDB to turn a natural-language request
291
- into a SearchQuery and execute it:
299
+ into one read-only SQL statement over your schema and run it (RushDB Cloud):
292
300
 
293
301
  ```python
294
302
  results = db.ai.search('Find active memories about Q4 results for agent-42')
295
- print(results.search_query)
303
+ print(results.name) # a short title, e.g. to save the query under
304
+ print(results.sql) # the statement it ran
305
+ more = results.next() # the next page of the statement's rows
306
+
307
+ # Run a statement directly, e.g. a saved one:
308
+ rows = db.ai.run_sql('SELECT agent_id, count(*) AS memories FROM "MEMORY" GROUP BY agent_id')
296
309
  ```
297
310
 
311
+ Rows that select a record's `__id` come back as full records; aggregates come
312
+ back as plain rows.
313
+
298
314
  `db.ai.search({...})` still works as a deprecated vector-search alias, but new
299
315
  code should use `db.records.vector_search({...})`.
300
316
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rushdb"
3
- version = "3.0.1"
3
+ version = "3.2.0"
4
4
  description = "RushDB SDK for Python: the client for RushDB, the database for AI (RushDB Cloud or self-hosted)"
5
5
  authors = [
6
6
  {name = "RushDB Team", email = "hi@rushdb.com"}
@@ -5,12 +5,11 @@ and embedding index management.
5
5
  """
6
6
 
7
7
  import warnings
8
- from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast
8
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Union
9
9
 
10
10
  from ..models.api_response import ApiResponse
11
11
  from ..models.record import Record
12
12
  from ..models.result import RecordSearchResult
13
- from ..models.search_query import SearchQuery
14
13
  from ..models.transaction import Transaction
15
14
  from .base import BaseAPI
16
15
 
@@ -225,14 +224,18 @@ class AIAPI(BaseAPI):
225
224
  def search(
226
225
  self,
227
226
  prompt: Union[str, Dict[str, Any]],
228
- current_query: Optional[SearchQuery] = None,
227
+ skip: Optional[int] = None,
228
+ limit: Optional[int] = None,
229
229
  transaction: Optional[Union[Transaction, str]] = None,
230
230
  ) -> RecordSearchResult:
231
231
  """Perform AI-assisted smart search from natural language.
232
232
 
233
- RushDB converts ``prompt`` into a SearchQuery using the project schema,
234
- executes it, and returns matching records with the generated query
235
- attached to ``result.search_query``.
233
+ RushDB turns ``prompt`` into one read-only SQL statement over the
234
+ project's schema, runs it, and returns the result with the statement
235
+ as ``result.sql``, a short title as ``result.name`` and any generation
236
+ warnings as ``result.warnings``. Rows that select a record's ``__id``
237
+ come back as full records; aggregates come back as plain rows.
238
+ ``result.next()`` pages through the statement.
236
239
 
237
240
  Use ``db.records.vector_search({...})`` for direct vector similarity
238
241
  over embedding indexes.
@@ -240,14 +243,12 @@ class AIAPI(BaseAPI):
240
243
  Args:
241
244
  prompt: Natural-language search request. Passing a dict is
242
245
  deprecated and delegates to ``db.records.vector_search``.
243
- current_query: Optional current SearchQuery context from a
244
- dashboard/query-builder session.
245
- transaction: Optional transaction context.
246
+ skip: Rows to skip (default 0).
247
+ limit: Rows to return, at most 1000 (default 100).
248
+ transaction: Only used by the deprecated dict form.
246
249
 
247
250
  Returns:
248
- RecordSearchResult: Matching records. The generated SearchQuery is
249
- available as ``result.search_query`` and server warnings as
250
- ``result.warnings``.
251
+ RecordSearchResult: The statement's first page of rows.
251
252
  """
252
253
  if isinstance(prompt, dict):
253
254
  warnings.warn(
@@ -258,24 +259,45 @@ class AIAPI(BaseAPI):
258
259
  )
259
260
  return self.client.records.vector_search(prompt, transaction=transaction)
260
261
 
261
- headers = Transaction._build_transaction_header(transaction)
262
262
  generated = self.client._make_request(
263
- "POST",
264
- "/ai/search-query",
265
- {"prompt": prompt, "currentQuery": current_query},
266
- headers,
263
+ "POST", "/ai/sql-search", {"prompt": prompt}
267
264
  )
268
265
  generated_data = generated.get("data") or {}
269
- search_query = generated_data.get("searchQuery") or {}
266
+ result = self.run_sql(generated_data.get("sql", ""), skip=skip, limit=limit)
267
+ result._name = generated_data.get("name")
268
+ result._warnings = generated_data.get("warnings") or []
269
+ return result
270
+
271
+ def run_sql(
272
+ self,
273
+ sql: str,
274
+ skip: Optional[int] = None,
275
+ limit: Optional[int] = None,
276
+ ) -> RecordSearchResult:
277
+ """Run one read-only SQL statement over the project's schema, as Smart
278
+ search does: a statement from ``ai.search`` (its ``sql``), or one
279
+ written by hand. Labels are tables; relationship types are
280
+ ``rushdb_edges.<type>`` tables.
270
281
 
282
+ Args:
283
+ sql: One read-only SELECT statement.
284
+ skip: Rows to skip (default 0).
285
+ limit: Rows to return, at most 1000 (default 100).
286
+
287
+ Returns:
288
+ RecordSearchResult: Full records for rows that select a record's
289
+ ``__id``, otherwise the rows themselves; ``result.sql`` holds the
290
+ statement and ``result.next()`` fetches the following page.
291
+ """
292
+ page = {"skip": skip or 0, "limit": limit or 100}
271
293
  response = self.client._make_request(
272
- "POST", "/records/search", search_query, headers
294
+ "POST", "/records/sql-search", {"sql": sql, **page}
273
295
  )
274
296
  records = [Record(self.client, item) for item in response.get("data", [])]
275
297
  return RecordSearchResult(
276
298
  data=records,
277
299
  total=response.get("total", len(records)),
278
- search_query=cast(SearchQuery, search_query),
279
300
  client=self.client,
280
- warnings=generated_data.get("warnings") or [],
301
+ sql=sql,
302
+ sql_page=page,
281
303
  )
@@ -8,7 +8,7 @@ class QueryAPI(BaseAPI):
8
8
  """API client for executing raw openCypher queries.
9
9
 
10
10
  Works on RushDB Cloud and on a self-hosted RushDB engine in direct mode
11
- (the engine must be started with ``--languages cypher``).
11
+ (the engine must be started with ``--languages cypher,sql``).
12
12
 
13
13
  Example:
14
14
  >>> from rushdb import RushDB
@@ -10,6 +10,7 @@ from .relationship_patterns import RelationshipPatternsAPI
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from ..client import RushDB
13
+ from ..models.record import RecordTarget, RelationTarget
13
14
 
14
15
 
15
16
  class PaginationParams(TypedDict, total=False):
@@ -181,6 +182,61 @@ class RelationsAPI(BaseAPI):
181
182
  "POST", "/relationships/create-many", payload, headers
182
183
  )
183
184
 
185
+ def update(
186
+ self,
187
+ source: "RecordTarget",
188
+ target: "RelationTarget",
189
+ *,
190
+ type: str,
191
+ direction: Optional[str] = None,
192
+ set: Optional[dict] = None,
193
+ unset: Optional[typing.List[str]] = None,
194
+ types: Optional[typing.Dict[str, str]] = None,
195
+ ) -> dict:
196
+ """Update the properties of existing relationships of one type.
197
+
198
+ Changes the ``type`` relationships from ``source`` to each ``target`` (or the
199
+ reverse with ``direction='in'``). Pairs without such a relationship are skipped.
200
+ Not available inside a transaction. Needs RushDB 3.0.4 or newer.
201
+
202
+ Args:
203
+ source: The source record (id, ``Record`` or dict with ``__id``).
204
+ target: The target record(s).
205
+ type (str): The relationship type to update.
206
+ direction (str, optional): ``'out'`` (default) or ``'in'``.
207
+ set (dict, optional): Properties to write; ``None`` values store an explicit null.
208
+ unset (list[str], optional): Names of properties to remove.
209
+ types (dict, optional): Type of ``set`` properties JSON can't express, one of
210
+ ``'string'``, ``'number'``, ``'boolean'``, ``'datetime'``;
211
+ ``{"since": "datetime"}`` stores an ISO string as a datetime.
212
+
213
+ Returns:
214
+ dict: API response; ``data["updated"]`` counts the relationships changed.
215
+
216
+ Example:
217
+ >>> db.relationships.update(
218
+ ... customer, order, type="PLACES", set={"channel": "store"}, unset=["legacyCode"]
219
+ ... )
220
+ """
221
+ from .records import RecordsAPI
222
+
223
+ source_id = RecordsAPI._extract_target_ids(source)[0]
224
+ payload: dict = {
225
+ "targetIds": RecordsAPI._extract_target_ids(target),
226
+ "type": type,
227
+ }
228
+ if direction:
229
+ payload["direction"] = direction
230
+ if set is not None:
231
+ payload["set"] = set
232
+ if unset is not None:
233
+ payload["unset"] = unset
234
+ if types:
235
+ payload["types"] = types
236
+ return self.client._make_request(
237
+ "PATCH", f"/relationships/{source_id}", payload
238
+ )
239
+
184
240
  def delete_many(
185
241
  self,
186
242
  *,
@@ -10,7 +10,8 @@ from typing import Dict
10
10
  _PATTERNS = "relationship-pattern suggestions are produced and stored by the platform"
11
11
 
12
12
  CLOUD_ONLY_METHODS: Dict[str, str] = {
13
- "ai.search": "natural-language search turns the prompt into a query with an LLM",
13
+ "ai.search": "natural-language search turns the prompt into a SQL statement with an LLM",
14
+ "ai.run_sql": "Smart search SQL runs on the platform, which loads the full records the statement selects",
14
15
  "relationships.patterns.list": _PATTERNS,
15
16
  "relationships.patterns.analyze": _PATTERNS,
16
17
  "relationships.patterns.approve": _PATTERNS,
@@ -753,6 +753,39 @@ def _detach(request: _Request) -> Body:
753
753
  )
754
754
 
755
755
 
756
+ def _update_relationships(request: _Request) -> Body:
757
+ body, (source_id,) = request.body, request.params
758
+ _not_in_transaction(request.tx_id, "relationships.update")
759
+ result = request.engine.execute(
760
+ {
761
+ "kind": "mutate",
762
+ "mutation": {
763
+ "kind": "updateRelationshipsById",
764
+ "command": {
765
+ "relationType": body.get("type"),
766
+ "direction": (
767
+ "in" if _direction(body.get("direction")) == "in" else "out"
768
+ ),
769
+ "sourceIds": [source_id],
770
+ "targetIds": _as_list(body.get("targetIds")),
771
+ "set": body.get("set") or {},
772
+ "unset": body.get("unset") or [],
773
+ # Sent only when used, so untyped updates work on engines without it.
774
+ **({"types": body["types"]} if body.get("types") else {}),
775
+ },
776
+ },
777
+ }
778
+ )
779
+ updated = int((result or {}).get("affected") or 0)
780
+ plural = "" if updated == 1 else "s"
781
+ return _ok(
782
+ {
783
+ "message": f"{updated} relationship{plural} of Record {source_id} updated",
784
+ "updated": updated,
785
+ }
786
+ )
787
+
788
+
756
789
  def _link_many(kind: str, message: str, what: str) -> Callable[[_Request], Body]:
757
790
  def handle(request: _Request) -> Body:
758
791
  _not_in_transaction(request.tx_id, what)
@@ -1283,6 +1316,7 @@ _ROUTES: List[Tuple[str, "re.Pattern[str]", Callable[[_Request], Body]]] = [
1283
1316
  ("POST", re.compile(r"^/records/export/csv$"), _export_csv),
1284
1317
  ("POST", re.compile(rf"^/relationships/{_ID}$"), _attach),
1285
1318
  ("PUT", re.compile(rf"^/relationships/{_ID}$"), _detach),
1319
+ ("PATCH", re.compile(rf"^/relationships/{_ID}$"), _update_relationships),
1286
1320
  (
1287
1321
  "POST",
1288
1322
  re.compile(r"^/relationships/create-many$"),
@@ -1372,7 +1406,8 @@ _ROUTES: List[Tuple[str, "re.Pattern[str]", Callable[[_Request], Body]]] = [
1372
1406
  ),
1373
1407
  ("DELETE", re.compile(rf"^/text-indexes/{_ID}$"), _drop_text_index),
1374
1408
  ("POST", re.compile(r"^/search/text$"), _text_search),
1375
- ("POST", re.compile(r"^/ai/search-query$"), _cloud_only("ai.search")),
1409
+ ("POST", re.compile(r"^/ai/sql-search$"), _cloud_only("ai.search")),
1410
+ ("POST", re.compile(r"^/records/sql-search$"), _cloud_only("ai.run_sql")),
1376
1411
  ("POST", re.compile(r"^/ai/search$"), _vector_search),
1377
1412
  (
1378
1413
  "GET",
@@ -32,6 +32,9 @@ class SearchResult(Generic[T]):
32
32
  client: Optional["RushDB"] = None,
33
33
  warnings: Optional[List[str]] = None,
34
34
  meta: Optional[Dict[str, Any]] = None,
35
+ sql: Optional[str] = None,
36
+ name: Optional[str] = None,
37
+ sql_page: Optional[Dict[str, int]] = None,
35
38
  ):
36
39
  """
37
40
  Initialize search result.
@@ -44,6 +47,10 @@ class SearchResult(Generic[T]):
44
47
  warnings: Optional warnings returned by AI-assisted query generation
45
48
  meta: How a vector, keyword or hybrid search ran (``vectorSearch``,
46
49
  ``textSearch``, ``fusion``)
50
+ sql: For Smart search, the SQL statement that produced this result
51
+ name: For Smart search, a short title of the query
52
+ sql_page: For Smart search, the page (``skip``, ``limit``) of the
53
+ statement's rows this result holds
47
54
  """
48
55
  self._data = data
49
56
  self._total = total or len(data)
@@ -51,6 +58,9 @@ class SearchResult(Generic[T]):
51
58
  self._client = client
52
59
  self._warnings = warnings or []
53
60
  self._meta = meta or {}
61
+ self._sql = sql
62
+ self._name = name
63
+ self._sql_page = sql_page or {"skip": 0, "limit": 100}
54
64
 
55
65
  @property
56
66
  def data(self) -> List[T]:
@@ -67,6 +77,16 @@ class SearchResult(Generic[T]):
67
77
  """Get the search query used to generate this result."""
68
78
  return self._search_query
69
79
 
80
+ @property
81
+ def sql(self) -> Optional[str]:
82
+ """For Smart search: the SQL statement that produced this result."""
83
+ return self._sql
84
+
85
+ @property
86
+ def name(self) -> Optional[str]:
87
+ """For Smart search: a short title of the query."""
88
+ return self._name
89
+
70
90
  @property
71
91
  def warnings(self) -> List[str]:
72
92
  """Get warnings returned by AI-assisted query generation."""
@@ -87,11 +107,15 @@ class SearchResult(Generic[T]):
87
107
  @property
88
108
  def skip(self) -> int:
89
109
  """Get the number of records that were skipped."""
110
+ if self._sql:
111
+ return self._sql_page["skip"]
90
112
  return self._search_query.get("skip") or 0
91
113
 
92
114
  @property
93
115
  def limit(self) -> Optional[int]:
94
116
  """Get the limit that was applied to the search."""
117
+ if self._sql:
118
+ return self._sql_page["limit"]
95
119
  return self._search_query.get("limit")
96
120
 
97
121
  def __len__(self) -> int:
@@ -119,12 +143,14 @@ class SearchResult(Generic[T]):
119
143
  Return the result in a standardized dictionary format.
120
144
 
121
145
  Returns:
122
- Dict with keys: total, data, search_query
146
+ Dict with keys: total, data, search_query, sql, name, warnings
123
147
  """
124
148
  return {
125
149
  "total": self.total,
126
150
  "data": self.data,
127
151
  "search_query": self.search_query,
152
+ "sql": self.sql,
153
+ "name": self.name,
128
154
  "warnings": self.warnings,
129
155
  }
130
156
 
@@ -164,7 +190,8 @@ class SearchResult(Generic[T]):
164
190
  return self._get_client().records.delete_by_id(ids, transaction)
165
191
 
166
192
  def next(self, preserve_data: bool = False) -> "SearchResult":
167
- """Fetch the next page of results.
193
+ """Fetch the next page of results: of the original search query, or of
194
+ the SQL statement for a Smart search result.
168
195
 
169
196
  Args:
170
197
  preserve_data: If True, appends new records to this instance's data
@@ -174,8 +201,21 @@ class SearchResult(Generic[T]):
174
201
  SearchResult with the next page (or self when preserve_data=True)
175
202
 
176
203
  Raises:
177
- RuntimeError: If no search_query was provided at construction
204
+ RuntimeError: If neither a search_query nor a sql statement was provided
178
205
  """
206
+ if self._sql:
207
+ next_page = {
208
+ "skip": self._sql_page["skip"] + self._sql_page["limit"],
209
+ "limit": self._sql_page["limit"],
210
+ }
211
+ result = self._get_client().ai.run_sql(self._sql, **next_page)
212
+ if preserve_data:
213
+ self._data = self._data + result._data # type: ignore[operator]
214
+ self._total = result._total
215
+ self._sql_page = next_page
216
+ return self
217
+ result._name = self._name
218
+ return result
179
219
  if not self._search_query:
180
220
  raise RuntimeError(
181
221
  "SearchResult: cannot paginate — no search_query was provided."
@@ -43,13 +43,14 @@ class TestVectorAndSmartSearch(unittest.TestCase):
43
43
  self.assertEqual(result[0].get("title"), "Alpha")
44
44
  self.assertEqual(result[0].score, 0.91)
45
45
 
46
- def test_ai_search_prompt_generates_and_executes_search_query(self):
46
+ def test_ai_search_prompt_generates_and_runs_sql(self):
47
47
  client = Mock()
48
48
  client._make_request.side_effect = [
49
49
  {
50
50
  "success": True,
51
51
  "data": {
52
- "searchQuery": {"labels": ["Pilot"], "where": {"ship": "Falcon"}},
52
+ "sql": 'SELECT __id FROM "Pilot" WHERE "ship" = \'Falcon\'',
53
+ "name": "Falcon pilots",
53
54
  "warnings": ["ambiguous ship name"],
54
55
  },
55
56
  },
@@ -60,38 +61,41 @@ class TestVectorAndSmartSearch(unittest.TestCase):
60
61
  },
61
62
  ]
62
63
 
63
- result = AIAPI(client).search(
64
- "Who are piloting Falcon?",
65
- current_query={"labels": ["Pilot"]},
66
- transaction="tx_123",
67
- )
64
+ result = AIAPI(client).search("Who are piloting Falcon?", limit=10)
68
65
 
69
66
  self.assertEqual(
70
67
  client._make_request.call_args_list[0].args,
71
- (
72
- "POST",
73
- "/ai/search-query",
74
- {
75
- "prompt": "Who are piloting Falcon?",
76
- "currentQuery": {"labels": ["Pilot"]},
77
- },
78
- {"X-Transaction-Id": "tx_123"},
79
- ),
68
+ ("POST", "/ai/sql-search", {"prompt": "Who are piloting Falcon?"}),
80
69
  )
70
+ sql = 'SELECT __id FROM "Pilot" WHERE "ship" = \'Falcon\''
71
+ self.assertEqual(
72
+ client._make_request.call_args_list[1].args,
73
+ ("POST", "/records/sql-search", {"sql": sql, "skip": 0, "limit": 10}),
74
+ )
75
+ self.assertEqual(result.sql, sql)
76
+ self.assertEqual(result.name, "Falcon pilots")
77
+ self.assertEqual(result.warnings, ["ambiguous ship name"])
78
+ self.assertEqual(result.search_query, {})
79
+ self.assertEqual(result[0].get("name"), "Han")
80
+
81
+ def test_smart_search_result_pages_through_its_sql(self):
82
+ client = Mock()
83
+ client._make_request.return_value = {"success": True, "total": 3, "data": []}
84
+ client.ai = AIAPI(client)
85
+
86
+ first = client.ai.run_sql('SELECT __id FROM "Pilot"', limit=2)
87
+ self.assertEqual((first.skip, first.limit), (0, 2))
88
+ second = first.next()
89
+
81
90
  self.assertEqual(
82
91
  client._make_request.call_args_list[1].args,
83
92
  (
84
93
  "POST",
85
- "/records/search",
86
- {"labels": ["Pilot"], "where": {"ship": "Falcon"}},
87
- {"X-Transaction-Id": "tx_123"},
94
+ "/records/sql-search",
95
+ {"sql": 'SELECT __id FROM "Pilot"', "skip": 2, "limit": 2},
88
96
  ),
89
97
  )
90
- self.assertEqual(
91
- result.search_query, {"labels": ["Pilot"], "where": {"ship": "Falcon"}}
92
- )
93
- self.assertEqual(result.warnings, ["ambiguous ship name"])
94
- self.assertEqual(result[0].get("name"), "Han")
98
+ self.assertEqual((second.skip, second.limit, second.sql), (2, 2, first.sql))
95
99
 
96
100
  def test_ai_search_dict_is_deprecated_vector_search_alias(self):
97
101
  client = Mock()
@@ -191,6 +191,9 @@ EVERY_METHOD = {
191
191
  "records.detach": lambda db: db.records.detach(
192
192
  RECORD["id"], RECORD["id"], options={"typeOrTypes": "LIKES"}
193
193
  ),
194
+ "relationships.update": lambda db: db.relationships.update(
195
+ RECORD["id"], RECORD["id"], type="LIKES", set={"weight": 1}
196
+ ),
194
197
  "records.create": lambda db: db.records.create("Book", {"title": "Dune"}),
195
198
  "records.create_many": lambda db: db.records.create_many(
196
199
  "Book", [{"title": "Dune"}]
@@ -271,6 +274,7 @@ EVERY_METHOD = {
271
274
  "book-vec", {"items": [{"recordId": RECORD["id"], "vector": [1, 0, 0]}]}
272
275
  ),
273
276
  "ai.search": lambda db: db.ai.search("books about deserts"),
277
+ "ai.run_sql": lambda db: db.ai.run_sql('SELECT __id FROM "Book"'),
274
278
  "settings.get": lambda db: db.settings.get(),
275
279
  }
276
280
 
@@ -530,6 +534,39 @@ def test_detach_without_type_or_direction_removes_every_link(engine_calls):
530
534
  ]
531
535
 
532
536
 
537
+ def test_relationship_properties_are_updated_by_record_ids(engine_calls):
538
+ result = direct_db().relationships.update(
539
+ RECORD["id"],
540
+ ["t1", "t2"],
541
+ type="PLACES",
542
+ direction="in",
543
+ set={"channel": "web", "note": None, "since": "2026-11-06T10:00:00.000Z"},
544
+ unset=["old"],
545
+ types={"since": "datetime"},
546
+ )
547
+ assert result["data"]["updated"] == 1
548
+ commands = [
549
+ op["mutation"]["command"]
550
+ for op in operations(engine_calls)
551
+ if op.get("mutation", {}).get("kind") == "updateRelationshipsById"
552
+ ]
553
+ assert commands == [
554
+ {
555
+ "relationType": "PLACES",
556
+ "direction": "in",
557
+ "sourceIds": [RECORD["id"]],
558
+ "targetIds": ["t1", "t2"],
559
+ "set": {
560
+ "channel": "web",
561
+ "note": None,
562
+ "since": "2026-11-06T10:00:00.000Z",
563
+ },
564
+ "unset": ["old"],
565
+ "types": {"since": "datetime"},
566
+ }
567
+ ]
568
+
569
+
533
570
  def test_keyword_search_uses_the_labels_text_index(engine_calls):
534
571
  result = direct_db().records.text_search({"query": "dune", "labels": ["Book"]})
535
572
  hybrid = [op for op in operations(engine_calls) if op.get("kind") == "query"][0]
@@ -85,6 +85,27 @@ def test_relationships(db):
85
85
  )
86
86
  assert found.total == 1
87
87
 
88
+ edge = found.data[0]
89
+ result = db.relationships.update(
90
+ edge["sourceId"],
91
+ edge["targetId"],
92
+ type="AUTHORED",
93
+ set={"year": 1965, "role": "author", "since": "1965-08-01T10:30:00.000Z"},
94
+ types={"since": "datetime"},
95
+ )
96
+ assert result["data"]["updated"] == 1
97
+ db.relationships.update(
98
+ edge["sourceId"], edge["targetId"], type="AUTHORED", unset=["role"]
99
+ )
100
+ updated = db.relationships.find(
101
+ {"where": {"type": "AUTHORED"}, "source": {"labels": [AUTHOR]}}
102
+ )
103
+ assert updated.data[0].get("properties") == {
104
+ "year": 1965,
105
+ "since": "1965-08-01T10:30:00.000Z",
106
+ }
107
+ assert updated.data[0].get("propertyTypes", {}).get("since") == "datetime"
108
+
88
109
 
89
110
  def test_labels_properties_schema(db):
90
111
  assert db.labels.find({})["data"][BOOK] >= 3
@@ -710,7 +710,7 @@ wheels = [
710
710
 
711
711
  [[package]]
712
712
  name = "rushdb"
713
- version = "3.0.1"
713
+ version = "3.2.0"
714
714
  source = { editable = "." }
715
715
 
716
716
  [package.dev-dependencies]
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