surrealdb-orm 0.1.4__py3-none-any.whl → 0.5.0__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.
Files changed (50) hide show
  1. surreal_orm/__init__.py +72 -3
  2. surreal_orm/aggregations.py +164 -0
  3. surreal_orm/auth/__init__.py +15 -0
  4. surreal_orm/auth/access.py +167 -0
  5. surreal_orm/auth/mixins.py +302 -0
  6. surreal_orm/cli/__init__.py +15 -0
  7. surreal_orm/cli/commands.py +369 -0
  8. surreal_orm/connection_manager.py +58 -18
  9. surreal_orm/fields/__init__.py +36 -0
  10. surreal_orm/fields/encrypted.py +166 -0
  11. surreal_orm/fields/relation.py +465 -0
  12. surreal_orm/migrations/__init__.py +51 -0
  13. surreal_orm/migrations/executor.py +380 -0
  14. surreal_orm/migrations/generator.py +272 -0
  15. surreal_orm/migrations/introspector.py +305 -0
  16. surreal_orm/migrations/migration.py +188 -0
  17. surreal_orm/migrations/operations.py +531 -0
  18. surreal_orm/migrations/state.py +406 -0
  19. surreal_orm/model_base.py +530 -44
  20. surreal_orm/query_set.py +609 -33
  21. surreal_orm/relations.py +645 -0
  22. surreal_orm/surreal_function.py +95 -0
  23. surreal_orm/surreal_ql.py +113 -0
  24. surreal_orm/types.py +86 -0
  25. surreal_sdk/README.md +79 -0
  26. surreal_sdk/__init__.py +151 -0
  27. surreal_sdk/connection/__init__.py +17 -0
  28. surreal_sdk/connection/base.py +516 -0
  29. surreal_sdk/connection/http.py +421 -0
  30. surreal_sdk/connection/pool.py +244 -0
  31. surreal_sdk/connection/websocket.py +519 -0
  32. surreal_sdk/exceptions.py +71 -0
  33. surreal_sdk/functions.py +607 -0
  34. surreal_sdk/protocol/__init__.py +13 -0
  35. surreal_sdk/protocol/rpc.py +218 -0
  36. surreal_sdk/py.typed +0 -0
  37. surreal_sdk/pyproject.toml +49 -0
  38. surreal_sdk/streaming/__init__.py +31 -0
  39. surreal_sdk/streaming/change_feed.py +278 -0
  40. surreal_sdk/streaming/live_query.py +265 -0
  41. surreal_sdk/streaming/live_select.py +369 -0
  42. surreal_sdk/transaction.py +386 -0
  43. surreal_sdk/types.py +346 -0
  44. surrealdb_orm-0.5.0.dist-info/METADATA +465 -0
  45. surrealdb_orm-0.5.0.dist-info/RECORD +52 -0
  46. {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.0.dist-info}/WHEEL +1 -1
  47. surrealdb_orm-0.5.0.dist-info/entry_points.txt +2 -0
  48. {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.0.dist-info}/licenses/LICENSE +1 -1
  49. surrealdb_orm-0.1.4.dist-info/METADATA +0 -184
  50. surrealdb_orm-0.1.4.dist-info/RECORD +0 -12
surreal_orm/model_base.py CHANGED
@@ -1,38 +1,229 @@
1
- from typing import Any, Self
1
+ from typing import Any, Literal, Self, cast, TYPE_CHECKING
2
+
2
3
  from pydantic import BaseModel, ConfigDict, model_validator
4
+
3
5
  from .connection_manager import SurrealDBConnectionManager
4
- from surrealdb import RecordID, SurrealDbError
6
+ from .types import SchemaMode, TableType
7
+
8
+ if TYPE_CHECKING:
9
+ from surreal_sdk.transaction import BaseTransaction, HTTPTransaction
5
10
 
6
11
  import logging
7
12
 
8
13
 
14
+ class SurrealDbError(Exception):
15
+ """Error from SurrealDB operations."""
16
+
17
+ pass
18
+
19
+
9
20
  logger = logging.getLogger(__name__)
10
21
 
22
+ # Global registry of all SurrealDB models for migration introspection
23
+ _MODEL_REGISTRY: list[type["BaseSurrealModel"]] = []
24
+
25
+
26
+ def get_registered_models() -> list[type["BaseSurrealModel"]]:
27
+ """
28
+ Get all registered SurrealDB models.
29
+
30
+ Returns:
31
+ List of all model classes that inherit from BaseSurrealModel
32
+ """
33
+ return _MODEL_REGISTRY.copy()
34
+
35
+
36
+ def clear_model_registry() -> None:
37
+ """
38
+ Clear the model registry. Useful for testing.
39
+ """
40
+ _MODEL_REGISTRY.clear()
41
+
42
+
43
+ def _parse_record_id(record_id: Any) -> str | None:
44
+ """
45
+ Parse a record ID from various formats.
46
+ SurrealDB returns IDs as 'table:id' strings.
47
+ """
48
+ if record_id is None:
49
+ return None
50
+ record_str = str(record_id)
51
+ if ":" in record_str:
52
+ return record_str.split(":", 1)[1]
53
+ return record_str
54
+
11
55
 
12
56
  class SurrealConfigDict(ConfigDict):
13
57
  """
14
58
  SurrealConfigDict is a configuration dictionary for SurrealDB models.
15
59
 
60
+ Extends Pydantic's ConfigDict with SurrealDB-specific options for
61
+ table types, schema modes, and authentication settings.
62
+
16
63
  Attributes:
17
- primary_key (str | None): The primary key field name for the model.
64
+ primary_key: The primary key field name for the model
65
+ table_name: Override the default table name (default: class name)
66
+ table_type: Table classification (NORMAL, USER, STREAM, HASH)
67
+ schema_mode: Schema enforcement mode (SCHEMAFULL, SCHEMALESS)
68
+ changefeed: Changefeed duration for STREAM tables (e.g., "7d")
69
+ permissions: Table-level permissions dict {"select": "...", "update": "..."}
70
+ identifier_field: Field used for signin (USER type, default: "email")
71
+ password_field: Field containing password (USER type, default: "password")
72
+ token_duration: JWT token duration (USER type, default: "15m")
73
+ session_duration: Session duration (USER type, default: "12h")
18
74
  """
19
75
 
20
76
  primary_key: str | None
21
- " The primary key field name for the model. "
77
+ table_name: str | None
78
+ table_type: TableType | None
79
+ schema_mode: SchemaMode | None
80
+ changefeed: str | None
81
+ permissions: dict[str, str] | None
82
+ identifier_field: str | None
83
+ password_field: str | None
84
+ token_duration: str | None
85
+ session_duration: str | None
22
86
 
23
87
 
24
88
  class BaseSurrealModel(BaseModel):
25
89
  """
26
90
  Base class for models interacting with SurrealDB.
91
+
92
+ All models that interact with SurrealDB should inherit from this class.
93
+ Models are automatically registered for migration introspection.
94
+
95
+ Example:
96
+ class User(BaseSurrealModel):
97
+ model_config = SurrealConfigDict(
98
+ table_type=TableType.USER,
99
+ schema_mode=SchemaMode.SCHEMAFULL,
100
+ )
101
+
102
+ id: str | None = None
103
+ email: str
104
+ password: Encrypted
27
105
  """
28
106
 
107
+ def __init_subclass__(cls, **kwargs: Any) -> None:
108
+ """Register subclasses in the model registry for migration introspection."""
109
+ super().__init_subclass__(**kwargs)
110
+ # Only register concrete models, not intermediate base classes
111
+ if cls.__name__ != "BaseSurrealModel" and not cls.__name__.startswith("_"):
112
+ if cls not in _MODEL_REGISTRY:
113
+ _MODEL_REGISTRY.append(cls)
114
+
29
115
  @classmethod
30
116
  def get_table_name(cls) -> str:
31
117
  """
32
118
  Get the table name for the model.
119
+
120
+ Returns the table_name from model_config if set,
121
+ otherwise returns the class name.
33
122
  """
123
+ if hasattr(cls, "model_config"):
124
+ table_name = cls.model_config.get("table_name", None)
125
+ if isinstance(table_name, str):
126
+ return table_name
34
127
  return cls.__name__
35
128
 
129
+ @classmethod
130
+ def get_table_type(cls) -> TableType:
131
+ """
132
+ Get the table type classification for the model.
133
+
134
+ Returns:
135
+ TableType enum value (default: NORMAL)
136
+ """
137
+ if hasattr(cls, "model_config"):
138
+ table_type = cls.model_config.get("table_type", None)
139
+ if isinstance(table_type, TableType):
140
+ return table_type
141
+ return TableType.NORMAL
142
+
143
+ @classmethod
144
+ def get_schema_mode(cls) -> SchemaMode:
145
+ """
146
+ Get the schema mode for the model.
147
+
148
+ USER tables are always SCHEMAFULL.
149
+ HASH tables default to SCHEMALESS.
150
+ All others default to SCHEMAFULL.
151
+
152
+ Returns:
153
+ SchemaMode enum value
154
+ """
155
+ table_type = cls.get_table_type()
156
+
157
+ # USER tables must be SCHEMAFULL
158
+ if table_type == TableType.USER:
159
+ return SchemaMode.SCHEMAFULL
160
+
161
+ if hasattr(cls, "model_config"):
162
+ schema_mode = cls.model_config.get("schema_mode", None)
163
+ if isinstance(schema_mode, SchemaMode):
164
+ return schema_mode
165
+
166
+ # HASH tables default to SCHEMALESS
167
+ if table_type == TableType.HASH:
168
+ return SchemaMode.SCHEMALESS
169
+
170
+ return SchemaMode.SCHEMAFULL
171
+
172
+ @classmethod
173
+ def get_changefeed(cls) -> str | None:
174
+ """
175
+ Get the changefeed duration for the model.
176
+
177
+ Returns:
178
+ Changefeed duration string (e.g., "7d") or None
179
+ """
180
+ if hasattr(cls, "model_config"):
181
+ changefeed = cls.model_config.get("changefeed", None)
182
+ return str(changefeed) if changefeed is not None else None
183
+ return None
184
+
185
+ @classmethod
186
+ def get_permissions(cls) -> dict[str, str]:
187
+ """
188
+ Get the table permissions for the model.
189
+
190
+ Returns:
191
+ Dict of permission type to condition expression
192
+ """
193
+ if hasattr(cls, "model_config"):
194
+ permissions = cls.model_config.get("permissions", None)
195
+ if isinstance(permissions, dict):
196
+ return permissions
197
+ return {}
198
+
199
+ @classmethod
200
+ def get_identifier_field(cls) -> str:
201
+ """
202
+ Get the identifier field for USER type tables.
203
+
204
+ Returns:
205
+ Field name used for signin (default: "email")
206
+ """
207
+ if hasattr(cls, "model_config"):
208
+ field = cls.model_config.get("identifier_field", None)
209
+ if isinstance(field, str):
210
+ return field
211
+ return "email"
212
+
213
+ @classmethod
214
+ def get_password_field(cls) -> str:
215
+ """
216
+ Get the password field for USER type tables.
217
+
218
+ Returns:
219
+ Field name containing password (default: "password")
220
+ """
221
+ if hasattr(cls, "model_config"):
222
+ field = cls.model_config.get("password_field", None)
223
+ if isinstance(field, str):
224
+ return field
225
+ return "password"
226
+
36
227
  @classmethod
37
228
  def get_index_primary_key(cls) -> str | None:
38
229
  """
@@ -45,7 +236,7 @@ class BaseSurrealModel(BaseModel):
45
236
 
46
237
  return None
47
238
 
48
- def get_id(self) -> None | str | RecordID:
239
+ def get_id(self) -> str | None:
49
240
  """
50
241
  Get the ID of the model instance.
51
242
  """
@@ -62,10 +253,13 @@ class BaseSurrealModel(BaseModel):
62
253
  return None # pragma: no cover
63
254
 
64
255
  @classmethod
65
- def from_db(cls, record: dict | list) -> Self | list[Self]:
256
+ def from_db(cls, record: dict | list | None) -> Self | list[Self]:
66
257
  """
67
258
  Create an instance from a SurrealDB record.
68
259
  """
260
+ if record is None:
261
+ raise cls.DoesNotExist("Record not found.")
262
+
69
263
  if isinstance(record, list):
70
264
  return [cls.from_db(rs) for rs in record] # type: ignore
71
265
 
@@ -75,11 +269,11 @@ class BaseSurrealModel(BaseModel):
75
269
  @classmethod
76
270
  def set_data(cls, data: Any) -> Any:
77
271
  """
78
- Set the ID of the model instance.
272
+ Parse the ID from SurrealDB format (table:id) to just id.
79
273
  """
80
274
  if isinstance(data, dict): # pragma: no cover
81
- if "id" in data and isinstance(data["id"], RecordID):
82
- data["id"] = str(data["id"]).split(":")[1]
275
+ if "id" in data:
276
+ data["id"] = _parse_record_id(data["id"])
83
277
  return data
84
278
 
85
279
  async def refresh(self) -> None:
@@ -90,18 +284,56 @@ class BaseSurrealModel(BaseModel):
90
284
  raise SurrealDbError("Can't refresh data, not recorded yet.") # pragma: no cover
91
285
 
92
286
  client = await SurrealDBConnectionManager.get_client()
93
- record = await client.select(f"{self.get_table_name()}:{self.get_id()}")
287
+ result = await client.select(f"{self.get_table_name()}:{self.get_id()}")
94
288
 
289
+ # SDK returns RecordsResponse with .records list
290
+ if result.is_empty:
291
+ raise SurrealDbError("Can't refresh data, no record found.") # pragma: no cover
292
+
293
+ record = result.first
95
294
  if record is None:
96
295
  raise SurrealDbError("Can't refresh data, no record found.") # pragma: no cover
97
296
 
98
- self.from_db(record)
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)
99
303
  return None
100
304
 
101
- async def save(self) -> Self:
305
+ async def save(self, tx: "BaseTransaction | None" = None) -> Self:
102
306
  """
103
307
  Save the model instance to the database.
308
+
309
+ Args:
310
+ tx: Optional transaction to use for this operation.
311
+ If provided, the operation will be part of the transaction.
312
+
313
+ Example:
314
+ # Without transaction
315
+ await user.save()
316
+
317
+ # With transaction
318
+ async with SurrealDBConnectionManager.transaction() as tx:
319
+ await user.save(tx=tx)
104
320
  """
321
+ if tx is not None:
322
+ # Use transaction
323
+ data = self.model_dump(exclude={"id"})
324
+ id = self.get_id()
325
+ table = self.get_table_name()
326
+
327
+ if id is not None:
328
+ thing = f"{table}:{id}"
329
+ await tx.create(thing, data)
330
+ return self
331
+
332
+ # Auto-generate ID - create without specific ID
333
+ await tx.create(table, data)
334
+ return self
335
+
336
+ # Original behavior without transaction
105
337
  client = await SurrealDBConnectionManager.get_client()
106
338
  data = self.model_dump(exclude={"id"})
107
339
  id = self.get_id()
@@ -113,71 +345,95 @@ class BaseSurrealModel(BaseModel):
113
345
  return self
114
346
 
115
347
  # Auto-generate the ID
116
- record = await client.create(table, data) # pragma: no cover
348
+ result = await client.create(table, data) # pragma: no cover
117
349
 
118
- if isinstance(record, list):
119
- raise SurrealDbError("Can't save data, multiple records returned.") # pragma: no cover
120
-
121
- if record is None:
350
+ # SDK returns RecordResponse
351
+ if not result.exists:
122
352
  raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
123
353
 
124
- obj = self.from_db(record)
354
+ obj = self.from_db(cast(dict | list | None, result.record))
125
355
  if isinstance(obj, type(self)):
126
356
  self = obj
127
357
  return self
128
358
 
129
359
  raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
130
360
 
131
- async def update(self) -> Any:
361
+ async def update(self, tx: "BaseTransaction | None" = None) -> Any:
132
362
  """
133
363
  Update the model instance to the database.
134
- """
135
- client = await SurrealDBConnectionManager.get_client()
136
364
 
365
+ Args:
366
+ tx: Optional transaction to use for this operation.
367
+ """
137
368
  data = self.model_dump(exclude={"id"})
138
369
  id = self.get_id()
139
- if id is not None:
140
- thing = f"{self.__class__.__name__}:{id}"
141
- test = await client.update(thing, data)
142
- return test
143
- raise SurrealDbError("Can't update data, no id found.")
144
370
 
145
- async def merge(self, **data: Any) -> Any:
371
+ if id is None:
372
+ raise SurrealDbError("Can't update data, no id found.")
373
+
374
+ thing = f"{self.__class__.__name__}:{id}"
375
+
376
+ if tx is not None:
377
+ await tx.update(thing, data)
378
+ return None
379
+
380
+ client = await SurrealDBConnectionManager.get_client()
381
+ result = await client.update(thing, data)
382
+ return result.records
383
+
384
+ @classmethod
385
+ def get(cls, item: str) -> str:
146
386
  """
147
- Update the model instance to the database.
387
+ Get the table name for the model.
148
388
  """
389
+ return f"{cls.__name__}:{item}"
149
390
 
150
- client = await SurrealDBConnectionManager.get_client()
391
+ async def merge(self, tx: "BaseTransaction | None" = None, **data: Any) -> Any:
392
+ """
393
+ Merge (partial update) the model instance in the database.
394
+
395
+ Args:
396
+ tx: Optional transaction to use for this operation.
397
+ **data: Fields to update.
398
+ """
151
399
  data_set = {key: value for key, value in data.items()}
152
400
 
153
401
  id = self.get_id()
154
- if id:
155
- thing = f"{self.get_table_name()}:{id}"
402
+ if not id:
403
+ raise SurrealDbError(f"No Id for the data to merge: {data}")
404
+
405
+ thing = f"{self.get_table_name()}:{id}"
156
406
 
157
- await client.merge(thing, data_set)
158
- await self.refresh()
407
+ if tx is not None:
408
+ await tx.merge(thing, data_set)
159
409
  return
160
410
 
161
- raise SurrealDbError(f"No Id for the data to merge: {data}")
411
+ client = await SurrealDBConnectionManager.get_client()
412
+ await client.merge(thing, data_set)
413
+ await self.refresh()
162
414
 
163
- async def delete(self) -> None:
415
+ async def delete(self, tx: "BaseTransaction | None" = None) -> None:
164
416
  """
165
417
  Delete the model instance from the database.
166
- """
167
-
168
- client = await SurrealDBConnectionManager.get_client()
169
418
 
419
+ Args:
420
+ tx: Optional transaction to use for this operation.
421
+ """
170
422
  id = self.get_id()
171
-
172
423
  thing = f"{self.get_table_name()}:{id}"
173
424
 
174
- deleted = await client.delete(thing)
425
+ if tx is not None:
426
+ await tx.delete(thing)
427
+ logger.info(f"Record deleted (in transaction) -> {thing}.")
428
+ return
429
+
430
+ client = await SurrealDBConnectionManager.get_client()
431
+ result = await client.delete(thing)
175
432
 
176
- if not deleted:
433
+ if not result.success:
177
434
  raise SurrealDbError(f"Can't delete Record id -> '{id}' not found!")
178
435
 
179
- logger.info(f"Record deleted -> {deleted}.")
180
- del self
436
+ logger.info(f"Record deleted -> {result.deleted!r}.")
181
437
 
182
438
  @model_validator(mode="after")
183
439
  def check_config(self) -> Self:
@@ -187,7 +443,7 @@ class BaseSurrealModel(BaseModel):
187
443
 
188
444
  if not self.get_index_primary_key() and not hasattr(self, "id"):
189
445
  raise SurrealDbError( # pragma: no cover
190
- "Can't create model, the model need either 'id' field or primirary_key in 'model_config'."
446
+ "Can't create model, the model needs either 'id' field or primary_key in 'model_config'."
191
447
  )
192
448
 
193
449
  return self
@@ -200,3 +456,233 @@ class BaseSurrealModel(BaseModel):
200
456
  from .query_set import QuerySet
201
457
 
202
458
  return QuerySet(cls)
459
+
460
+ @classmethod
461
+ async def transaction(cls) -> "HTTPTransaction":
462
+ """
463
+ Create a transaction context manager for atomic operations.
464
+
465
+ This is a convenience method that delegates to SurrealDBConnectionManager.
466
+
467
+ Usage:
468
+ async with User.transaction() as tx:
469
+ user1 = User(id="1", name="Alice")
470
+ await user1.save(tx=tx)
471
+ user2 = User(id="2", name="Bob")
472
+ await user2.save(tx=tx)
473
+ # Auto-commit on success, auto-rollback on exception
474
+
475
+ Returns:
476
+ HTTPTransaction context manager
477
+ """
478
+ return await SurrealDBConnectionManager.transaction()
479
+
480
+ # ==================== Graph Relation Methods ====================
481
+
482
+ async def relate(
483
+ self,
484
+ relation: str,
485
+ to: "BaseSurrealModel",
486
+ tx: "BaseTransaction | None" = None,
487
+ **edge_data: Any,
488
+ ) -> dict[str, Any]:
489
+ """
490
+ Create a graph relation (edge) to another record.
491
+
492
+ This method creates a SurrealDB RELATE edge between this record
493
+ and the target record. Optional edge data can be stored on the relation.
494
+
495
+ Args:
496
+ relation: Name of the edge table (e.g., "follows", "likes")
497
+ to: Target model instance to relate to
498
+ tx: Optional transaction to use for this operation
499
+ **edge_data: Additional data to store on the edge record
500
+
501
+ Returns:
502
+ dict: The created edge record
503
+
504
+ Example:
505
+ # Simple relation
506
+ await alice.relate("follows", bob)
507
+
508
+ # With edge data
509
+ await alice.relate("follows", bob, since="2025-01-01", strength="strong")
510
+
511
+ # In a transaction
512
+ async with User.transaction() as tx:
513
+ await alice.relate("follows", bob, tx=tx)
514
+ await alice.relate("follows", charlie, tx=tx)
515
+
516
+ SurrealQL equivalent:
517
+ RELATE users:alice->follows->users:bob SET since = '2025-01-01';
518
+ """
519
+ source_id = self.get_id()
520
+ target_id = to.get_id()
521
+
522
+ if not source_id:
523
+ raise SurrealDbError("Cannot create relation from unsaved instance")
524
+ if not target_id:
525
+ raise SurrealDbError("Cannot create relation to unsaved instance")
526
+
527
+ source_table = self.get_table_name()
528
+ target_table = to.get_table_name()
529
+
530
+ from_thing = f"{source_table}:{source_id}"
531
+ to_thing = f"{target_table}:{target_id}"
532
+
533
+ if tx is not None:
534
+ await tx.relate(from_thing, relation, to_thing, edge_data if edge_data else None)
535
+ return {"in": from_thing, "out": to_thing, **edge_data}
536
+
537
+ client = await SurrealDBConnectionManager.get_client()
538
+ result = await client.relate(
539
+ from_thing,
540
+ relation,
541
+ to_thing,
542
+ edge_data if edge_data else None,
543
+ )
544
+
545
+ if result.exists and result.record:
546
+ return dict(result.record)
547
+ return {"in": from_thing, "out": to_thing, **edge_data}
548
+
549
+ async def remove_relation(
550
+ self,
551
+ relation: str,
552
+ to: "BaseSurrealModel",
553
+ tx: "BaseTransaction | None" = None,
554
+ ) -> None:
555
+ """
556
+ Remove a graph relation (edge) to another record.
557
+
558
+ This method deletes the edge record(s) between this record
559
+ and the target record.
560
+
561
+ Args:
562
+ relation: Name of the edge table (e.g., "follows", "likes")
563
+ to: Target model instance to unrelate
564
+ tx: Optional transaction to use for this operation
565
+
566
+ Example:
567
+ # Remove relation
568
+ await alice.remove_relation("follows", bob)
569
+
570
+ # In a transaction
571
+ async with User.transaction() as tx:
572
+ await alice.remove_relation("follows", bob, tx=tx)
573
+ await alice.remove_relation("follows", charlie, tx=tx)
574
+ """
575
+ source_id = self.get_id()
576
+ target_id = to.get_id()
577
+
578
+ if not source_id:
579
+ raise SurrealDbError("Cannot remove relation from unsaved instance")
580
+ if not target_id:
581
+ raise SurrealDbError("Cannot remove relation to unsaved instance")
582
+
583
+ source_table = self.get_table_name()
584
+ target_table = to.get_table_name()
585
+
586
+ # Delete edge where in=source and out=target
587
+ query = f"DELETE {relation} WHERE in = {source_table}:{source_id} AND out = {target_table}:{target_id};"
588
+
589
+ if tx is not None:
590
+ await tx.query(query)
591
+ return
592
+
593
+ client = await SurrealDBConnectionManager.get_client()
594
+ await client.query(query)
595
+
596
+ async def get_related(
597
+ self,
598
+ relation: str,
599
+ direction: Literal["out", "in", "both"] = "out",
600
+ model_class: type["BaseSurrealModel"] | None = None,
601
+ ) -> list["BaseSurrealModel"] | list[dict[str, Any]]:
602
+ """
603
+ Get records related through a graph relation.
604
+
605
+ This method queries SurrealDB's graph traversal capabilities
606
+ to find related records.
607
+
608
+ Args:
609
+ relation: Name of the edge table (e.g., "follows", "likes")
610
+ direction: Traversal direction
611
+ - "out": Outgoing edges (this record -> relation -> target)
612
+ - "in": Incoming edges (source -> relation -> this record)
613
+ - "both": Both directions
614
+ model_class: Optional model class to convert results to instances
615
+
616
+ Returns:
617
+ List of related model instances or dicts if model_class is None
618
+
619
+ Example:
620
+ # Get users this user follows
621
+ following = await alice.get_related("follows", direction="out")
622
+
623
+ # Get users who follow this user
624
+ followers = await alice.get_related("follows", direction="in")
625
+
626
+ # With model class for typed results
627
+ followers = await alice.get_related("follows", direction="in", model_class=User)
628
+
629
+ SurrealQL equivalent:
630
+ - out: SELECT out FROM follows WHERE in = users:alice FETCH out;
631
+ - in: SELECT in FROM follows WHERE out = users:alice FETCH in;
632
+ """
633
+ source_id = self.get_id()
634
+ if not source_id:
635
+ raise SurrealDbError("Cannot query relations from unsaved instance")
636
+
637
+ source_table = self.get_table_name()
638
+ source_thing = f"{source_table}:{source_id}"
639
+
640
+ client = await SurrealDBConnectionManager.get_client()
641
+ records: list[dict[str, Any]] = []
642
+
643
+ # Query edge table and fetch related records
644
+ # For outgoing: get 'out' field where 'in' matches source
645
+ # For incoming: get 'in' field where 'out' matches source
646
+ if direction == "out":
647
+ query = f"SELECT out FROM {relation} WHERE in = {source_thing} FETCH out;"
648
+ result = await client.query(query)
649
+ for row in result.all_records or []:
650
+ if isinstance(row.get("out"), dict):
651
+ records.append(row["out"])
652
+ elif direction == "in":
653
+ query = f"SELECT in FROM {relation} WHERE out = {source_thing} FETCH in;"
654
+ result = await client.query(query)
655
+ for row in result.all_records or []:
656
+ if isinstance(row.get("in"), dict):
657
+ records.append(row["in"])
658
+ else: # both
659
+ # Get both outgoing and incoming relations
660
+ query_out = f"SELECT out FROM {relation} WHERE in = {source_thing} FETCH out;"
661
+ query_in = f"SELECT in FROM {relation} WHERE out = {source_thing} FETCH in;"
662
+ result_out = await client.query(query_out)
663
+ result_in = await client.query(query_in)
664
+ for row in result_out.all_records or []:
665
+ if isinstance(row.get("out"), dict):
666
+ records.append(row["out"])
667
+ for row in result_in.all_records or []:
668
+ if isinstance(row.get("in"), dict):
669
+ records.append(row["in"])
670
+
671
+ if model_class is not None:
672
+ instances: list[BaseSurrealModel] = []
673
+ for record in records:
674
+ instance = model_class.from_db(record)
675
+ if isinstance(instance, list):
676
+ instances.extend(instance)
677
+ else:
678
+ instances.append(instance)
679
+ return instances
680
+
681
+ return records
682
+
683
+ class DoesNotExist(Exception):
684
+ """
685
+ Exception raised when a model instance does not exist.
686
+ """
687
+
688
+ pass