surrealdb-orm 0.5.2__tar.gz → 0.5.3.1__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.
Potentially problematic release.
This version of surrealdb-orm might be problematic. Click here for more details.
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/PKG-INFO +15 -1
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/README.md +14 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/pyproject.toml +1 -1
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/model_base.py +163 -29
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/connection/base.py +17 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/transaction.py +20 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/.gitignore +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/LICENSE +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/Makefile +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/aggregations.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/auth/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/auth/access.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/auth/mixins.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/cli/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/cli/commands.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/connection_manager.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/constants.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/enum.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/fields/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/fields/encrypted.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/fields/relation.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/executor.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/generator.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/introspector.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/migration.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/operations.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/migrations/state.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/py.typed +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/query_set.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/relations.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/surreal_function.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/surreal_ql.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/types.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_orm/utils.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/README.md +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/connection/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/connection/http.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/connection/pool.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/connection/websocket.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/exceptions.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/functions.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/protocol/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/protocol/rpc.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/py.typed +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/pyproject.toml +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/streaming/__init__.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/streaming/change_feed.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/streaming/live_query.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/streaming/live_select.py +0 -0
- {surrealdb_orm-0.5.2 → surrealdb_orm-0.5.3.1}/src/surreal_sdk/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: surrealdb-orm
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3.1
|
|
4
4
|
Summary: SurrealDB ORM as 'DJango style' for Python with async support. Works with pydantic validation.
|
|
5
5
|
Project-URL: Homepage, https://github.com/EulogySnowfall/SurrealDB-ORM
|
|
6
6
|
Project-URL: Documentation, https://github.com/EulogySnowfall/SurrealDB-ORM
|
|
@@ -68,6 +68,20 @@ Description-Content-Type: text/markdown
|
|
|
68
68
|
|
|
69
69
|
## What's New in 0.5.x
|
|
70
70
|
|
|
71
|
+
### v0.5.3.1 - Bug Fixes
|
|
72
|
+
|
|
73
|
+
- **Partial updates for persisted records** - `save()` now uses `merge()` for already-persisted records, only sending modified fields
|
|
74
|
+
- **datetime parsing** - `_update_from_db()` now parses ISO 8601 strings to `datetime` objects automatically
|
|
75
|
+
- **`_db_persisted` flag** - Internal tracking to distinguish new vs persisted records
|
|
76
|
+
|
|
77
|
+
### v0.5.3 - ORM Improvements
|
|
78
|
+
|
|
79
|
+
- **Upsert save behavior** - `save()` now uses `upsert` for new records with ID (idempotent, Django-like)
|
|
80
|
+
- **`server_fields` config** - Exclude server-generated fields (created_at, updated_at) from saves
|
|
81
|
+
- **`merge()` returns self** - Now returns the updated model instance instead of None
|
|
82
|
+
- **`save()` updates self** - Updates original instance attributes instead of returning new object
|
|
83
|
+
- **NULL values fix** - `exclude_unset=True` now works correctly after loading from DB
|
|
84
|
+
|
|
71
85
|
### v0.5.2 - Bug Fixes & FieldType Improvements
|
|
72
86
|
|
|
73
87
|
- **FieldType enum** - Enhanced migration type system with `generic()` and `from_python_type()` methods
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
|
|
16
16
|
## What's New in 0.5.x
|
|
17
17
|
|
|
18
|
+
### v0.5.3.1 - Bug Fixes
|
|
19
|
+
|
|
20
|
+
- **Partial updates for persisted records** - `save()` now uses `merge()` for already-persisted records, only sending modified fields
|
|
21
|
+
- **datetime parsing** - `_update_from_db()` now parses ISO 8601 strings to `datetime` objects automatically
|
|
22
|
+
- **`_db_persisted` flag** - Internal tracking to distinguish new vs persisted records
|
|
23
|
+
|
|
24
|
+
### v0.5.3 - ORM Improvements
|
|
25
|
+
|
|
26
|
+
- **Upsert save behavior** - `save()` now uses `upsert` for new records with ID (idempotent, Django-like)
|
|
27
|
+
- **`server_fields` config** - Exclude server-generated fields (created_at, updated_at) from saves
|
|
28
|
+
- **`merge()` returns self** - Now returns the updated model instance instead of None
|
|
29
|
+
- **`save()` updates self** - Updates original instance attributes instead of returning new object
|
|
30
|
+
- **NULL values fix** - `exclude_unset=True` now works correctly after loading from DB
|
|
31
|
+
|
|
18
32
|
### v0.5.2 - Bug Fixes & FieldType Improvements
|
|
19
33
|
|
|
20
34
|
- **FieldType enum** - Enhanced migration type system with `generic()` and `from_python_type()` methods
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
from
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Literal, Self, TYPE_CHECKING, get_args, get_origin
|
|
2
3
|
|
|
3
|
-
from pydantic import BaseModel, ConfigDict, model_validator
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator
|
|
4
5
|
|
|
5
6
|
from .connection_manager import SurrealDBConnectionManager
|
|
6
7
|
from .types import SchemaMode, TableType
|
|
@@ -53,6 +54,36 @@ def _parse_record_id(record_id: Any) -> str | None:
|
|
|
53
54
|
return record_str
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
def _parse_datetime(value: Any) -> Any:
|
|
58
|
+
"""
|
|
59
|
+
Parse a datetime value from SurrealDB.
|
|
60
|
+
|
|
61
|
+
SurrealDB returns datetime as ISO 8601 strings.
|
|
62
|
+
"""
|
|
63
|
+
if value is None:
|
|
64
|
+
return None
|
|
65
|
+
if isinstance(value, datetime):
|
|
66
|
+
return value
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
try:
|
|
69
|
+
# Try parsing ISO format (with or without timezone)
|
|
70
|
+
# SurrealDB returns format like "2026-02-02T13:21:23.641315924Z"
|
|
71
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
return value # Return as-is if we can't parse
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_datetime_field(field_type: Any) -> bool:
|
|
78
|
+
"""Check if a field type is datetime or Optional[datetime]."""
|
|
79
|
+
# Handle Optional[datetime] which is Union[datetime, None]
|
|
80
|
+
origin = get_origin(field_type)
|
|
81
|
+
if origin is not None:
|
|
82
|
+
args = get_args(field_type)
|
|
83
|
+
return datetime in args
|
|
84
|
+
return field_type is datetime
|
|
85
|
+
|
|
86
|
+
|
|
56
87
|
class SurrealConfigDict(ConfigDict):
|
|
57
88
|
"""
|
|
58
89
|
SurrealConfigDict is a configuration dictionary for SurrealDB models.
|
|
@@ -71,6 +102,10 @@ class SurrealConfigDict(ConfigDict):
|
|
|
71
102
|
password_field: Field containing password (USER type, default: "password")
|
|
72
103
|
token_duration: JWT token duration (USER type, default: "15m")
|
|
73
104
|
session_duration: Session duration (USER type, default: "12h")
|
|
105
|
+
server_fields: List of field names that are server-generated and should
|
|
106
|
+
be excluded from save/update operations (e.g., ["created_at", "updated_at"]).
|
|
107
|
+
These fields are populated by SurrealDB's VALUE clause and should not be
|
|
108
|
+
sent back during updates.
|
|
74
109
|
"""
|
|
75
110
|
|
|
76
111
|
primary_key: str | None
|
|
@@ -83,6 +118,7 @@ class SurrealConfigDict(ConfigDict):
|
|
|
83
118
|
password_field: str | None
|
|
84
119
|
token_duration: str | None
|
|
85
120
|
session_duration: str | None
|
|
121
|
+
server_fields: list[str] | None
|
|
86
122
|
|
|
87
123
|
|
|
88
124
|
class BaseSurrealModel(BaseModel):
|
|
@@ -104,6 +140,10 @@ class BaseSurrealModel(BaseModel):
|
|
|
104
140
|
password: Encrypted
|
|
105
141
|
"""
|
|
106
142
|
|
|
143
|
+
# Private attribute to track if this instance has been persisted to the database.
|
|
144
|
+
# This helps distinguish between create (first save) and update (subsequent saves).
|
|
145
|
+
_db_persisted: bool = PrivateAttr(default=False)
|
|
146
|
+
|
|
107
147
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
108
148
|
"""Register subclasses in the model registry for migration introspection."""
|
|
109
149
|
super().__init_subclass__(**kwargs)
|
|
@@ -236,6 +276,23 @@ class BaseSurrealModel(BaseModel):
|
|
|
236
276
|
|
|
237
277
|
return None
|
|
238
278
|
|
|
279
|
+
@classmethod
|
|
280
|
+
def get_server_fields(cls) -> set[str]:
|
|
281
|
+
"""
|
|
282
|
+
Get the list of server-generated field names.
|
|
283
|
+
|
|
284
|
+
Server fields are populated by SurrealDB (e.g., via VALUE time::now())
|
|
285
|
+
and should be excluded from save/update operations.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Set of field names to exclude from save/update operations.
|
|
289
|
+
"""
|
|
290
|
+
if hasattr(cls, "model_config"):
|
|
291
|
+
server_fields = cls.model_config.get("server_fields", None)
|
|
292
|
+
if isinstance(server_fields, list):
|
|
293
|
+
return set(server_fields)
|
|
294
|
+
return set()
|
|
295
|
+
|
|
239
296
|
def get_id(self) -> str | None:
|
|
240
297
|
"""
|
|
241
298
|
Get the ID of the model instance.
|
|
@@ -263,7 +320,9 @@ class BaseSurrealModel(BaseModel):
|
|
|
263
320
|
if isinstance(record, list):
|
|
264
321
|
return [cls.from_db(rs) for rs in record] # type: ignore
|
|
265
322
|
|
|
266
|
-
|
|
323
|
+
instance = cls(**record)
|
|
324
|
+
instance._db_persisted = True
|
|
325
|
+
return instance
|
|
267
326
|
|
|
268
327
|
@model_validator(mode="before")
|
|
269
328
|
@classmethod
|
|
@@ -276,6 +335,43 @@ class BaseSurrealModel(BaseModel):
|
|
|
276
335
|
data["id"] = _parse_record_id(data["id"])
|
|
277
336
|
return data
|
|
278
337
|
|
|
338
|
+
def _update_from_db(self, record: dict[str, Any]) -> None:
|
|
339
|
+
"""
|
|
340
|
+
Update instance fields from a database record without marking them as user-set.
|
|
341
|
+
|
|
342
|
+
This preserves the original __pydantic_fields_set__ so that exclude_unset=True
|
|
343
|
+
continues to work correctly on subsequent saves.
|
|
344
|
+
|
|
345
|
+
Also handles type coercion for datetime fields (SurrealDB returns ISO strings).
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
record: Dictionary of field values from the database.
|
|
349
|
+
"""
|
|
350
|
+
# Store original fields_set to preserve user-set tracking
|
|
351
|
+
original_fields_set = self.__pydantic_fields_set__.copy()
|
|
352
|
+
|
|
353
|
+
# Get field type annotations for datetime parsing
|
|
354
|
+
field_types = self.__class__.model_fields
|
|
355
|
+
|
|
356
|
+
for key, value in record.items():
|
|
357
|
+
if key == "id":
|
|
358
|
+
value = _parse_record_id(value)
|
|
359
|
+
elif key in field_types:
|
|
360
|
+
# Check if this field is a datetime type and parse if needed
|
|
361
|
+
field_info = field_types[key]
|
|
362
|
+
if _is_datetime_field(field_info.annotation):
|
|
363
|
+
value = _parse_datetime(value)
|
|
364
|
+
|
|
365
|
+
if hasattr(self, key):
|
|
366
|
+
setattr(self, key, value)
|
|
367
|
+
|
|
368
|
+
# Restore original fields_set - only user-set fields should be marked
|
|
369
|
+
# DB-loaded fields should not be considered as "set" for exclude_unset
|
|
370
|
+
object.__setattr__(self, "__pydantic_fields_set__", original_fields_set)
|
|
371
|
+
|
|
372
|
+
# Mark as persisted since we just loaded data from DB
|
|
373
|
+
self._db_persisted = True
|
|
374
|
+
|
|
279
375
|
async def refresh(self) -> None:
|
|
280
376
|
"""
|
|
281
377
|
Refresh the model instance from the database.
|
|
@@ -294,18 +390,19 @@ class BaseSurrealModel(BaseModel):
|
|
|
294
390
|
if record is None:
|
|
295
391
|
raise SurrealDbError("Can't refresh data, no record found.") # pragma: no cover
|
|
296
392
|
|
|
297
|
-
# Update instance fields
|
|
298
|
-
|
|
299
|
-
if key == "id":
|
|
300
|
-
value = _parse_record_id(value)
|
|
301
|
-
if hasattr(self, key):
|
|
302
|
-
setattr(self, key, value)
|
|
393
|
+
# Update instance fields without marking them as user-set
|
|
394
|
+
self._update_from_db(record)
|
|
303
395
|
return None
|
|
304
396
|
|
|
305
397
|
async def save(self, tx: "BaseTransaction | None" = None) -> Self:
|
|
306
398
|
"""
|
|
307
399
|
Save the model instance to the database.
|
|
308
400
|
|
|
401
|
+
For persisted records: uses merge() for partial update, only sending
|
|
402
|
+
explicitly set fields (preserving server-side values like timestamps).
|
|
403
|
+
For new records with ID: uses upsert() to create or fully replace.
|
|
404
|
+
For new records without ID: uses create() to auto-generate an ID.
|
|
405
|
+
|
|
309
406
|
Args:
|
|
310
407
|
tx: Optional transaction to use for this operation.
|
|
311
408
|
If provided, the operation will be part of the transaction.
|
|
@@ -318,42 +415,64 @@ class BaseSurrealModel(BaseModel):
|
|
|
318
415
|
async with SurrealDBConnectionManager.transaction() as tx:
|
|
319
416
|
await user.save(tx=tx)
|
|
320
417
|
"""
|
|
418
|
+
# Build exclude set: always exclude 'id' and any server-generated fields
|
|
419
|
+
exclude_fields = {"id"} | self.get_server_fields()
|
|
420
|
+
id = self.get_id()
|
|
421
|
+
table = self.get_table_name()
|
|
422
|
+
|
|
321
423
|
if tx is not None:
|
|
322
424
|
# Use transaction
|
|
323
|
-
data = self.model_dump(exclude=
|
|
324
|
-
id = self.get_id()
|
|
325
|
-
table = self.get_table_name()
|
|
425
|
+
data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
|
|
326
426
|
|
|
327
|
-
if id is not None:
|
|
427
|
+
if self._db_persisted and id is not None:
|
|
428
|
+
# Already persisted: use merge for partial update
|
|
429
|
+
# Only sends explicitly set fields, preserving server-side values
|
|
328
430
|
thing = f"{table}:{id}"
|
|
329
|
-
await tx.
|
|
431
|
+
await tx.merge(thing, data)
|
|
330
432
|
return self
|
|
331
433
|
|
|
332
|
-
|
|
333
|
-
|
|
434
|
+
if id is not None:
|
|
435
|
+
# New record with user-provided ID: use upsert
|
|
436
|
+
thing = f"{table}:{id}"
|
|
437
|
+
await tx.upsert(thing, data)
|
|
438
|
+
else:
|
|
439
|
+
# Auto-generate ID
|
|
440
|
+
await tx.create(table, data)
|
|
441
|
+
|
|
442
|
+
self._db_persisted = True
|
|
334
443
|
return self
|
|
335
444
|
|
|
336
445
|
# Original behavior without transaction
|
|
337
446
|
client = await SurrealDBConnectionManager.get_client()
|
|
338
|
-
data = self.model_dump(exclude=
|
|
339
|
-
|
|
340
|
-
|
|
447
|
+
data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
|
|
448
|
+
|
|
449
|
+
if self._db_persisted and id is not None:
|
|
450
|
+
# Already persisted: use merge for partial update
|
|
451
|
+
# Only sends explicitly set fields, preserving server-side values
|
|
452
|
+
thing = f"{table}:{id}"
|
|
453
|
+
await client.merge(thing, data)
|
|
454
|
+
return self
|
|
341
455
|
|
|
342
456
|
if id is not None:
|
|
457
|
+
# New record with user-provided ID: use upsert
|
|
343
458
|
thing = f"{table}:{id}"
|
|
344
|
-
await client.
|
|
459
|
+
await client.upsert(thing, data)
|
|
460
|
+
self._db_persisted = True
|
|
345
461
|
return self
|
|
346
462
|
|
|
347
463
|
# Auto-generate the ID
|
|
348
|
-
result = await client.create(table, data)
|
|
464
|
+
result = await client.create(table, data)
|
|
349
465
|
|
|
350
466
|
# SDK returns RecordResponse
|
|
351
467
|
if not result.exists:
|
|
352
468
|
raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
|
|
353
469
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
470
|
+
# Update self's attributes from the database response
|
|
471
|
+
# This includes the auto-generated ID and any server-side fields
|
|
472
|
+
# Use _update_from_db to avoid marking DB fields as user-set
|
|
473
|
+
record = result.record
|
|
474
|
+
if isinstance(record, dict):
|
|
475
|
+
self._update_from_db(record)
|
|
357
476
|
return self
|
|
358
477
|
|
|
359
478
|
raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
|
|
@@ -362,10 +481,15 @@ class BaseSurrealModel(BaseModel):
|
|
|
362
481
|
"""
|
|
363
482
|
Update the model instance to the database.
|
|
364
483
|
|
|
484
|
+
Uses merge() to only update the specified fields, preserving
|
|
485
|
+
any fields that weren't explicitly set.
|
|
486
|
+
|
|
365
487
|
Args:
|
|
366
488
|
tx: Optional transaction to use for this operation.
|
|
367
489
|
"""
|
|
368
|
-
|
|
490
|
+
# Build exclude set: always exclude 'id' and any server-generated fields
|
|
491
|
+
exclude_fields = {"id"} | self.get_server_fields()
|
|
492
|
+
data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
|
|
369
493
|
id = self.get_id()
|
|
370
494
|
|
|
371
495
|
if id is None:
|
|
@@ -374,11 +498,13 @@ class BaseSurrealModel(BaseModel):
|
|
|
374
498
|
thing = f"{self.__class__.__name__}:{id}"
|
|
375
499
|
|
|
376
500
|
if tx is not None:
|
|
377
|
-
|
|
501
|
+
# Use merge for partial update - preserves unspecified fields
|
|
502
|
+
await tx.merge(thing, data)
|
|
378
503
|
return None
|
|
379
504
|
|
|
380
505
|
client = await SurrealDBConnectionManager.get_client()
|
|
381
|
-
|
|
506
|
+
# Use merge for partial update - preserves unspecified fields
|
|
507
|
+
result = await client.merge(thing, data)
|
|
382
508
|
return result.records
|
|
383
509
|
|
|
384
510
|
@classmethod
|
|
@@ -388,13 +514,16 @@ class BaseSurrealModel(BaseModel):
|
|
|
388
514
|
"""
|
|
389
515
|
return f"{cls.__name__}:{item}"
|
|
390
516
|
|
|
391
|
-
async def merge(self, tx: "BaseTransaction | None" = None, **data: Any) ->
|
|
517
|
+
async def merge(self, tx: "BaseTransaction | None" = None, **data: Any) -> Self:
|
|
392
518
|
"""
|
|
393
519
|
Merge (partial update) the model instance in the database.
|
|
394
520
|
|
|
395
521
|
Args:
|
|
396
522
|
tx: Optional transaction to use for this operation.
|
|
397
523
|
**data: Fields to update.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Self: The updated model instance.
|
|
398
527
|
"""
|
|
399
528
|
data_set = {key: value for key, value in data.items()}
|
|
400
529
|
|
|
@@ -406,11 +535,16 @@ class BaseSurrealModel(BaseModel):
|
|
|
406
535
|
|
|
407
536
|
if tx is not None:
|
|
408
537
|
await tx.merge(thing, data_set)
|
|
409
|
-
|
|
538
|
+
# Update local instance with merged data
|
|
539
|
+
for key, value in data_set.items():
|
|
540
|
+
if hasattr(self, key):
|
|
541
|
+
setattr(self, key, value)
|
|
542
|
+
return self
|
|
410
543
|
|
|
411
544
|
client = await SurrealDBConnectionManager.get_client()
|
|
412
545
|
await client.merge(thing, data_set)
|
|
413
546
|
await self.refresh()
|
|
547
|
+
return self
|
|
414
548
|
|
|
415
549
|
async def delete(self, tx: "BaseTransaction | None" = None) -> None:
|
|
416
550
|
"""
|
|
@@ -315,6 +315,23 @@ class BaseSurrealConnection(ABC):
|
|
|
315
315
|
result = await self.rpc("update", [thing, data])
|
|
316
316
|
return RecordsResponse.from_rpc_result(result)
|
|
317
317
|
|
|
318
|
+
async def upsert(self, thing: str, data: dict[str, Any]) -> RecordsResponse:
|
|
319
|
+
"""
|
|
320
|
+
Upsert record(s) - create if not exists, update if exists.
|
|
321
|
+
|
|
322
|
+
This is the recommended method for save operations when you have
|
|
323
|
+
a specific ID and want idempotent behavior.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
thing: Table name or record ID
|
|
327
|
+
data: Record data
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
RecordsResponse containing upserted record(s)
|
|
331
|
+
"""
|
|
332
|
+
result = await self.rpc("upsert", [thing, data])
|
|
333
|
+
return RecordsResponse.from_rpc_result(result)
|
|
334
|
+
|
|
318
335
|
async def merge(self, thing: str, data: dict[str, Any]) -> RecordsResponse:
|
|
319
336
|
"""
|
|
320
337
|
Merge data into record(s), updating only specified fields.
|
|
@@ -119,6 +119,11 @@ class BaseTransaction(ABC):
|
|
|
119
119
|
"""Update records within the transaction."""
|
|
120
120
|
...
|
|
121
121
|
|
|
122
|
+
@abstractmethod
|
|
123
|
+
async def upsert(self, thing: str, data: dict[str, Any]) -> "RecordsResponse":
|
|
124
|
+
"""Upsert records within the transaction (create or update)."""
|
|
125
|
+
...
|
|
126
|
+
|
|
122
127
|
@abstractmethod
|
|
123
128
|
async def merge(self, thing: str, data: dict[str, Any]) -> "RecordsResponse":
|
|
124
129
|
"""Merge data into records within the transaction."""
|
|
@@ -256,6 +261,15 @@ class HTTPTransaction(BaseTransaction):
|
|
|
256
261
|
self._queue_statement(sql, data)
|
|
257
262
|
return RecordsResponse(records=[], raw=[])
|
|
258
263
|
|
|
264
|
+
async def upsert(self, thing: str, data: dict[str, Any]) -> "RecordsResponse":
|
|
265
|
+
"""Queue an upsert operation (create or update)."""
|
|
266
|
+
from .types import RecordsResponse
|
|
267
|
+
|
|
268
|
+
fields = ", ".join(f"{k} = ${k}" for k in data.keys())
|
|
269
|
+
sql = f"UPSERT {thing} SET {fields};"
|
|
270
|
+
self._queue_statement(sql, data)
|
|
271
|
+
return RecordsResponse(records=[], raw=[])
|
|
272
|
+
|
|
259
273
|
async def merge(self, thing: str, data: dict[str, Any]) -> "RecordsResponse":
|
|
260
274
|
"""Queue a merge operation."""
|
|
261
275
|
from .types import RecordsResponse
|
|
@@ -361,6 +375,12 @@ class WebSocketTransaction(BaseTransaction):
|
|
|
361
375
|
raise TransactionError("Transaction not active")
|
|
362
376
|
return await self._connection.update(thing, data)
|
|
363
377
|
|
|
378
|
+
async def upsert(self, thing: str, data: dict[str, Any]) -> "RecordsResponse":
|
|
379
|
+
"""Execute upsert immediately within transaction (create or update)."""
|
|
380
|
+
if not self.is_active:
|
|
381
|
+
raise TransactionError("Transaction not active")
|
|
382
|
+
return await self._connection.upsert(thing, data)
|
|
383
|
+
|
|
364
384
|
async def merge(self, thing: str, data: dict[str, Any]) -> "RecordsResponse":
|
|
365
385
|
"""Execute merge immediately within transaction."""
|
|
366
386
|
if not self.is_active:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|