surrealdb-orm 0.5.1__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/__init__.py CHANGED
@@ -78,4 +78,4 @@ __all__ = [
78
78
  "AuthenticatedUserMixin",
79
79
  ]
80
80
 
81
- __version__ = "0.5.1"
81
+ __version__ = "0.5.2"
@@ -29,14 +29,31 @@ class SurrealDBConnectionManager:
29
29
  return await SurrealDBConnectionManager.get_client()
30
30
 
31
31
  @classmethod
32
- def set_connection(cls, url: str, user: str, password: str, namespace: str, database: str) -> None:
32
+ def set_connection(
33
+ cls,
34
+ url: str,
35
+ user: str,
36
+ password: str,
37
+ namespace: str,
38
+ database: str,
39
+ *,
40
+ username: str | None = None,
41
+ ) -> None:
33
42
  """
34
43
  Set the connection kwargs for the SurrealDB instance.
35
44
 
36
- :param kwargs: The connection kwargs for the SurrealDB instance.
45
+ :param url: The URL of the SurrealDB instance.
46
+ :param user: The username for authentication.
47
+ :param password: The password for authentication.
48
+ :param namespace: The namespace to use.
49
+ :param database: The database to use.
50
+ :param username: Keyword-only alias for 'user' (overrides 'user' if provided).
37
51
  """
52
+ # Allow 'username' keyword to override 'user' for API flexibility
53
+ actual_user = username if username is not None else user
54
+
38
55
  cls.__url = url
39
- cls.__user = user
56
+ cls.__user = actual_user
40
57
  cls.__password = password
41
58
  cls.__namespace = namespace
42
59
  cls.__database = database
@@ -9,6 +9,53 @@ from abc import ABC, abstractmethod
9
9
  from dataclasses import dataclass, field
10
10
  from typing import Any, Callable, Coroutine
11
11
 
12
+ from ..types import FieldType
13
+
14
+
15
+ def _normalize_field_type(field_type: FieldType | str) -> str:
16
+ """
17
+ Normalize a field type to its string representation.
18
+
19
+ Accepts FieldType enum or string. For strings, validates that it's either
20
+ a known FieldType value or a valid generic type (e.g., "array<string>").
21
+
22
+ Args:
23
+ field_type: FieldType enum or string type specification
24
+
25
+ Returns:
26
+ String representation of the type for SurrealQL
27
+
28
+ Raises:
29
+ ValueError: If the string is not a valid SurrealDB type
30
+ """
31
+ if isinstance(field_type, FieldType):
32
+ return field_type.value
33
+
34
+ # Check if it's a known base type
35
+ try:
36
+ return FieldType(field_type).value
37
+ except ValueError:
38
+ pass
39
+
40
+ # Check if it's a generic type (e.g., "array<string>", "record<users>")
41
+ if "<" in field_type and field_type.endswith(">"):
42
+ base_type = field_type.split("<")[0]
43
+ try:
44
+ FieldType(base_type)
45
+ return field_type # Valid generic type
46
+ except ValueError:
47
+ pass
48
+
49
+ # Check for union types (e.g., "int | null", "option<string>")
50
+ if "|" in field_type:
51
+ return field_type # Allow union types
52
+
53
+ raise ValueError(
54
+ f"Invalid field type: '{field_type}'. "
55
+ f"Must be a FieldType enum value, a valid SurrealDB type string, "
56
+ f"or a generic type like 'array<string>' or 'record<users>'."
57
+ )
58
+
12
59
 
13
60
  @dataclass
14
61
  class Operation(ABC):
@@ -123,17 +170,24 @@ class AddField(Operation):
123
170
  AddField(
124
171
  table="users",
125
172
  name="email",
126
- field_type="string",
173
+ field_type=FieldType.STRING, # or "string"
127
174
  assertion="is::email($value)"
128
175
  )
129
176
 
177
+ # With generic types
178
+ AddField(
179
+ table="users",
180
+ name="tags",
181
+ field_type=FieldType.ARRAY.generic("string"), # "array<string>"
182
+ )
183
+
130
184
  Generates:
131
185
  DEFINE FIELD email ON users TYPE string ASSERT is::email($value);
132
186
  """
133
187
 
134
188
  table: str
135
189
  name: str
136
- field_type: str
190
+ field_type: FieldType | str
137
191
  default: Any = None
138
192
  assertion: str | None = None
139
193
  encrypted: bool = False
@@ -142,13 +196,19 @@ class AddField(Operation):
142
196
  value: str | None = None
143
197
  comment: str | None = None
144
198
 
199
+ def __post_init__(self) -> None:
200
+ """Validate field_type on initialization."""
201
+ # Validate the field type (raises ValueError if invalid)
202
+ _normalize_field_type(self.field_type)
203
+
145
204
  def forwards(self) -> str:
146
205
  parts = [f"DEFINE FIELD {self.name} ON {self.table}"]
147
206
 
148
207
  if self.flexible:
149
208
  parts.append("FLEXIBLE")
150
209
 
151
- parts.append(f"TYPE {self.field_type}")
210
+ normalized_type = _normalize_field_type(self.field_type)
211
+ parts.append(f"TYPE {normalized_type}")
152
212
 
153
213
  # For encrypted fields, use VALUE clause with crypto function
154
214
  if self.encrypted:
@@ -224,7 +284,7 @@ class AlterField(Operation):
224
284
  AlterField(
225
285
  table="users",
226
286
  name="email",
227
- field_type="string",
287
+ field_type=FieldType.STRING, # or "string"
228
288
  assertion="is::email($value)"
229
289
  )
230
290
 
@@ -234,7 +294,7 @@ class AlterField(Operation):
234
294
 
235
295
  table: str
236
296
  name: str
237
- field_type: str | None = None
297
+ field_type: FieldType | str | None = None
238
298
  default: Any = None
239
299
  assertion: str | None = None
240
300
  encrypted: bool = False
@@ -242,10 +302,19 @@ class AlterField(Operation):
242
302
  readonly: bool = False
243
303
  value: str | None = None
244
304
  # Store previous definition for rollback
245
- previous_type: str | None = None
305
+ previous_type: FieldType | str | None = None
246
306
  previous_default: Any = None
247
307
  previous_assertion: str | None = None
248
308
 
309
+ def __post_init__(self) -> None:
310
+ """Validate field_type and set reversible based on previous state."""
311
+ # Validate field types if provided
312
+ if self.field_type is not None:
313
+ _normalize_field_type(self.field_type)
314
+ if self.previous_type is not None:
315
+ _normalize_field_type(self.previous_type)
316
+ object.__setattr__(self, "reversible", self.previous_type is not None)
317
+
249
318
  def forwards(self) -> str:
250
319
  # DEFINE FIELD is idempotent - it creates or updates
251
320
  parts = [f"DEFINE FIELD {self.name} ON {self.table}"]
@@ -254,7 +323,8 @@ class AlterField(Operation):
254
323
  parts.append("FLEXIBLE")
255
324
 
256
325
  if self.field_type:
257
- parts.append(f"TYPE {self.field_type}")
326
+ normalized_type = _normalize_field_type(self.field_type)
327
+ parts.append(f"TYPE {normalized_type}")
258
328
 
259
329
  if self.encrypted:
260
330
  parts.append("VALUE crypto::argon2::generate($value)")
@@ -284,7 +354,8 @@ class AlterField(Operation):
284
354
  if not self.previous_type:
285
355
  return ""
286
356
 
287
- parts = [f"DEFINE FIELD {self.name} ON {self.table} TYPE {self.previous_type}"]
357
+ normalized_prev_type = _normalize_field_type(self.previous_type)
358
+ parts = [f"DEFINE FIELD {self.name} ON {self.table} TYPE {normalized_prev_type}"]
288
359
 
289
360
  if self.previous_default is not None:
290
361
  if isinstance(self.previous_default, str):
@@ -297,10 +368,6 @@ class AlterField(Operation):
297
368
 
298
369
  return " ".join(parts) + ";"
299
370
 
300
- def __post_init__(self) -> None:
301
- """Set reversible based on whether previous state is stored."""
302
- object.__setattr__(self, "reversible", self.previous_type is not None)
303
-
304
371
  def describe(self) -> str:
305
372
  return f"Alter field {self.name} on {self.table}"
306
373
 
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"})
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"})
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"})
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
  """
surreal_orm/types.py CHANGED
@@ -42,23 +42,114 @@ class FieldType(StrEnum):
42
42
  SurrealDB field types for schema definitions.
43
43
 
44
44
  Maps to SurrealDB's native type system.
45
+ See: https://surrealdb.com/docs/surrealql/datamodel
46
+
47
+ Numeric Types:
48
+ - INT: 64-bit signed integer (-9223372036854775808 to 9223372036854775807)
49
+ - FLOAT: 64-bit double-precision floating point
50
+ - DECIMAL: Arbitrary precision decimal (for financial calculations)
51
+ - NUMBER: Auto-detected numeric type (stores using minimal bytes)
52
+
53
+ Primitive Types:
54
+ - STRING: Text data
55
+ - BOOL: Boolean true/false
56
+ - DATETIME: RFC 3339 timestamp with timezone
57
+ - DURATION: Time length (e.g., "1h30m", "7d")
58
+ - BYTES: Binary data / byte array
59
+ - UUID: Universal unique identifier
60
+
61
+ Collection Types:
62
+ - ARRAY: Ordered collection (can be typed: array<string>)
63
+ - SET: Unique collection (auto-deduplicated)
64
+ - OBJECT: Flexible JSON-like container
65
+
66
+ Special Types:
67
+ - ANY: Accepts any value type
68
+ - OPTION: Optional value (can be typed: option<string>)
69
+ - RECORD: Reference to another record (can be typed: record<users>)
70
+ - GEOMETRY: GeoJSON spatial data (point, line, polygon, etc.)
71
+ - REGEX: Compiled regular expression
72
+
73
+ Generic Type Syntax:
74
+ For typed collections/references, use the generic() class method:
75
+ - FieldType.ARRAY.generic("string") -> "array<string>"
76
+ - FieldType.RECORD.generic("users") -> "record<users>"
77
+ - FieldType.OPTION.generic("int") -> "option<int>"
78
+ - FieldType.GEOMETRY.generic("point") -> "geometry<point>"
45
79
  """
46
80
 
47
- STRING = "string"
81
+ # Numeric types
48
82
  INT = "int"
49
83
  FLOAT = "float"
84
+ DECIMAL = "decimal"
85
+ NUMBER = "number"
86
+
87
+ # Primitive types
88
+ STRING = "string"
50
89
  BOOL = "bool"
51
90
  DATETIME = "datetime"
52
91
  DURATION = "duration"
53
- DECIMAL = "decimal"
92
+ BYTES = "bytes"
93
+ UUID = "uuid"
94
+
95
+ # Collection types
54
96
  ARRAY = "array"
97
+ SET = "set"
55
98
  OBJECT = "object"
56
- RECORD = "record"
57
- GEOMETRY = "geometry"
99
+
100
+ # Special types
58
101
  ANY = "any"
59
102
  OPTION = "option"
60
- BYTES = "bytes"
61
- UUID = "uuid"
103
+ RECORD = "record"
104
+ GEOMETRY = "geometry"
105
+ REGEX = "regex"
106
+
107
+ def generic(self, inner_type: str) -> str:
108
+ """
109
+ Create a generic type string for parameterized types.
110
+
111
+ Args:
112
+ inner_type: The inner type parameter (e.g., "string", "users", "point")
113
+
114
+ Returns:
115
+ Formatted type string (e.g., "array<string>", "record<users>")
116
+
117
+ Examples:
118
+ >>> FieldType.ARRAY.generic("string")
119
+ 'array<string>'
120
+ >>> FieldType.RECORD.generic("users")
121
+ 'record<users>'
122
+ >>> FieldType.GEOMETRY.generic("point|polygon")
123
+ 'geometry<point|polygon>'
124
+ """
125
+ return f"{self.value}<{inner_type}>"
126
+
127
+ @classmethod
128
+ def from_python_type(cls, python_type: type) -> "FieldType":
129
+ """
130
+ Map a Python type to a SurrealDB FieldType.
131
+
132
+ Args:
133
+ python_type: A Python type (str, int, float, bool, list, dict, bytes)
134
+
135
+ Returns:
136
+ The corresponding FieldType
137
+
138
+ Raises:
139
+ ValueError: If the type cannot be mapped
140
+ """
141
+ mapping: dict[type, FieldType] = {
142
+ str: cls.STRING,
143
+ int: cls.INT,
144
+ float: cls.FLOAT,
145
+ bool: cls.BOOL,
146
+ list: cls.ARRAY,
147
+ dict: cls.OBJECT,
148
+ bytes: cls.BYTES,
149
+ }
150
+ if python_type in mapping:
151
+ return mapping[python_type]
152
+ raise ValueError(f"Cannot map Python type {python_type} to SurrealDB FieldType")
62
153
 
63
154
 
64
155
  class EncryptionAlgorithm(StrEnum):
@@ -75,6 +166,7 @@ class EncryptionAlgorithm(StrEnum):
75
166
 
76
167
 
77
168
  # Type mapping from Python types to SurrealDB types
169
+ # Deprecated: Use FieldType.from_python_type() instead
78
170
  PYTHON_TO_SURREAL_TYPE: dict[type, FieldType] = {
79
171
  str: FieldType.STRING,
80
172
  int: FieldType.INT,
surreal_sdk/__init__.py CHANGED
@@ -64,7 +64,7 @@ from .functions import (
64
64
  CryptoFunctions,
65
65
  )
66
66
 
67
- __version__ = "0.5.1"
67
+ __version__ = "0.5.2"
68
68
  __all__ = [
69
69
  # Connections
70
70
  "BaseSurrealConnection",
@@ -71,8 +71,8 @@ class BaseSurrealConnection(ABC):
71
71
  # Abstract methods that must be implemented
72
72
 
73
73
  @abstractmethod
74
- async def connect(self) -> None:
75
- """Establish connection to SurrealDB."""
74
+ async def connect(self) -> Self:
75
+ """Establish connection to SurrealDB. Returns self for fluent API."""
76
76
  ...
77
77
 
78
78
  @abstractmethod
@@ -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.
@@ -4,7 +4,7 @@ HTTP Connection Implementation for SurrealDB SDK.
4
4
  Provides stateless HTTP-based connection, ideal for microservices and serverless.
5
5
  """
6
6
 
7
- from typing import TYPE_CHECKING, Any
7
+ from typing import TYPE_CHECKING, Any, Self
8
8
 
9
9
  import httpx
10
10
 
@@ -71,10 +71,10 @@ class HTTPConnection(BaseSurrealConnection):
71
71
  self._request_id += 1
72
72
  return self._request_id
73
73
 
74
- async def connect(self) -> None:
75
- """Establish HTTP client connection."""
74
+ async def connect(self) -> Self:
75
+ """Establish HTTP client connection. Returns self for fluent API."""
76
76
  if self._connected:
77
- return
77
+ return self
78
78
 
79
79
  self._client = httpx.AsyncClient(
80
80
  base_url=self.url,
@@ -83,6 +83,7 @@ class HTTPConnection(BaseSurrealConnection):
83
83
  limits=httpx.Limits(max_keepalive_connections=0, max_connections=100),
84
84
  )
85
85
  self._connected = True
86
+ return self
86
87
 
87
88
  async def close(self) -> None:
88
89
  """Close HTTP client."""
@@ -112,10 +113,12 @@ class HTTPConnection(BaseSurrealConnection):
112
113
  request.id = self._next_request_id()
113
114
 
114
115
  try:
116
+ # Use pre-encoded JSON with custom encoder for datetime, UUID, etc.
117
+ headers = {**self.headers, "Content-Type": "application/json"}
115
118
  response = await self._client.post(
116
119
  "/rpc",
117
- json=request.to_dict(),
118
- headers=self.headers,
120
+ content=request.to_json(),
121
+ headers=headers,
119
122
  )
120
123
  response.raise_for_status()
121
124
  return RPCResponse.from_dict(response.json())
@@ -4,7 +4,7 @@ WebSocket Connection Implementation for SurrealDB SDK.
4
4
  Provides stateful WebSocket-based connection for real-time features.
5
5
  """
6
6
 
7
- from typing import TYPE_CHECKING, Any, Callable, Coroutine
7
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Self
8
8
  import asyncio
9
9
  import json
10
10
 
@@ -80,16 +80,17 @@ class WebSocketConnection(BaseSurrealConnection):
80
80
  self._reader_task: asyncio.Task[None] | None = None
81
81
  self._reconnect_task: asyncio.Task[None] | None = None
82
82
  self._closing = False
83
+ self._callback_tasks: set[asyncio.Task[Any]] = set() # Track fire-and-forget callback tasks
83
84
 
84
85
  def _next_request_id(self) -> int:
85
86
  """Generate next request ID."""
86
87
  self._request_id += 1
87
88
  return self._request_id
88
89
 
89
- async def connect(self) -> None:
90
- """Establish WebSocket connection."""
90
+ async def connect(self) -> Self:
91
+ """Establish WebSocket connection. Returns self for fluent API."""
91
92
  if self._connected:
92
- return
93
+ return self
93
94
 
94
95
  self._closing = False
95
96
  self._session = aiohttp.ClientSession()
@@ -113,6 +114,8 @@ class WebSocketConnection(BaseSurrealConnection):
113
114
  # Set namespace and database
114
115
  await self.use(self.namespace, self.database)
115
116
 
117
+ return self
118
+
116
119
  except aiohttp.ClientError as e:
117
120
  await self._cleanup()
118
121
  raise ConnectionError(f"WebSocket connection failed: {e}")
@@ -151,6 +154,11 @@ class WebSocketConnection(BaseSurrealConnection):
151
154
  future.set_exception(ConnectionError("Connection closed"))
152
155
  self._pending.clear()
153
156
 
157
+ # Cancel all callback tasks
158
+ for task in self._callback_tasks:
159
+ task.cancel()
160
+ self._callback_tasks.clear()
161
+
154
162
  # Close WebSocket
155
163
  if self._ws:
156
164
  await self._ws.close()
@@ -217,7 +225,10 @@ class WebSocketConnection(BaseSurrealConnection):
217
225
  live_id = message.get("id")
218
226
  if live_id and live_id in self._live_callbacks:
219
227
  callback = self._live_callbacks[live_id]
220
- asyncio.create_task(callback(message))
228
+ # Track task for proper cleanup on connection close
229
+ task = asyncio.create_task(callback(message))
230
+ self._callback_tasks.add(task)
231
+ task.add_done_callback(self._callback_tasks.discard)
221
232
 
222
233
  async def _reconnect(self) -> None:
223
234
  """Attempt to reconnect after disconnection."""
@@ -5,10 +5,40 @@ Handles the JSON-RPC style messaging format used by SurrealDB.
5
5
  """
6
6
 
7
7
  from dataclasses import dataclass, field
8
+ from datetime import date, datetime, time
9
+ from decimal import Decimal
8
10
  from typing import Any
11
+ from uuid import UUID
9
12
  import json
10
13
 
11
14
 
15
+ class SurrealJSONEncoder(json.JSONEncoder):
16
+ """
17
+ Custom JSON encoder for SurrealDB types.
18
+
19
+ Handles serialization of Python types that are not natively JSON serializable:
20
+ - datetime → ISO 8601 string
21
+ - date → ISO 8601 string
22
+ - time → ISO 8601 string
23
+ - Decimal → float
24
+ - UUID → string
25
+ """
26
+
27
+ def default(self, obj: Any) -> Any:
28
+ """Encode non-standard types to JSON-serializable values."""
29
+ if isinstance(obj, datetime):
30
+ return obj.isoformat()
31
+ if isinstance(obj, date):
32
+ return obj.isoformat()
33
+ if isinstance(obj, time):
34
+ return obj.isoformat()
35
+ if isinstance(obj, Decimal):
36
+ return float(obj)
37
+ if isinstance(obj, UUID):
38
+ return str(obj)
39
+ return super().default(obj)
40
+
41
+
12
42
  @dataclass
13
43
  class RPCRequest:
14
44
  """
@@ -33,8 +63,8 @@ class RPCRequest:
33
63
  }
34
64
 
35
65
  def to_json(self) -> str:
36
- """Serialize to JSON string."""
37
- return json.dumps(self.to_dict())
66
+ """Serialize to JSON string with custom encoder for datetime, UUID, etc."""
67
+ return json.dumps(self.to_dict(), cls=SurrealJSONEncoder)
38
68
 
39
69
  @classmethod
40
70
  def query(cls, sql: str, vars: dict[str, Any] | None = None, request_id: int = 1) -> "RPCRequest":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "surreal-sdk"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "Custom Python SDK for SurrealDB with HTTP and WebSocket support. No dependency on official surrealdb package."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.1
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
@@ -66,7 +66,44 @@ Description-Content-Type: text/markdown
66
66
 
67
67
  ---
68
68
 
69
- ## What's New in 0.4.0
69
+ ## What's New in 0.5.x
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
+
79
+ ### v0.5.2 - Bug Fixes & FieldType Improvements
80
+
81
+ - **FieldType enum** - Enhanced migration type system with `generic()` and `from_python_type()` methods
82
+ - **datetime serialization** - Proper JSON encoding for datetime, date, time, Decimal, UUID
83
+ - **Fluent API** - `connect()` now returns `self` for method chaining
84
+ - **Session cleanup** - WebSocket callback tasks properly tracked and cancelled
85
+ - **Optional fields** - `exclude_unset=True` prevents None from overriding DB defaults
86
+ - **Parameter alias** - `username` parameter alias for `user` in ConnectionManager
87
+
88
+ ### v0.5.1 - Security Workflows
89
+
90
+ - **Dependabot integration** - Automatic dependency security updates
91
+ - **Auto-merge** - Dependabot PRs merged after CI passes
92
+ - **SurrealDB monitoring** - Integration tests on new SurrealDB releases
93
+
94
+ ### v0.5.0 - Real-time SDK Enhancements
95
+
96
+ - **Live Select Stream** - Async iterator pattern for real-time changes
97
+ - `async with db.live_select("table") as stream: async for change in stream:`
98
+ - `LiveChange` dataclass with `record_id`, `action`, `result`, `changed_fields`
99
+ - WHERE clause support with parameterized queries
100
+ - **Auto-Resubscribe** - Automatic reconnection after WebSocket disconnect
101
+ - `auto_resubscribe=True` parameter for seamless K8s pod restart recovery
102
+ - `on_reconnect(old_id, new_id)` callback for tracking ID changes
103
+ - **Typed Function Calls** - Pydantic/dataclass return type support
104
+ - `await db.call("fn::my_func", params={...}, return_type=MyModel)`
105
+
106
+ ### v0.4.0 - Relations & Graph
70
107
 
71
108
  - **Relations & Graph Traversal** - Django-style relation definitions with SurrealDB graph support
72
109
  - `ForeignKey`, `ManyToMany`, `Relation` field types
@@ -250,22 +287,54 @@ result = await db.fn.my_custom_function(arg1, arg2)
250
287
  Real-time updates via WebSocket:
251
288
 
252
289
  ```python
253
- from surreal_sdk import LiveQuery, LiveNotification, LiveAction
290
+ from surreal_sdk import LiveAction
291
+
292
+ # Async iterator pattern (recommended)
293
+ async with db.live_select(
294
+ "orders",
295
+ where="status = $status",
296
+ params={"status": "pending"},
297
+ auto_resubscribe=True, # Auto-reconnect on WebSocket drop
298
+ ) as stream:
299
+ async for change in stream:
300
+ match change.action:
301
+ case LiveAction.CREATE:
302
+ print(f"New order: {change.result}")
303
+ case LiveAction.UPDATE:
304
+ print(f"Updated: {change.record_id}")
305
+ case LiveAction.DELETE:
306
+ print(f"Deleted: {change.record_id}")
307
+
308
+ # Callback-based pattern
309
+ from surreal_sdk import LiveQuery, LiveNotification
254
310
 
255
311
  async def on_change(notification: LiveNotification):
256
- if notification.action == LiveAction.CREATE:
257
- print(f"New record: {notification.result}")
258
- elif notification.action == LiveAction.UPDATE:
259
- print(f"Updated: {notification.result}")
260
- elif notification.action == LiveAction.DELETE:
261
- print(f"Deleted: {notification.result}")
312
+ print(f"{notification.action}: {notification.result}")
262
313
 
263
314
  live = LiveQuery(ws_conn, "orders")
264
315
  await live.subscribe(on_change)
265
- # ... records changes trigger callbacks ...
316
+ # ... record changes trigger callbacks ...
266
317
  await live.unsubscribe()
267
318
  ```
268
319
 
320
+ **Typed Function Calls:**
321
+
322
+ ```python
323
+ from pydantic import BaseModel
324
+
325
+ class VoteResult(BaseModel):
326
+ success: bool
327
+ count: int
328
+
329
+ # Call SurrealDB function with typed return
330
+ result = await db.call(
331
+ "cast_vote",
332
+ params={"user": "alice", "vote": "yes"},
333
+ return_type=VoteResult
334
+ )
335
+ print(result.success, result.count) # Typed access
336
+ ```
337
+
269
338
  ---
270
339
 
271
340
  ## ORM Features
@@ -1,15 +1,15 @@
1
- surreal_orm/__init__.py,sha256=CiOI5uxIRF0ul_PThqxXw47jui2oMuvXzbo_ZkzRAhA,1645
1
+ surreal_orm/__init__.py,sha256=ucGuE53NINPmdGmEnoObF93n7iwyhORC-r0C-f_3n5Q,1645
2
2
  surreal_orm/aggregations.py,sha256=5ERMHMWQfaW76OrMNazMjyg7dbf9bJ3GX_8QWz6tfxY,3218
3
- surreal_orm/connection_manager.py,sha256=SA4W399idh8vVl5JI0LX_l-YE1C9KTfv51CaFNtQyGc,9971
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=STFrLauWm3r1GOm4bX6hTdSwupcfjAZGIk6hsDnYO0M,22953
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
10
10
  surreal_orm/surreal_function.py,sha256=zsiJHPa4Z-RfqJpekXupJI79SKxUGtsnptwFpq1KFEM,2627
11
11
  surreal_orm/surreal_ql.py,sha256=k3XHIdesu5Yb6RVU62ESizLLrJ8NtYYDQ8pI-WSqdRI,3436
12
- surreal_orm/types.py,sha256=bEbEXXVSRdjOFjt6IRZIXUjAjDvIymWdrxUGGa5imQY,2022
12
+ surreal_orm/types.py,sha256=0mb3FLd9qYlDN2d_qv7ZXwhiqHpW9ytFrDIWSZIj2QE,5167
13
13
  surreal_orm/utils.py,sha256=mni_dTtb4VGTdge8eWSZpBw5xoWci2m-XThKFHYPKTo,171
14
14
  surreal_orm/auth/__init__.py,sha256=wydszeh5ee4OpQu_OSX7CpCNKNIXPGFTlAoTmapxho4,340
15
15
  surreal_orm/auth/access.py,sha256=FUuBAgnyq_j4a6RfLJ96TsgSHpaNXFpwLvrpCVrUbVA,5584
@@ -24,29 +24,29 @@ surreal_orm/migrations/executor.py,sha256=6QNhsJAt0EL1QmBXmXBrG5LyVhKyJa0xxqd-9C
24
24
  surreal_orm/migrations/generator.py,sha256=PutA0OjXZ8YgBXKTAJrMymo-E0h1zyaVpqh7qCzQ5LM,7994
25
25
  surreal_orm/migrations/introspector.py,sha256=49AWhOAZRhWdv2jSaW3n6uuoInNh_U8AzUCo6toYgPo,9673
26
26
  surreal_orm/migrations/migration.py,sha256=6z7rJC-oWBDzAj_tqIpOCDSjRK6kJoHdKdkI6blb718,5663
27
- surreal_orm/migrations/operations.py,sha256=8WVn0vo51blD8KpzuDFUAvR211vSkZZKyBFtcoa0Qmc,14207
27
+ surreal_orm/migrations/operations.py,sha256=KHxxPIv-etJpMDrB9yqINhX1vnSisjX8hHviiuHSJCo,16555
28
28
  surreal_orm/migrations/state.py,sha256=cWLLOuvojeoafBTYBfgKHlWJ0n31mnBcbeBVTtRvn8Q,15848
29
29
  surreal_sdk/README.md,sha256=SCHz5yMMvgHE70V7JGupvg3VZTllVsu60D_DtOngJjk,1957
30
- surreal_sdk/__init__.py,sha256=TPcaNxDzPIm3DC67NKNqtgtHj1l4ICUkeCLSlBHmXkk,4055
30
+ surreal_sdk/__init__.py,sha256=h7Bwv9N7FxrIM_YSi_Yy-bZYaMAw4o8kCeKH7UMtxvA,4055
31
31
  surreal_sdk/exceptions.py,sha256=qiDA3xJ2VkY8HUhvmDmW8kkNxhZ9NJu-FCUKUzfBrbU,1489
32
32
  surreal_sdk/functions.py,sha256=eTJA6zWlivTEnKJgtR53MAygElhMB1o9p6_FPt8_ErI,20537
33
33
  surreal_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- surreal_sdk/pyproject.toml,sha256=GYx99zPXSPu8mmrY0gKpnl-HUR9_aU9gOS85UyQdrQs,1421
35
- surreal_sdk/transaction.py,sha256=FkRcMO3qIb0CmjYnPRseDcZ1GIe-v1KRJeuATBuT_HU,13396
34
+ surreal_sdk/pyproject.toml,sha256=pbcDsXdadtxF2d4aQKlinbEYEIftYfGy-T_lZX_8sRw,1421
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=8InOGVHgcUiGLQcReNAK_D1yLH6gVP_jKXWgCVEzbBk,15165
39
- surreal_sdk/connection/http.py,sha256=0fef-CGJVUVQPXu_RGyR2bUQiebXk7l0pqXUHvc5m9M,12435
38
+ surreal_sdk/connection/base.py,sha256=AN-Ab84Vr5TcI4Wpxm_Px63yDBvBo2vLFUdffgBMtLw,15769
39
+ surreal_sdk/connection/http.py,sha256=bUJ7bwEGKbufFo6NNggOBSimvJZZGmhoY9IR0Jd4YPw,12648
40
40
  surreal_sdk/connection/pool.py,sha256=v9FZmpfwWn674iY--xdDtaiYimBgpv3UtGT4k6q0DHE,7849
41
- surreal_sdk/connection/websocket.py,sha256=veqDVLu3fpWMIZEi3QrKSBFyGlHLiQfMichgH8u-LiU,17739
41
+ surreal_sdk/connection/websocket.py,sha256=sTbYSAi9-C8W3T2gR1bm-XO3vtCirtdO95MYJoxH3z8,18238
42
42
  surreal_sdk/protocol/__init__.py,sha256=ibLAVvwGfLChUHu3TStO7-bM1dkXggEE8x0BU7YNLTM,217
43
- surreal_sdk/protocol/rpc.py,sha256=_P-Am0T6OpXxO_9Un7RZnNe90ebeX0EJFHnBu7_k8TE,5945
43
+ surreal_sdk/protocol/rpc.py,sha256=602CvidbgawtmzW6d80WRl-V6D7hRLSQkTld7ZR2nUg,6925
44
44
  surreal_sdk/streaming/__init__.py,sha256=TljF9HFN-XOshK_1smmTanF68hcdNsTgdlHtfFoAmtQ,714
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.1.dist-info/METADATA,sha256=OkllmtEb8svEoNBcub_RKvmZcfytmlPo5qMK3y7Dx78,14375
49
- surrealdb_orm-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
50
- surrealdb_orm-0.5.1.dist-info/entry_points.txt,sha256=3zbp1VzVPSxFrxNuvKZAyt9U432QITdBNjp5QnMZ2o8,62
51
- surrealdb_orm-0.5.1.dist-info/licenses/LICENSE,sha256=OYVJQ7TKsjHAgVndePmKcMWOZkxwJxOHiXOG1TAnCk4,1079
52
- surrealdb_orm-0.5.1.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,,