surreal-orm-lite 0.9.0__tar.gz → 0.10.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.
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/CHANGELOG.md +44 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/PKG-INFO +65 -3
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/README.md +64 -2
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/pyproject.toml +1 -1
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/__init__.py +1 -1
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/model_base.py +79 -7
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/query_set.py +110 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/.gitignore +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/LICENSE +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/Makefile +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/__init__.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/_sdk.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/aggregations.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/connection_manager.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/constants.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/enum.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/exceptions.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/py.typed +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/q.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/signals.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/transaction.py +0 -0
- {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/utils.py +0 -0
|
@@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.10.0] - 2026-06-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`model.upsert()`** — insert-or-replace by explicit id, backed by the SDK's native
|
|
13
|
+
`upsert()` (`UPSERT $record CONTENT $data`, full REPLACE). Supports `tx=` (buffered onto
|
|
14
|
+
the transaction).
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
user = User(id="alice", name="Alice", status="active")
|
|
18
|
+
await user.upsert() # CREATE if absent, full REPLACE if present
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **`QuerySet.update_or_create()` / `QuerySet.get_or_create()`** — Django-style
|
|
22
|
+
criteria-based create-or-update returning `(instance, created)`. Writes route through
|
|
23
|
+
`save()` / `merge()`, so lifecycle signals fire, SDK errors normalise to `SurrealDbError`,
|
|
24
|
+
and the primary key anchors record identity. `update_or_create` does a **partial merge** on
|
|
25
|
+
update — fields outside the criteria/defaults are preserved — while `get_or_create` returns
|
|
26
|
+
an existing match untouched. Non-`exact` lookups (e.g. `name__contains`) drive the lookup
|
|
27
|
+
but are not written. A SELECT-guard raises an explicit `SurrealDbError` when the criteria
|
|
28
|
+
match more than one record.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
user, created = await User.objects().update_or_create(
|
|
32
|
+
email="alice@example.com", defaults={"name": "Alice"}
|
|
33
|
+
)
|
|
34
|
+
user, created = await User.objects().get_or_create(
|
|
35
|
+
email="bob@example.com", defaults={"name": "Bob"}
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Notes
|
|
40
|
+
|
|
41
|
+
- Behaviour is **identical on SurrealDB 2.6.x and 3.x** (no 3.x-only primitive): the native
|
|
42
|
+
`upsert()` call and the SELECT-guard work the same on both lines (verified by spike).
|
|
43
|
+
- `upsert()` is REPLACE, not merge: fields omitted from the model are dropped. Use `merge()`
|
|
44
|
+
for partial updates.
|
|
45
|
+
- `update_or_create` / `get_or_create` do the lookup and the write in two round-trips
|
|
46
|
+
(SurrealDB 2.6.x exposes no row-locking primitive here), so a small race window exists
|
|
47
|
+
under concurrent writers — the same non-atomic fallback Django documents.
|
|
48
|
+
- Under `objects(tx=)`, `update_or_create` / `get_or_create` participate in the transaction on
|
|
49
|
+
SurrealDB 3.x (interactive); a buffered 2.6.x transaction raises on their lookup read
|
|
50
|
+
(reads inside a buffered transaction are unsupported — consistent with other QuerySet reads).
|
|
51
|
+
|
|
8
52
|
## [0.9.0] - 2026-06-07
|
|
9
53
|
|
|
10
54
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: surreal-orm-lite
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation.
|
|
5
5
|
Project-URL: Homepage, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
|
|
6
6
|
Project-URL: Documentation, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
|
|
@@ -204,6 +204,7 @@ results = await User.objects().query(
|
|
|
204
204
|
| Relations & Graph | ✅ |
|
|
205
205
|
| FETCH clause | ✅ |
|
|
206
206
|
| Transactions (`tx=`) | ✅ |
|
|
207
|
+
| upsert / get_or_create | ✅ |
|
|
207
208
|
|
|
208
209
|
### Supported Filter Lookups
|
|
209
210
|
|
|
@@ -385,6 +386,47 @@ async with SurrealDBConnectionManager.transaction() as tx:
|
|
|
385
386
|
tx raise; `save(tx=)` requires an explicit `id`. `bulk_update`/`bulk_delete` return `0`
|
|
386
387
|
(the row count is not knowable before commit).
|
|
387
388
|
|
|
389
|
+
### 12. Upsert & get_or_create / update_or_create
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
from surreal_orm_lite import BaseSurrealModel, SurrealConfigDict
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class User(BaseSurrealModel):
|
|
396
|
+
model_config = SurrealConfigDict(primary_key="id")
|
|
397
|
+
id: str | None = None
|
|
398
|
+
name: str
|
|
399
|
+
email: str
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# Insert-or-replace by explicit id (full REPLACE — omitted fields are dropped).
|
|
403
|
+
# Use merge() instead if you only want a partial update.
|
|
404
|
+
await User(id="alice", name="Alice", email="alice@example.com").upsert()
|
|
405
|
+
|
|
406
|
+
# Criteria-based, Django-style; returns (instance, created).
|
|
407
|
+
# update_or_create: on create, writes criteria + defaults; on update, MERGEs them (a partial
|
|
408
|
+
# update — fields outside the criteria/defaults are preserved). Lifecycle signals fire on both
|
|
409
|
+
# paths, and the primary key anchors the record identity.
|
|
410
|
+
user, created = await User.objects().update_or_create(
|
|
411
|
+
email="alice@example.com", defaults={"name": "Alice"}
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# get_or_create writes the defaults ONLY when creating; an existing match is returned as-is:
|
|
415
|
+
user, created = await User.objects().get_or_create(
|
|
416
|
+
email="bob@example.com", defaults={"name": "Bob"}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Both participate in a transaction via objects(tx=) (interactive on SurrealDB 3.x):
|
|
420
|
+
async with SurrealDBConnectionManager.transaction() as tx:
|
|
421
|
+
user, created = await User.objects(tx=tx).get_or_create(email="z@x.io", defaults={"name": "Z"})
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
If the lookup criteria match more than one record, both methods raise `SurrealDbError`
|
|
425
|
+
(the criteria are not unique). Non-`exact` lookups (e.g. `name__contains`) drive the lookup
|
|
426
|
+
but are not written to the record. Without a transaction the behaviour is identical on
|
|
427
|
+
SurrealDB 2.6.x and 3.x; under `objects(tx=)` they participate in the transaction on 3.x,
|
|
428
|
+
while a buffered 2.6.x transaction raises on the lookup (see the behaviour table).
|
|
429
|
+
|
|
388
430
|
---
|
|
389
431
|
|
|
390
432
|
## Configuration Options
|
|
@@ -425,6 +467,25 @@ As of v0.7.0, Surreal ORM Lite uses `surrealdb[pydantic]>=2.0.0,<3.0.0` (Surreal
|
|
|
425
467
|
| 2.6.x | 2.0 | ✅ Compatible |
|
|
426
468
|
| < 2.6 or > 3.1 | — | ⚠️ Not guaranteed |
|
|
427
469
|
|
|
470
|
+
### ORM behaviour: SurrealDB 2.6.x vs 3.x
|
|
471
|
+
|
|
472
|
+
Surreal ORM Lite runs on both lines; some capabilities differ because they rely on server
|
|
473
|
+
features introduced in SurrealDB 3.x. On 2.6.x the ORM degrades gracefully. Capabilities not
|
|
474
|
+
listed behave the same on both lines.
|
|
475
|
+
|
|
476
|
+
| ORM capability | SurrealDB 2.6.x | SurrealDB 3.x (3.1.3) | Since |
|
|
477
|
+
| -------------- | --------------- | --------------------- | ----- |
|
|
478
|
+
| Transaction strategy auto-selected by `transaction()` | buffered batch (`BEGIN…COMMIT`) | native interactive on WebSocket | v0.9.0 |
|
|
479
|
+
| Reads inside a transaction (`objects(tx=)`) | raise (buffered cannot read) | see uncommitted writes | v0.9.0 |
|
|
480
|
+
| `save(tx=)` with an auto-generated id | raises — explicit id required | supported | v0.9.0 |
|
|
481
|
+
| `refresh(tx=)` inside a transaction | raises | works | v0.9.0 |
|
|
482
|
+
| `bulk_update` / `bulk_delete` row count inside a tx | returns `0` (not knowable pre-commit) | real count | v0.9.0 |
|
|
483
|
+
| "Already exists" error on create | normalised to `SurrealDbError` | normalised to `SurrealDbError` | v0.7.0 |
|
|
484
|
+
| Cleanup on a missing target (`delete_table`, `remove_relation`) | native no-op | ORM makes it a silent no-op | v0.7.0 |
|
|
485
|
+
| Aggregation over an empty set (`NaN` / `±inf`) | returns `0.0` / `None` | ORM normalises to `0.0` / `None` | v0.7.0 |
|
|
486
|
+
| Namespace/db selection (`use()` ordering) | lenient (auto-creates) | strict — ORM signs in before `use()` | v0.7.0 |
|
|
487
|
+
| `upsert()` / `update_or_create()` / `get_or_create()` | same on both lines | same on both lines | v0.10.0 |
|
|
488
|
+
|
|
428
489
|
> **Note on record IDs**: A record loaded from the database has its `id` field set to a native `surrealdb.RecordID` object, not a plain string. Use `model.get_raw_id()` to obtain the bare identifier string (e.g. `"alice"`), or compare directly with `model.id == RecordID("User", "alice")`. In-memory instances you construct yourself retain whatever value you assign.
|
|
429
490
|
|
|
430
491
|
---
|
|
@@ -448,7 +509,8 @@ Contributions are welcome! Please:
|
|
|
448
509
|
| v0.2.x – v0.7.0 | Core ORM → SDK 2.0 / SurrealDB 3.x migration | ✅ Released |
|
|
449
510
|
| v0.8.0 | Transactions ORM (`tx=`) | ✅ Released |
|
|
450
511
|
| v0.9.0 | Transactions — QuerySet & interactive (3.x) | ✅ Released |
|
|
451
|
-
| v0.10.0
|
|
512
|
+
| v0.10.0 | upsert / update_or_create / get_or_create | ✅ Released |
|
|
513
|
+
| v0.11.0 – v0.22.0 | Tier 1 — Core (auth, live, relations, …) | 📋 Planned |
|
|
452
514
|
| v0.23.0 – v0.29.0 | Tier 2 — Extended (rich types, geo, subqueries) | 📋 Planned |
|
|
453
515
|
| v0.30.0 – v0.39.0 | Tier 3 — Advanced (search, DDL, migrations, CLI) | 📋 Planned |
|
|
454
516
|
| v0.40.0 | Beta Phase (API freeze, hardening) | 📋 Planned |
|
|
@@ -483,7 +545,7 @@ SDK) and **server support**. Everything below is on the lite roadmap via the off
|
|
|
483
545
|
| FETCH clause | ✅ | ✅ |
|
|
484
546
|
| Transactions (tx=) | ✅ v0.8 (core), v0.9 QS | ✅ |
|
|
485
547
|
| Interactive tx (3.x native) | ✅ v0.9 | ✅ |
|
|
486
|
-
| upsert / update_or_create | v0.10.0
|
|
548
|
+
| upsert / update_or_create | ✅ v0.10.0 | ✅ |
|
|
487
549
|
| Atomic field/array operations | v0.11.0 | ✅ |
|
|
488
550
|
| Retry on conflict | v0.12.0 | ✅ |
|
|
489
551
|
| SurrealFunc & Computed | v0.13 – v0.14 | ✅ |
|
|
@@ -154,6 +154,7 @@ results = await User.objects().query(
|
|
|
154
154
|
| Relations & Graph | ✅ |
|
|
155
155
|
| FETCH clause | ✅ |
|
|
156
156
|
| Transactions (`tx=`) | ✅ |
|
|
157
|
+
| upsert / get_or_create | ✅ |
|
|
157
158
|
|
|
158
159
|
### Supported Filter Lookups
|
|
159
160
|
|
|
@@ -335,6 +336,47 @@ async with SurrealDBConnectionManager.transaction() as tx:
|
|
|
335
336
|
tx raise; `save(tx=)` requires an explicit `id`. `bulk_update`/`bulk_delete` return `0`
|
|
336
337
|
(the row count is not knowable before commit).
|
|
337
338
|
|
|
339
|
+
### 12. Upsert & get_or_create / update_or_create
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from surreal_orm_lite import BaseSurrealModel, SurrealConfigDict
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class User(BaseSurrealModel):
|
|
346
|
+
model_config = SurrealConfigDict(primary_key="id")
|
|
347
|
+
id: str | None = None
|
|
348
|
+
name: str
|
|
349
|
+
email: str
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# Insert-or-replace by explicit id (full REPLACE — omitted fields are dropped).
|
|
353
|
+
# Use merge() instead if you only want a partial update.
|
|
354
|
+
await User(id="alice", name="Alice", email="alice@example.com").upsert()
|
|
355
|
+
|
|
356
|
+
# Criteria-based, Django-style; returns (instance, created).
|
|
357
|
+
# update_or_create: on create, writes criteria + defaults; on update, MERGEs them (a partial
|
|
358
|
+
# update — fields outside the criteria/defaults are preserved). Lifecycle signals fire on both
|
|
359
|
+
# paths, and the primary key anchors the record identity.
|
|
360
|
+
user, created = await User.objects().update_or_create(
|
|
361
|
+
email="alice@example.com", defaults={"name": "Alice"}
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# get_or_create writes the defaults ONLY when creating; an existing match is returned as-is:
|
|
365
|
+
user, created = await User.objects().get_or_create(
|
|
366
|
+
email="bob@example.com", defaults={"name": "Bob"}
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Both participate in a transaction via objects(tx=) (interactive on SurrealDB 3.x):
|
|
370
|
+
async with SurrealDBConnectionManager.transaction() as tx:
|
|
371
|
+
user, created = await User.objects(tx=tx).get_or_create(email="z@x.io", defaults={"name": "Z"})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
If the lookup criteria match more than one record, both methods raise `SurrealDbError`
|
|
375
|
+
(the criteria are not unique). Non-`exact` lookups (e.g. `name__contains`) drive the lookup
|
|
376
|
+
but are not written to the record. Without a transaction the behaviour is identical on
|
|
377
|
+
SurrealDB 2.6.x and 3.x; under `objects(tx=)` they participate in the transaction on 3.x,
|
|
378
|
+
while a buffered 2.6.x transaction raises on the lookup (see the behaviour table).
|
|
379
|
+
|
|
338
380
|
---
|
|
339
381
|
|
|
340
382
|
## Configuration Options
|
|
@@ -375,6 +417,25 @@ As of v0.7.0, Surreal ORM Lite uses `surrealdb[pydantic]>=2.0.0,<3.0.0` (Surreal
|
|
|
375
417
|
| 2.6.x | 2.0 | ✅ Compatible |
|
|
376
418
|
| < 2.6 or > 3.1 | — | ⚠️ Not guaranteed |
|
|
377
419
|
|
|
420
|
+
### ORM behaviour: SurrealDB 2.6.x vs 3.x
|
|
421
|
+
|
|
422
|
+
Surreal ORM Lite runs on both lines; some capabilities differ because they rely on server
|
|
423
|
+
features introduced in SurrealDB 3.x. On 2.6.x the ORM degrades gracefully. Capabilities not
|
|
424
|
+
listed behave the same on both lines.
|
|
425
|
+
|
|
426
|
+
| ORM capability | SurrealDB 2.6.x | SurrealDB 3.x (3.1.3) | Since |
|
|
427
|
+
| -------------- | --------------- | --------------------- | ----- |
|
|
428
|
+
| Transaction strategy auto-selected by `transaction()` | buffered batch (`BEGIN…COMMIT`) | native interactive on WebSocket | v0.9.0 |
|
|
429
|
+
| Reads inside a transaction (`objects(tx=)`) | raise (buffered cannot read) | see uncommitted writes | v0.9.0 |
|
|
430
|
+
| `save(tx=)` with an auto-generated id | raises — explicit id required | supported | v0.9.0 |
|
|
431
|
+
| `refresh(tx=)` inside a transaction | raises | works | v0.9.0 |
|
|
432
|
+
| `bulk_update` / `bulk_delete` row count inside a tx | returns `0` (not knowable pre-commit) | real count | v0.9.0 |
|
|
433
|
+
| "Already exists" error on create | normalised to `SurrealDbError` | normalised to `SurrealDbError` | v0.7.0 |
|
|
434
|
+
| Cleanup on a missing target (`delete_table`, `remove_relation`) | native no-op | ORM makes it a silent no-op | v0.7.0 |
|
|
435
|
+
| Aggregation over an empty set (`NaN` / `±inf`) | returns `0.0` / `None` | ORM normalises to `0.0` / `None` | v0.7.0 |
|
|
436
|
+
| Namespace/db selection (`use()` ordering) | lenient (auto-creates) | strict — ORM signs in before `use()` | v0.7.0 |
|
|
437
|
+
| `upsert()` / `update_or_create()` / `get_or_create()` | same on both lines | same on both lines | v0.10.0 |
|
|
438
|
+
|
|
378
439
|
> **Note on record IDs**: A record loaded from the database has its `id` field set to a native `surrealdb.RecordID` object, not a plain string. Use `model.get_raw_id()` to obtain the bare identifier string (e.g. `"alice"`), or compare directly with `model.id == RecordID("User", "alice")`. In-memory instances you construct yourself retain whatever value you assign.
|
|
379
440
|
|
|
380
441
|
---
|
|
@@ -398,7 +459,8 @@ Contributions are welcome! Please:
|
|
|
398
459
|
| v0.2.x – v0.7.0 | Core ORM → SDK 2.0 / SurrealDB 3.x migration | ✅ Released |
|
|
399
460
|
| v0.8.0 | Transactions ORM (`tx=`) | ✅ Released |
|
|
400
461
|
| v0.9.0 | Transactions — QuerySet & interactive (3.x) | ✅ Released |
|
|
401
|
-
| v0.10.0
|
|
462
|
+
| v0.10.0 | upsert / update_or_create / get_or_create | ✅ Released |
|
|
463
|
+
| v0.11.0 – v0.22.0 | Tier 1 — Core (auth, live, relations, …) | 📋 Planned |
|
|
402
464
|
| v0.23.0 – v0.29.0 | Tier 2 — Extended (rich types, geo, subqueries) | 📋 Planned |
|
|
403
465
|
| v0.30.0 – v0.39.0 | Tier 3 — Advanced (search, DDL, migrations, CLI) | 📋 Planned |
|
|
404
466
|
| v0.40.0 | Beta Phase (API freeze, hardening) | 📋 Planned |
|
|
@@ -433,7 +495,7 @@ SDK) and **server support**. Everything below is on the lite roadmap via the off
|
|
|
433
495
|
| FETCH clause | ✅ | ✅ |
|
|
434
496
|
| Transactions (tx=) | ✅ v0.8 (core), v0.9 QS | ✅ |
|
|
435
497
|
| Interactive tx (3.x native) | ✅ v0.9 | ✅ |
|
|
436
|
-
| upsert / update_or_create | v0.10.0
|
|
498
|
+
| upsert / update_or_create | ✅ v0.10.0 | ✅ |
|
|
437
499
|
| Atomic field/array operations | v0.11.0 | ✅ |
|
|
438
500
|
| Retry on conflict | v0.12.0 | ✅ |
|
|
439
501
|
| SurrealFunc & Computed | v0.13 – v0.14 | ✅ |
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import logging
|
|
3
3
|
import typing
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
4
5
|
from typing import Any, Self
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel, ConfigDict, model_validator
|
|
@@ -251,30 +252,101 @@ class BaseSurrealModel(BaseModel):
|
|
|
251
252
|
is deferred until the transaction commits successfully; if the tx rolls back,
|
|
252
253
|
``post_save`` is NOT emitted (the write never happened).
|
|
253
254
|
"""
|
|
255
|
+
return await self._save_with_signals(self._do_save, tx)
|
|
256
|
+
|
|
257
|
+
def _apply_record(self, record: Any) -> None:
|
|
258
|
+
"""Copy a returned DB row's fields onto this instance (id kept as native ``RecordID``).
|
|
259
|
+
|
|
260
|
+
Accepts the raw SDK result (a row dict, a single-element list, or ``None``) and applies
|
|
261
|
+
only attributes the model actually declares. Shared by ``_do_save``/``_do_upsert`` so
|
|
262
|
+
the "apply the row the server gave back" step lives in one place.
|
|
263
|
+
"""
|
|
264
|
+
if isinstance(record, list):
|
|
265
|
+
record = record[0] if record else None
|
|
266
|
+
if isinstance(record, dict):
|
|
267
|
+
for key, value in record.items():
|
|
268
|
+
if hasattr(self, key):
|
|
269
|
+
object.__setattr__(self, key, value)
|
|
270
|
+
|
|
271
|
+
async def _save_with_signals(
|
|
272
|
+
self,
|
|
273
|
+
do_op: Callable[..., Awaitable[tuple[Self, bool]]],
|
|
274
|
+
tx: Transaction | None,
|
|
275
|
+
) -> Self:
|
|
276
|
+
"""Shared ``save``/``upsert`` signal envelope around the concrete write ``do_op``.
|
|
277
|
+
|
|
278
|
+
Emits ``pre_save`` → (``around_save`` wrap) → ``post_save``. In a transaction the write
|
|
279
|
+
is buffered, so ``around_save`` is skipped and ``post_save`` is deferred to a
|
|
280
|
+
successful commit — identical for both ``save()`` (``do_op=_do_save``) and ``upsert()``
|
|
281
|
+
(``do_op=_do_upsert``), so the contract can't drift between them.
|
|
282
|
+
"""
|
|
254
283
|
sender = self.__class__
|
|
255
284
|
has_signals = pre_save.has_handlers(sender) or post_save.has_handlers(sender) or around_save.has_handlers(sender)
|
|
256
285
|
|
|
257
286
|
if not has_signals:
|
|
258
|
-
result,
|
|
287
|
+
result, _created = await do_op(tx=tx)
|
|
259
288
|
return result
|
|
260
289
|
|
|
261
290
|
await pre_save.send(sender, instance=self)
|
|
262
291
|
|
|
263
292
|
if tx is not None:
|
|
264
|
-
|
|
265
|
-
# skipped (the write happens at commit). post_save is deferred to post-commit
|
|
266
|
-
# so handlers only run when the write is durable.
|
|
267
|
-
result, created = await self._do_save(tx=tx)
|
|
293
|
+
result, created = await do_op(tx=tx)
|
|
268
294
|
tx.enqueue_post_commit(lambda: post_save.send(sender, instance=self, created=created))
|
|
269
295
|
return result
|
|
270
296
|
|
|
271
297
|
async with around_save.wrap(sender, instance=self):
|
|
272
|
-
result, created = await
|
|
298
|
+
result, created = await do_op(tx=tx)
|
|
273
299
|
|
|
274
300
|
await post_save.send(sender, instance=self, created=created)
|
|
275
|
-
|
|
276
301
|
return result
|
|
277
302
|
|
|
303
|
+
async def _do_upsert(self, tx: Transaction | None = None) -> tuple[Self, bool]:
|
|
304
|
+
"""Internal upsert logic. Returns (self, created).
|
|
305
|
+
|
|
306
|
+
Uses the SDK's native ``upsert()`` which runs ``UPSERT $record CONTENT $data`` —
|
|
307
|
+
a full REPLACE: the record is created if absent, or entirely replaced if present
|
|
308
|
+
(fields omitted from the model are dropped). An explicit id is required (there is
|
|
309
|
+
nothing to match without one). ``created`` cannot be told apart from the native
|
|
310
|
+
call, so it is reported best-effort as ``True``; use ``update_or_create`` when the
|
|
311
|
+
precise ``created`` flag matters.
|
|
312
|
+
|
|
313
|
+
When ``tx`` is provided the ``UPSERT`` statement is buffered (deferred to commit);
|
|
314
|
+
on an interactive transaction the returned row is applied to ``self``.
|
|
315
|
+
"""
|
|
316
|
+
record_id = self._record_id()
|
|
317
|
+
if record_id is None:
|
|
318
|
+
raise SurrealDbError(
|
|
319
|
+
"upsert() requires an explicit id (there is nothing to match without one); "
|
|
320
|
+
"use save() to create a record with an auto-generated id."
|
|
321
|
+
)
|
|
322
|
+
data = self.model_dump(exclude={"id"})
|
|
323
|
+
|
|
324
|
+
if tx is not None:
|
|
325
|
+
rows = await tx.add(f"UPSERT {record_id} CONTENT $data;", {"data": data})
|
|
326
|
+
self._apply_record(rows)
|
|
327
|
+
return self, True
|
|
328
|
+
|
|
329
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
330
|
+
record = await client.upsert(record_id, data)
|
|
331
|
+
self._apply_record(record)
|
|
332
|
+
return self, True
|
|
333
|
+
|
|
334
|
+
async def upsert(self, tx: Transaction | None = None) -> Self:
|
|
335
|
+
"""
|
|
336
|
+
Insert the model instance, or fully replace it if a record with the same id exists.
|
|
337
|
+
|
|
338
|
+
Backed by the SDK's native ``upsert()`` (``UPSERT $record CONTENT $data``): this is
|
|
339
|
+
REPLACE semantics, so any field omitted from the model is removed from the stored
|
|
340
|
+
record. For a partial update use ``merge()`` instead. An explicit id is required.
|
|
341
|
+
|
|
342
|
+
When ``tx`` is provided, the ``UPSERT`` is buffered onto the transaction.
|
|
343
|
+
|
|
344
|
+
Emits the same signals as ``save()`` (``pre_save``/``around_save``/``post_save``);
|
|
345
|
+
in a transaction ``around_save`` is skipped and ``post_save`` is deferred to a
|
|
346
|
+
successful commit (consistent with ``save(tx=)``).
|
|
347
|
+
"""
|
|
348
|
+
return await self._save_with_signals(self._do_upsert, tx)
|
|
349
|
+
|
|
278
350
|
async def update(self, tx: Transaction | None = None) -> Any:
|
|
279
351
|
"""
|
|
280
352
|
Update the model instance to the database.
|
|
@@ -748,6 +748,116 @@ class QuerySet:
|
|
|
748
748
|
return len(results)
|
|
749
749
|
return 0
|
|
750
750
|
|
|
751
|
+
# ==================== Upsert / get_or_create ====================
|
|
752
|
+
|
|
753
|
+
@staticmethod
|
|
754
|
+
def _criteria_payload(criteria: dict[str, Any]) -> dict[str, Any]:
|
|
755
|
+
"""Field=value pairs usable as a WRITE payload, taken from EQUALITY criteria only.
|
|
756
|
+
|
|
757
|
+
``criteria`` keys are filter lookups (``field`` or ``field__lookup``). A literal raw
|
|
758
|
+
key like ``name__contains`` must never be written as a column, so only ``exact``
|
|
759
|
+
lookups contribute to the payload (their base field name → value). Non-``exact``
|
|
760
|
+
lookups (``gt``/``contains``/``in``/…) still drive the lookup but cannot be written,
|
|
761
|
+
so they are excluded here — mirroring Django, which only writes exact lookups on
|
|
762
|
+
create.
|
|
763
|
+
"""
|
|
764
|
+
payload: dict[str, Any] = {}
|
|
765
|
+
for key, value in criteria.items():
|
|
766
|
+
field_name, lookup = parse_lookup(key)
|
|
767
|
+
if lookup == "exact":
|
|
768
|
+
payload[field_name] = value
|
|
769
|
+
return payload
|
|
770
|
+
|
|
771
|
+
async def _lookup_matches(self, criteria: dict[str, Any]) -> list[Any]:
|
|
772
|
+
"""Return rows matching ``criteria``, routed through the transaction when attached.
|
|
773
|
+
|
|
774
|
+
Reuses the parameterized WHERE builder (anti-injection) via a throwaway QuerySet's
|
|
775
|
+
``filter(**criteria)``, then runs through ``_execute_query`` so the read participates
|
|
776
|
+
in ``self._tx`` (``objects(tx=)``) — seeing the tx's uncommitted writes on an
|
|
777
|
+
interactive transaction, and raising on a buffered one (consistent with every other
|
|
778
|
+
read). ``LIMIT 2`` is enough to distinguish 0 / 1 / >1.
|
|
779
|
+
"""
|
|
780
|
+
probe = QuerySet(self.model)
|
|
781
|
+
probe.filter(**criteria)
|
|
782
|
+
where_clause, where_vars = probe._build_where()
|
|
783
|
+
query = f"SELECT * FROM {self._model_table}{where_clause} LIMIT 2;"
|
|
784
|
+
rows = await self._execute_query(query, where_vars)
|
|
785
|
+
return rows if isinstance(rows, list) else []
|
|
786
|
+
|
|
787
|
+
async def update_or_create(
|
|
788
|
+
self,
|
|
789
|
+
defaults: dict[str, Any] | None = None,
|
|
790
|
+
**criteria: Any,
|
|
791
|
+
) -> tuple[Any, bool]:
|
|
792
|
+
"""Look up a record by ``criteria``; create it or update it; return ``(obj, created)``.
|
|
793
|
+
|
|
794
|
+
Django-style: ``criteria`` are filters used to find the record; ``defaults`` are extra
|
|
795
|
+
field values (they win on conflict). The write payload is built from the EQUALITY
|
|
796
|
+
criteria fields plus ``defaults`` (non-``exact`` lookups drive the lookup only).
|
|
797
|
+
|
|
798
|
+
- 0 matches → CREATE via ``save()`` → ``created=True``.
|
|
799
|
+
- 1 match → partial UPDATE via ``merge()`` (untouched fields preserved) → ``created=False``.
|
|
800
|
+
- >1 matches → ``SurrealDbError`` (the criteria are not unique).
|
|
801
|
+
|
|
802
|
+
Writes go through ``save()``/``merge()``, so lifecycle signals fire, SDK errors are
|
|
803
|
+
normalised to ``SurrealDbError``, the primary key anchors identity, and — under
|
|
804
|
+
``objects(tx=)`` — the operation participates in the transaction. The lookup and the
|
|
805
|
+
write are two round-trips (no server-side locking on SurrealDB 2.6.x), so a small race
|
|
806
|
+
window exists, the same non-atomic fallback Django documents. ``criteria`` must be
|
|
807
|
+
non-empty.
|
|
808
|
+
|
|
809
|
+
Create builds ``self.model(**payload)``: keys not declared on the model are dropped by
|
|
810
|
+
Pydantic, and a missing required field raises ``ValidationError`` at construction.
|
|
811
|
+
"""
|
|
812
|
+
if not criteria:
|
|
813
|
+
raise SurrealDbError("update_or_create() requires at least one lookup criteria.")
|
|
814
|
+
defaults = defaults or {}
|
|
815
|
+
matches = await self._lookup_matches(criteria)
|
|
816
|
+
if len(matches) > 1:
|
|
817
|
+
raise SurrealDbError("update_or_create() matched multiple records; the lookup criteria are not unique.")
|
|
818
|
+
payload = {**self._criteria_payload(criteria), **defaults}
|
|
819
|
+
if not matches:
|
|
820
|
+
obj: Any = self.model(**payload)
|
|
821
|
+
await obj.save(tx=self._tx)
|
|
822
|
+
return obj, True
|
|
823
|
+
obj = self.model.from_db(matches[0])
|
|
824
|
+
await obj.merge(tx=self._tx, **payload)
|
|
825
|
+
return obj, False
|
|
826
|
+
|
|
827
|
+
async def get_or_create(
|
|
828
|
+
self,
|
|
829
|
+
defaults: dict[str, Any] | None = None,
|
|
830
|
+
**criteria: Any,
|
|
831
|
+
) -> tuple[Any, bool]:
|
|
832
|
+
"""Look up a record by ``criteria``; return it, or create it; return ``(obj, created)``.
|
|
833
|
+
|
|
834
|
+
Django-style: ``criteria`` are filters. Unlike ``update_or_create``, ``defaults`` are
|
|
835
|
+
applied ONLY when creating; an existing match is returned untouched.
|
|
836
|
+
|
|
837
|
+
- 0 matches → CREATE (equality criteria + ``defaults``) via ``save()`` → ``created=True``.
|
|
838
|
+
- 1 match → returned as-is (no write) → ``created=False``.
|
|
839
|
+
- >1 matches → ``SurrealDbError`` (the criteria are not unique).
|
|
840
|
+
|
|
841
|
+
The create goes through ``save()`` (signals, error normalisation, PK identity, and
|
|
842
|
+
``objects(tx=)`` participation); the lookup routes through the transaction too.
|
|
843
|
+
``criteria`` must be non-empty.
|
|
844
|
+
|
|
845
|
+
Create builds ``self.model(**payload)``: keys not declared on the model are dropped by
|
|
846
|
+
Pydantic, and a missing required field raises ``ValidationError`` at construction.
|
|
847
|
+
"""
|
|
848
|
+
if not criteria:
|
|
849
|
+
raise SurrealDbError("get_or_create() requires at least one lookup criteria.")
|
|
850
|
+
defaults = defaults or {}
|
|
851
|
+
matches = await self._lookup_matches(criteria)
|
|
852
|
+
if len(matches) > 1:
|
|
853
|
+
raise SurrealDbError("get_or_create() matched multiple records; the lookup criteria are not unique.")
|
|
854
|
+
if matches:
|
|
855
|
+
return self.model.from_db(matches[0]), False
|
|
856
|
+
payload = {**self._criteria_payload(criteria), **defaults}
|
|
857
|
+
obj = self.model(**payload)
|
|
858
|
+
await obj.save(tx=self._tx)
|
|
859
|
+
return obj, True
|
|
860
|
+
|
|
751
861
|
# ==================== Custom Query ====================
|
|
752
862
|
|
|
753
863
|
async def query(self, query: str, variables: dict[str, Any] | None = None) -> Any:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/connection_manager.py
RENAMED
|
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
|