surrealdb-orm 0.5.2__py3-none-any.whl → 0.5.3.1__py3-none-any.whl

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.

surreal_orm/model_base.py CHANGED
@@ -1,6 +1,7 @@
1
- from typing import Any, Literal, Self, cast, TYPE_CHECKING
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
- return cls(**record)
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 from the record
298
- for key, value in record.items():
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={"id"}, exclude_unset=True)
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.create(thing, data)
431
+ await tx.merge(thing, data)
330
432
  return self
331
433
 
332
- # Auto-generate ID - create without specific ID
333
- await tx.create(table, data)
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={"id"}, exclude_unset=True)
339
- id = self.get_id()
340
- table = self.get_table_name()
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.create(thing, data)
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) # pragma: no cover
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
- obj = self.from_db(cast(dict | list | None, result.record))
355
- if isinstance(obj, type(self)):
356
- self = obj
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
- data = self.model_dump(exclude={"id"}, exclude_unset=True)
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
- await tx.update(thing, data)
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
- result = await client.update(thing, data)
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) -> 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
- return
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: surrealdb-orm
3
- Version: 0.5.2
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
@@ -3,7 +3,7 @@ surreal_orm/aggregations.py,sha256=5ERMHMWQfaW76OrMNazMjyg7dbf9bJ3GX_8QWz6tfxY,3
3
3
  surreal_orm/connection_manager.py,sha256=VJVfsuUXK5OEG0rtuiu3DKbyWxkkeFrCEtzjXFd6lAw,10496
4
4
  surreal_orm/constants.py,sha256=CLavEca1M6cLJLqVl4l4KoE-cBrgVQNsuGxW9zGJBmg,429
5
5
  surreal_orm/enum.py,sha256=kR-vzkHqnqy9YaYOvWTwAHdl2-WCzPcSEch-YTyJv1Y,158
6
- surreal_orm/model_base.py,sha256=8UjJGAHhNyltaSKp5yriXJHrxw_g05Z3nQvJ99Bb5QE,23013
6
+ surreal_orm/model_base.py,sha256=mbU9zEDBN2Sty9c__JyVVFUJ_g_rkNrwxrKUZtDsiDg,28649
7
7
  surreal_orm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  surreal_orm/query_set.py,sha256=3nmQ9A13g-1EJlRZsJQX8z7yFO3pjI_6MscFT3W4Lm0,38890
9
9
  surreal_orm/relations.py,sha256=pbsbKz2jw1h9FdFQowidUkx8krYv-l3OreQEViasN5w,21137
@@ -32,10 +32,10 @@ surreal_sdk/exceptions.py,sha256=qiDA3xJ2VkY8HUhvmDmW8kkNxhZ9NJu-FCUKUzfBrbU,148
32
32
  surreal_sdk/functions.py,sha256=eTJA6zWlivTEnKJgtR53MAygElhMB1o9p6_FPt8_ErI,20537
33
33
  surreal_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  surreal_sdk/pyproject.toml,sha256=pbcDsXdadtxF2d4aQKlinbEYEIftYfGy-T_lZX_8sRw,1421
35
- surreal_sdk/transaction.py,sha256=FkRcMO3qIb0CmjYnPRseDcZ1GIe-v1KRJeuATBuT_HU,13396
35
+ surreal_sdk/transaction.py,sha256=AIvEJhGcqmaQ4WiyTCD_knhpGW7zffyd7eSYT32V3Vc,14287
36
36
  surreal_sdk/types.py,sha256=y08l9x-ZYgPSAIFztzly474HiZ9slxYqZ0oAA7jJpOI,9841
37
37
  surreal_sdk/connection/__init__.py,sha256=zTe2qPLHsl9Upy6NYUr04rr4aWP-D2vCMA2iM5eUcU0,363
38
- surreal_sdk/connection/base.py,sha256=Z8I3thpIHaKxI6CTv-sErdDzopaOZJHQLas3BX6tXXY,15194
38
+ surreal_sdk/connection/base.py,sha256=AN-Ab84Vr5TcI4Wpxm_Px63yDBvBo2vLFUdffgBMtLw,15769
39
39
  surreal_sdk/connection/http.py,sha256=bUJ7bwEGKbufFo6NNggOBSimvJZZGmhoY9IR0Jd4YPw,12648
40
40
  surreal_sdk/connection/pool.py,sha256=v9FZmpfwWn674iY--xdDtaiYimBgpv3UtGT4k6q0DHE,7849
41
41
  surreal_sdk/connection/websocket.py,sha256=sTbYSAi9-C8W3T2gR1bm-XO3vtCirtdO95MYJoxH3z8,18238
@@ -45,8 +45,8 @@ surreal_sdk/streaming/__init__.py,sha256=TljF9HFN-XOshK_1smmTanF68hcdNsTgdlHtfFo
45
45
  surreal_sdk/streaming/change_feed.py,sha256=lS6CGNinsMkzslf1y0aV0VgZ2gq01EDIEZrnvvYhv-E,8540
46
46
  surreal_sdk/streaming/live_query.py,sha256=QwPXmRIsH0j3jgGQY9ULJU7MB0eTk8dnOVbPzy4SeSo,7561
47
47
  surreal_sdk/streaming/live_select.py,sha256=mYg6NKMx3GPShg_hYz4SjjDwhG7baI3t_Gynv8YCBNE,12035
48
- surrealdb_orm-0.5.2.dist-info/METADATA,sha256=K8-wXndSr-_25BO46XjHatDzUJikSChhSqDm9paEMqc,16596
49
- surrealdb_orm-0.5.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
50
- surrealdb_orm-0.5.2.dist-info/entry_points.txt,sha256=3zbp1VzVPSxFrxNuvKZAyt9U432QITdBNjp5QnMZ2o8,62
51
- surrealdb_orm-0.5.2.dist-info/licenses/LICENSE,sha256=OYVJQ7TKsjHAgVndePmKcMWOZkxwJxOHiXOG1TAnCk4,1079
52
- surrealdb_orm-0.5.2.dist-info/RECORD,,
48
+ surrealdb_orm-0.5.3.1.dist-info/METADATA,sha256=PjZ0BlUAAnqXOJPmIX1spcFq8C_6IV0x35xHpcJH9eo,17464
49
+ surrealdb_orm-0.5.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
50
+ surrealdb_orm-0.5.3.1.dist-info/entry_points.txt,sha256=3zbp1VzVPSxFrxNuvKZAyt9U432QITdBNjp5QnMZ2o8,62
51
+ surrealdb_orm-0.5.3.1.dist-info/licenses/LICENSE,sha256=OYVJQ7TKsjHAgVndePmKcMWOZkxwJxOHiXOG1TAnCk4,1079
52
+ surrealdb_orm-0.5.3.1.dist-info/RECORD,,