surrealdb-orm 0.5.3__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, 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.
@@ -109,6 +140,10 @@ class BaseSurrealModel(BaseModel):
109
140
  password: Encrypted
110
141
  """
111
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
+
112
147
  def __init_subclass__(cls, **kwargs: Any) -> None:
113
148
  """Register subclasses in the model registry for migration introspection."""
114
149
  super().__init_subclass__(**kwargs)
@@ -285,7 +320,9 @@ class BaseSurrealModel(BaseModel):
285
320
  if isinstance(record, list):
286
321
  return [cls.from_db(rs) for rs in record] # type: ignore
287
322
 
288
- return cls(**record)
323
+ instance = cls(**record)
324
+ instance._db_persisted = True
325
+ return instance
289
326
 
290
327
  @model_validator(mode="before")
291
328
  @classmethod
@@ -305,15 +342,26 @@ class BaseSurrealModel(BaseModel):
305
342
  This preserves the original __pydantic_fields_set__ so that exclude_unset=True
306
343
  continues to work correctly on subsequent saves.
307
344
 
345
+ Also handles type coercion for datetime fields (SurrealDB returns ISO strings).
346
+
308
347
  Args:
309
348
  record: Dictionary of field values from the database.
310
349
  """
311
350
  # Store original fields_set to preserve user-set tracking
312
351
  original_fields_set = self.__pydantic_fields_set__.copy()
313
352
 
353
+ # Get field type annotations for datetime parsing
354
+ field_types = self.__class__.model_fields
355
+
314
356
  for key, value in record.items():
315
357
  if key == "id":
316
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
+
317
365
  if hasattr(self, key):
318
366
  setattr(self, key, value)
319
367
 
@@ -321,6 +369,9 @@ class BaseSurrealModel(BaseModel):
321
369
  # DB-loaded fields should not be considered as "set" for exclude_unset
322
370
  object.__setattr__(self, "__pydantic_fields_set__", original_fields_set)
323
371
 
372
+ # Mark as persisted since we just loaded data from DB
373
+ self._db_persisted = True
374
+
324
375
  async def refresh(self) -> None:
325
376
  """
326
377
  Refresh the model instance from the database.
@@ -347,6 +398,11 @@ class BaseSurrealModel(BaseModel):
347
398
  """
348
399
  Save the model instance to the database.
349
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
+
350
406
  Args:
351
407
  tx: Optional transaction to use for this operation.
352
408
  If provided, the operation will be part of the transaction.
@@ -361,37 +417,51 @@ class BaseSurrealModel(BaseModel):
361
417
  """
362
418
  # Build exclude set: always exclude 'id' and any server-generated fields
363
419
  exclude_fields = {"id"} | self.get_server_fields()
420
+ id = self.get_id()
421
+ table = self.get_table_name()
364
422
 
365
423
  if tx is not None:
366
424
  # Use transaction
367
425
  data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
368
- id = self.get_id()
369
- table = self.get_table_name()
426
+
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
430
+ thing = f"{table}:{id}"
431
+ await tx.merge(thing, data)
432
+ return self
370
433
 
371
434
  if id is not None:
372
- # Use upsert for idempotent save (create or update)
435
+ # New record with user-provided ID: use upsert
373
436
  thing = f"{table}:{id}"
374
437
  await tx.upsert(thing, data)
375
- return self
438
+ else:
439
+ # Auto-generate ID
440
+ await tx.create(table, data)
376
441
 
377
- # Auto-generate ID - create without specific ID
378
- await tx.create(table, data)
442
+ self._db_persisted = True
379
443
  return self
380
444
 
381
445
  # Original behavior without transaction
382
446
  client = await SurrealDBConnectionManager.get_client()
383
447
  data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
384
- id = self.get_id()
385
- table = self.get_table_name()
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
386
455
 
387
456
  if id is not None:
388
- # Use upsert for idempotent save (create or update)
457
+ # New record with user-provided ID: use upsert
389
458
  thing = f"{table}:{id}"
390
459
  await client.upsert(thing, data)
460
+ self._db_persisted = True
391
461
  return self
392
462
 
393
463
  # Auto-generate the ID
394
- result = await client.create(table, data) # pragma: no cover
464
+ result = await client.create(table, data)
395
465
 
396
466
  # SDK returns RecordResponse
397
467
  if not result.exists:
@@ -411,6 +481,9 @@ class BaseSurrealModel(BaseModel):
411
481
  """
412
482
  Update the model instance to the database.
413
483
 
484
+ Uses merge() to only update the specified fields, preserving
485
+ any fields that weren't explicitly set.
486
+
414
487
  Args:
415
488
  tx: Optional transaction to use for this operation.
416
489
  """
@@ -425,11 +498,13 @@ class BaseSurrealModel(BaseModel):
425
498
  thing = f"{self.__class__.__name__}:{id}"
426
499
 
427
500
  if tx is not None:
428
- await tx.update(thing, data)
501
+ # Use merge for partial update - preserves unspecified fields
502
+ await tx.merge(thing, data)
429
503
  return None
430
504
 
431
505
  client = await SurrealDBConnectionManager.get_client()
432
- result = await client.update(thing, data)
506
+ # Use merge for partial update - preserves unspecified fields
507
+ result = await client.merge(thing, data)
433
508
  return result.records
434
509
 
435
510
  @classmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: surrealdb-orm
3
- Version: 0.5.3
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,9 +68,15 @@ 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
+
71
77
  ### v0.5.3 - ORM Improvements
72
78
 
73
- - **Upsert save behavior** - `save()` now uses `upsert` for existing records (idempotent, Django-like)
79
+ - **Upsert save behavior** - `save()` now uses `upsert` for new records with ID (idempotent, Django-like)
74
80
  - **`server_fields` config** - Exclude server-generated fields (created_at, updated_at) from saves
75
81
  - **`merge()` returns self** - Now returns the updated model instance instead of None
76
82
  - **`save()` updates self** - Updates original instance attributes instead of returning new object
@@ -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=yXFsCskiCkmqpWtpNpYWy-MIYsrQvovd71jHMgzUNG4,25652
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
@@ -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.3.dist-info/METADATA,sha256=qCZAOpLAFD3y7PY_o8HJ2fgLldLm4wxE8E_G2nANl6k,17102
49
- surrealdb_orm-0.5.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
50
- surrealdb_orm-0.5.3.dist-info/entry_points.txt,sha256=3zbp1VzVPSxFrxNuvKZAyt9U432QITdBNjp5QnMZ2o8,62
51
- surrealdb_orm-0.5.3.dist-info/licenses/LICENSE,sha256=OYVJQ7TKsjHAgVndePmKcMWOZkxwJxOHiXOG1TAnCk4,1079
52
- surrealdb_orm-0.5.3.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,,