surrealdb-orm 0.5.2__py3-none-any.whl → 0.5.3__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.
surreal_orm/model_base.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Any, Literal, Self, cast, TYPE_CHECKING
1
+ from typing import Any, Literal, Self, TYPE_CHECKING
2
2
 
3
3
  from pydantic import BaseModel, ConfigDict, model_validator
4
4
 
@@ -71,6 +71,10 @@ class SurrealConfigDict(ConfigDict):
71
71
  password_field: Field containing password (USER type, default: "password")
72
72
  token_duration: JWT token duration (USER type, default: "15m")
73
73
  session_duration: Session duration (USER type, default: "12h")
74
+ server_fields: List of field names that are server-generated and should
75
+ be excluded from save/update operations (e.g., ["created_at", "updated_at"]).
76
+ These fields are populated by SurrealDB's VALUE clause and should not be
77
+ sent back during updates.
74
78
  """
75
79
 
76
80
  primary_key: str | None
@@ -83,6 +87,7 @@ class SurrealConfigDict(ConfigDict):
83
87
  password_field: str | None
84
88
  token_duration: str | None
85
89
  session_duration: str | None
90
+ server_fields: list[str] | None
86
91
 
87
92
 
88
93
  class BaseSurrealModel(BaseModel):
@@ -236,6 +241,23 @@ class BaseSurrealModel(BaseModel):
236
241
 
237
242
  return None
238
243
 
244
+ @classmethod
245
+ def get_server_fields(cls) -> set[str]:
246
+ """
247
+ Get the list of server-generated field names.
248
+
249
+ Server fields are populated by SurrealDB (e.g., via VALUE time::now())
250
+ and should be excluded from save/update operations.
251
+
252
+ Returns:
253
+ Set of field names to exclude from save/update operations.
254
+ """
255
+ if hasattr(cls, "model_config"):
256
+ server_fields = cls.model_config.get("server_fields", None)
257
+ if isinstance(server_fields, list):
258
+ return set(server_fields)
259
+ return set()
260
+
239
261
  def get_id(self) -> str | None:
240
262
  """
241
263
  Get the ID of the model instance.
@@ -276,6 +298,29 @@ class BaseSurrealModel(BaseModel):
276
298
  data["id"] = _parse_record_id(data["id"])
277
299
  return data
278
300
 
301
+ def _update_from_db(self, record: dict[str, Any]) -> None:
302
+ """
303
+ Update instance fields from a database record without marking them as user-set.
304
+
305
+ This preserves the original __pydantic_fields_set__ so that exclude_unset=True
306
+ continues to work correctly on subsequent saves.
307
+
308
+ Args:
309
+ record: Dictionary of field values from the database.
310
+ """
311
+ # Store original fields_set to preserve user-set tracking
312
+ original_fields_set = self.__pydantic_fields_set__.copy()
313
+
314
+ for key, value in record.items():
315
+ if key == "id":
316
+ value = _parse_record_id(value)
317
+ if hasattr(self, key):
318
+ setattr(self, key, value)
319
+
320
+ # Restore original fields_set - only user-set fields should be marked
321
+ # DB-loaded fields should not be considered as "set" for exclude_unset
322
+ object.__setattr__(self, "__pydantic_fields_set__", original_fields_set)
323
+
279
324
  async def refresh(self) -> None:
280
325
  """
281
326
  Refresh the model instance from the database.
@@ -294,12 +339,8 @@ class BaseSurrealModel(BaseModel):
294
339
  if record is None:
295
340
  raise SurrealDbError("Can't refresh data, no record found.") # pragma: no cover
296
341
 
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)
342
+ # Update instance fields without marking them as user-set
343
+ self._update_from_db(record)
303
344
  return None
304
345
 
305
346
  async def save(self, tx: "BaseTransaction | None" = None) -> Self:
@@ -318,15 +359,19 @@ class BaseSurrealModel(BaseModel):
318
359
  async with SurrealDBConnectionManager.transaction() as tx:
319
360
  await user.save(tx=tx)
320
361
  """
362
+ # Build exclude set: always exclude 'id' and any server-generated fields
363
+ exclude_fields = {"id"} | self.get_server_fields()
364
+
321
365
  if tx is not None:
322
366
  # Use transaction
323
- data = self.model_dump(exclude={"id"}, exclude_unset=True)
367
+ data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
324
368
  id = self.get_id()
325
369
  table = self.get_table_name()
326
370
 
327
371
  if id is not None:
372
+ # Use upsert for idempotent save (create or update)
328
373
  thing = f"{table}:{id}"
329
- await tx.create(thing, data)
374
+ await tx.upsert(thing, data)
330
375
  return self
331
376
 
332
377
  # Auto-generate ID - create without specific ID
@@ -335,13 +380,14 @@ class BaseSurrealModel(BaseModel):
335
380
 
336
381
  # Original behavior without transaction
337
382
  client = await SurrealDBConnectionManager.get_client()
338
- data = self.model_dump(exclude={"id"}, exclude_unset=True)
383
+ data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
339
384
  id = self.get_id()
340
385
  table = self.get_table_name()
341
386
 
342
387
  if id is not None:
388
+ # Use upsert for idempotent save (create or update)
343
389
  thing = f"{table}:{id}"
344
- await client.create(thing, data)
390
+ await client.upsert(thing, data)
345
391
  return self
346
392
 
347
393
  # Auto-generate the ID
@@ -351,9 +397,12 @@ class BaseSurrealModel(BaseModel):
351
397
  if not result.exists:
352
398
  raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
353
399
 
354
- obj = self.from_db(cast(dict | list | None, result.record))
355
- if isinstance(obj, type(self)):
356
- self = obj
400
+ # Update self's attributes from the database response
401
+ # This includes the auto-generated ID and any server-side fields
402
+ # Use _update_from_db to avoid marking DB fields as user-set
403
+ record = result.record
404
+ if isinstance(record, dict):
405
+ self._update_from_db(record)
357
406
  return self
358
407
 
359
408
  raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
@@ -365,7 +414,9 @@ class BaseSurrealModel(BaseModel):
365
414
  Args:
366
415
  tx: Optional transaction to use for this operation.
367
416
  """
368
- data = self.model_dump(exclude={"id"}, exclude_unset=True)
417
+ # Build exclude set: always exclude 'id' and any server-generated fields
418
+ exclude_fields = {"id"} | self.get_server_fields()
419
+ data = self.model_dump(exclude=exclude_fields, exclude_unset=True)
369
420
  id = self.get_id()
370
421
 
371
422
  if id is None:
@@ -388,13 +439,16 @@ class BaseSurrealModel(BaseModel):
388
439
  """
389
440
  return f"{cls.__name__}:{item}"
390
441
 
391
- async def merge(self, tx: "BaseTransaction | None" = None, **data: Any) -> Any:
442
+ async def merge(self, tx: "BaseTransaction | None" = None, **data: Any) -> Self:
392
443
  """
393
444
  Merge (partial update) the model instance in the database.
394
445
 
395
446
  Args:
396
447
  tx: Optional transaction to use for this operation.
397
448
  **data: Fields to update.
449
+
450
+ Returns:
451
+ Self: The updated model instance.
398
452
  """
399
453
  data_set = {key: value for key, value in data.items()}
400
454
 
@@ -406,11 +460,16 @@ class BaseSurrealModel(BaseModel):
406
460
 
407
461
  if tx is not None:
408
462
  await tx.merge(thing, data_set)
409
- return
463
+ # Update local instance with merged data
464
+ for key, value in data_set.items():
465
+ if hasattr(self, key):
466
+ setattr(self, key, value)
467
+ return self
410
468
 
411
469
  client = await SurrealDBConnectionManager.get_client()
412
470
  await client.merge(thing, data_set)
413
471
  await self.refresh()
472
+ return self
414
473
 
415
474
  async def delete(self, tx: "BaseTransaction | None" = None) -> None:
416
475
  """
@@ -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
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,14 @@ Description-Content-Type: text/markdown
68
68
 
69
69
  ## What's New in 0.5.x
70
70
 
71
+ ### v0.5.3 - ORM Improvements
72
+
73
+ - **Upsert save behavior** - `save()` now uses `upsert` for existing records (idempotent, Django-like)
74
+ - **`server_fields` config** - Exclude server-generated fields (created_at, updated_at) from saves
75
+ - **`merge()` returns self** - Now returns the updated model instance instead of None
76
+ - **`save()` updates self** - Updates original instance attributes instead of returning new object
77
+ - **NULL values fix** - `exclude_unset=True` now works correctly after loading from DB
78
+
71
79
  ### v0.5.2 - Bug Fixes & FieldType Improvements
72
80
 
73
81
  - **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=yXFsCskiCkmqpWtpNpYWy-MIYsrQvovd71jHMgzUNG4,25652
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.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,,