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.
Files changed (22) hide show
  1. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/CHANGELOG.md +44 -0
  2. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/PKG-INFO +65 -3
  3. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/README.md +64 -2
  4. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/pyproject.toml +1 -1
  5. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/__init__.py +1 -1
  6. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/model_base.py +79 -7
  7. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/query_set.py +110 -0
  8. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/.gitignore +0 -0
  9. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/LICENSE +0 -0
  10. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/Makefile +0 -0
  11. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/__init__.py +0 -0
  12. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/_sdk.py +0 -0
  13. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/aggregations.py +0 -0
  14. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/connection_manager.py +0 -0
  15. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/constants.py +0 -0
  16. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/enum.py +0 -0
  17. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/exceptions.py +0 -0
  18. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/py.typed +0 -0
  19. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/q.py +0 -0
  20. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/signals.py +0 -0
  21. {surreal_orm_lite-0.9.0 → surreal_orm_lite-0.10.0}/src/surreal_orm_lite/transaction.py +0 -0
  22. {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.9.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 – v0.22.0 | Tier 1 Core (auth, live, relations, …) | 📋 Planned |
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 – v0.22.0 | Tier 1 Core (auth, live, relations, …) | 📋 Planned |
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,6 @@
1
1
  [project]
2
2
  name = "surreal-orm-lite"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,4 +1,4 @@
1
- __version__ = "0.9.0"
1
+ __version__ = "0.10.0"
2
2
 
3
3
  from .aggregations import Aggregation, Avg, Count, Max, Min, Sum
4
4
  from .connection_manager import SurrealDBConnectionManager
@@ -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, created = await self._do_save(tx=tx)
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
- # Buffered op: there is no actual write to wrap here, so around_save is
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 self._do_save(tx=tx)
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: