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
@@ -0,0 +1,645 @@
1
+ """
2
+ Relation management for SurrealDB ORM.
3
+
4
+ This module provides classes for managing lazy loading and operations
5
+ on related objects, including graph traversal capabilities.
6
+
7
+ Example:
8
+ # Using RelationManager through model instance
9
+ followers = await alice.followers.all()
10
+ await alice.following.add(bob)
11
+ await alice.following.remove(charlie)
12
+
13
+ # Multi-hop traversal
14
+ friends_of_friends = await alice.following.following.all()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import TYPE_CHECKING, Any, Literal
21
+
22
+ from .connection_manager import SurrealDBConnectionManager
23
+ from .fields.relation import RelationInfo
24
+
25
+ if TYPE_CHECKING:
26
+ from .model_base import BaseSurrealModel
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class RelationQuerySet:
32
+ """
33
+ QuerySet for chained relation traversal.
34
+
35
+ Allows building multi-hop graph queries with filters at each level.
36
+
37
+ Example:
38
+ # Simple traversal
39
+ following = await user.following.all()
40
+
41
+ # Multi-hop
42
+ friends_of_friends = await user.following.following.all()
43
+
44
+ # With filters
45
+ active_fof = await user.following.filter(active=True).following.all()
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ instance: "BaseSurrealModel",
51
+ relation_info: RelationInfo,
52
+ traversal_path: list[tuple[RelationInfo, dict[str, Any]]] | None = None,
53
+ ):
54
+ """
55
+ Initialize a RelationQuerySet.
56
+
57
+ Args:
58
+ instance: The source model instance
59
+ relation_info: Information about the current relation
60
+ traversal_path: List of (relation_info, filters) for chained traversals
61
+ """
62
+ self._instance = instance
63
+ self._relation_info = relation_info
64
+ self._traversal_path = traversal_path or [(relation_info, {})]
65
+ self._filters: dict[str, Any] = {}
66
+
67
+ def filter(self, **kwargs: Any) -> "RelationQuerySet":
68
+ """
69
+ Add filters to the current traversal level.
70
+
71
+ Args:
72
+ **kwargs: Filter conditions (field=value or field__lookup=value)
73
+
74
+ Returns:
75
+ RelationQuerySet for chaining
76
+ """
77
+ # Update filters for the current level
78
+ new_path = self._traversal_path.copy()
79
+ if new_path:
80
+ current_info, current_filters = new_path[-1]
81
+ new_filters = {**current_filters, **kwargs}
82
+ new_path[-1] = (current_info, new_filters)
83
+
84
+ new_qs = RelationQuerySet(
85
+ self._instance,
86
+ self._relation_info,
87
+ new_path,
88
+ )
89
+ return new_qs
90
+
91
+ def __getattr__(self, name: str) -> "RelationQuerySet":
92
+ """
93
+ Enable chained traversal via attribute access.
94
+
95
+ Example:
96
+ user.following.following.all()
97
+ """
98
+ # Try to get the relation from the target model
99
+ # This requires model registry lookup
100
+ from .model_base import get_registered_models
101
+
102
+ target_model_name = self._relation_info.to_model
103
+ target_model = None
104
+
105
+ for model in get_registered_models():
106
+ if model.__name__ == target_model_name:
107
+ target_model = model
108
+ break
109
+
110
+ if target_model is None:
111
+ raise AttributeError(f"Model '{target_model_name}' not found in registry")
112
+
113
+ # Check if the target model has this relation
114
+ if hasattr(target_model, "__annotations__"):
115
+ from .fields.relation import get_relation_info as get_rel_info
116
+
117
+ annotations = target_model.__annotations__
118
+ if name in annotations:
119
+ field_type = annotations[name]
120
+ rel_info = get_rel_info(field_type)
121
+ if rel_info:
122
+ # Chain the traversal
123
+ new_path = self._traversal_path.copy()
124
+ new_path.append((rel_info, {}))
125
+ return RelationQuerySet(
126
+ self._instance,
127
+ rel_info,
128
+ new_path,
129
+ )
130
+
131
+ raise AttributeError(f"'{target_model_name}' has no relation '{name}'")
132
+
133
+ def _build_traversal_query(self) -> tuple[str, dict[str, Any]]:
134
+ """
135
+ Build the SurrealQL traversal query from the path.
136
+
137
+ Returns:
138
+ Tuple of (query_string, variables)
139
+ """
140
+ source_table = self._instance.get_table_name()
141
+ source_id = self._instance.get_id()
142
+
143
+ if not source_id:
144
+ raise ValueError("Cannot traverse relations from unsaved instance")
145
+
146
+ # Build the traversal path
147
+ path_parts = [f"{source_table}:{source_id}"]
148
+ variables: dict[str, Any] = {}
149
+ where_clauses: list[str] = []
150
+ var_counter = 0
151
+
152
+ for rel_info, filters in self._traversal_path:
153
+ # Add traversal direction and edge
154
+ if rel_info.relation_type == "relation":
155
+ direction = "<-" if rel_info.reverse else "->"
156
+ path_parts.append(f"{direction}{rel_info.edge_table}{direction}{rel_info.to_model}")
157
+ elif rel_info.relation_type == "many_to_many":
158
+ through = rel_info.through or f"_{rel_info.to_model.lower()}"
159
+ path_parts.append(f"->{through}->{rel_info.to_model}")
160
+ else: # foreign_key - different handling
161
+ pass
162
+
163
+ # Add filters for this level
164
+ for field, value in filters.items():
165
+ var_name = f"filter_{var_counter}"
166
+ var_counter += 1
167
+
168
+ # Handle lookup operators
169
+ if "__" in field:
170
+ parts = field.split("__", 1)
171
+ field_name = parts[0]
172
+ operator = parts[1]
173
+ clause = self._get_filter_clause(field_name, operator, var_name)
174
+ else:
175
+ clause = f"{field} = ${var_name}"
176
+
177
+ where_clauses.append(clause)
178
+ variables[var_name] = value
179
+
180
+ query = "SELECT * FROM " + "".join(path_parts)
181
+
182
+ if where_clauses:
183
+ query += " WHERE " + " AND ".join(where_clauses)
184
+
185
+ return query + ";", variables
186
+
187
+ def _get_filter_clause(self, field: str, operator: str, var_name: str) -> str:
188
+ """Convert filter operator to SurrealQL."""
189
+ operator_map = {
190
+ "exact": f"{field} = ${var_name}",
191
+ "gt": f"{field} > ${var_name}",
192
+ "gte": f"{field} >= ${var_name}",
193
+ "lt": f"{field} < ${var_name}",
194
+ "lte": f"{field} <= ${var_name}",
195
+ "in": f"{field} IN ${var_name}",
196
+ "contains": f"{field} CONTAINS ${var_name}",
197
+ "startswith": f"string::starts_with({field}, ${var_name})",
198
+ "endswith": f"string::ends_with({field}, ${var_name})",
199
+ "isnull": f"{field} IS NULL",
200
+ }
201
+ return operator_map.get(operator, f"{field} = ${var_name}")
202
+
203
+ async def all(self) -> list[Any]:
204
+ """
205
+ Execute the traversal and return all results.
206
+
207
+ Returns:
208
+ List of related model instances
209
+ """
210
+ query, variables = self._build_traversal_query()
211
+
212
+ client = await SurrealDBConnectionManager.get_client()
213
+ result = await client.query(query, variables)
214
+
215
+ # Convert results to model instances
216
+ from .model_base import get_registered_models
217
+
218
+ target_model_name = self._traversal_path[-1][0].to_model
219
+ target_model = None
220
+
221
+ for model in get_registered_models():
222
+ if model.__name__ == target_model_name:
223
+ target_model = model
224
+ break
225
+
226
+ if target_model and result.all_records:
227
+ return [target_model.from_db(record) for record in result.all_records]
228
+
229
+ return list(result.all_records) if result.all_records else []
230
+
231
+ async def first(self) -> Any | None:
232
+ """
233
+ Execute the traversal and return the first result.
234
+
235
+ Returns:
236
+ First related model instance or None
237
+ """
238
+ results = await self.all()
239
+ return results[0] if results else None
240
+
241
+ async def count(self) -> int:
242
+ """
243
+ Count the related records.
244
+
245
+ Returns:
246
+ Number of related records
247
+ """
248
+ results = await self.all()
249
+ return len(results)
250
+
251
+
252
+ class RelationManager:
253
+ """
254
+ Manages lazy loading and operations on related objects.
255
+
256
+ Provides a Django-like interface for working with relations,
257
+ including add/remove operations and query methods.
258
+
259
+ Example:
260
+ # Operations
261
+ await alice.following.add(bob)
262
+ await alice.following.add(charlie, david)
263
+ await alice.following.remove(bob)
264
+ await alice.following.set([charlie, david])
265
+ await alice.following.clear()
266
+
267
+ # Queries
268
+ followers = await alice.followers.all()
269
+ active = await alice.followers.filter(active=True)
270
+ count = await alice.followers.count()
271
+ is_following = await alice.following.contains(bob)
272
+ """
273
+
274
+ def __init__(
275
+ self,
276
+ instance: "BaseSurrealModel",
277
+ relation_info: RelationInfo,
278
+ field_name: str,
279
+ ):
280
+ """
281
+ Initialize a RelationManager.
282
+
283
+ Args:
284
+ instance: The model instance owning this relation
285
+ relation_info: Metadata about the relation
286
+ field_name: Name of the relation field
287
+ """
288
+ self._instance = instance
289
+ self._relation_info = relation_info
290
+ self._field_name = field_name
291
+ self._cache: list[Any] | None = None
292
+
293
+ def __repr__(self) -> str:
294
+ return f"<RelationManager({self._instance.__class__.__name__}.{self._field_name})>"
295
+
296
+ # ==================== Operations ====================
297
+
298
+ async def add(self, *objects: "BaseSurrealModel", **edge_data: Any) -> None:
299
+ """
300
+ Add objects to this relation.
301
+
302
+ For graph relations, creates RELATE edges.
303
+ For many-to-many, creates through table records.
304
+
305
+ Args:
306
+ *objects: Model instances to relate to
307
+ **edge_data: Additional data to store on the edge
308
+
309
+ Example:
310
+ await alice.following.add(bob)
311
+ await alice.following.add(charlie, david, since="2025-01-01")
312
+ """
313
+ if not self._instance.get_id():
314
+ raise ValueError("Cannot add relations to unsaved instance")
315
+
316
+ client = await SurrealDBConnectionManager.get_client()
317
+ source_table = self._instance.get_table_name()
318
+ source_id = self._instance.get_id()
319
+
320
+ for obj in objects:
321
+ if not obj.get_id():
322
+ raise ValueError("Cannot relate to unsaved instance")
323
+
324
+ target_table = obj.get_table_name()
325
+ target_id = obj.get_id()
326
+
327
+ if self._relation_info.relation_type == "relation":
328
+ # Use RELATE for graph relations
329
+ edge = self._relation_info.edge_table
330
+ if edge is None:
331
+ raise ValueError("Relation edge_table is required for graph relations")
332
+ if self._relation_info.reverse:
333
+ # Reverse relation: target -> edge -> source
334
+ await client.relate(
335
+ f"{target_table}:{target_id}",
336
+ edge,
337
+ f"{source_table}:{source_id}",
338
+ edge_data if edge_data else None,
339
+ )
340
+ else:
341
+ # Forward relation: source -> edge -> target
342
+ await client.relate(
343
+ f"{source_table}:{source_id}",
344
+ edge,
345
+ f"{target_table}:{target_id}",
346
+ edge_data if edge_data else None,
347
+ )
348
+ elif self._relation_info.relation_type == "many_to_many":
349
+ # Use intermediate table for many-to-many
350
+ through = self._relation_info.through or f"{source_table}_{target_table}"
351
+ await client.relate(
352
+ f"{source_table}:{source_id}",
353
+ through,
354
+ f"{target_table}:{target_id}",
355
+ edge_data if edge_data else None,
356
+ )
357
+
358
+ # Invalidate cache
359
+ self._cache = None
360
+
361
+ async def remove(self, *objects: "BaseSurrealModel") -> None:
362
+ """
363
+ Remove objects from this relation.
364
+
365
+ Deletes the edge records connecting the objects.
366
+
367
+ Args:
368
+ *objects: Model instances to unrelate
369
+
370
+ Example:
371
+ await alice.following.remove(bob)
372
+ """
373
+ if not self._instance.get_id():
374
+ raise ValueError("Cannot remove relations from unsaved instance")
375
+
376
+ client = await SurrealDBConnectionManager.get_client()
377
+ source_table = self._instance.get_table_name()
378
+ source_id = self._instance.get_id()
379
+
380
+ for obj in objects:
381
+ if not obj.get_id():
382
+ continue
383
+
384
+ target_table = obj.get_table_name()
385
+ target_id = obj.get_id()
386
+
387
+ if self._relation_info.relation_type == "relation":
388
+ edge = self._relation_info.edge_table
389
+ if self._relation_info.reverse:
390
+ # Delete edges where target -> edge -> source
391
+ query = f"DELETE {edge} WHERE in = {target_table}:{target_id} AND out = {source_table}:{source_id};"
392
+ else:
393
+ # Delete edges where source -> edge -> target
394
+ query = f"DELETE {edge} WHERE in = {source_table}:{source_id} AND out = {target_table}:{target_id};"
395
+ await client.query(query)
396
+ elif self._relation_info.relation_type == "many_to_many":
397
+ through = self._relation_info.through or f"{source_table}_{target_table}"
398
+ query = f"DELETE {through} WHERE in = {source_table}:{source_id} AND out = {target_table}:{target_id};"
399
+ await client.query(query)
400
+
401
+ # Invalidate cache
402
+ self._cache = None
403
+
404
+ async def set(self, objects: list["BaseSurrealModel"]) -> None:
405
+ """
406
+ Replace all relations with the given objects.
407
+
408
+ Clears existing relations and adds the new ones.
409
+
410
+ Args:
411
+ objects: List of model instances to set as relations
412
+
413
+ Example:
414
+ await alice.following.set([bob, charlie])
415
+ """
416
+ await self.clear()
417
+ if objects:
418
+ await self.add(*objects)
419
+
420
+ async def clear(self) -> None:
421
+ """
422
+ Remove all relations.
423
+
424
+ Example:
425
+ await alice.following.clear()
426
+ """
427
+ if not self._instance.get_id():
428
+ raise ValueError("Cannot clear relations from unsaved instance")
429
+
430
+ client = await SurrealDBConnectionManager.get_client()
431
+ source_table = self._instance.get_table_name()
432
+ source_id = self._instance.get_id()
433
+
434
+ if self._relation_info.relation_type == "relation":
435
+ edge = self._relation_info.edge_table
436
+ if self._relation_info.reverse:
437
+ # Delete all edges pointing to this record
438
+ query = f"DELETE {edge} WHERE out = {source_table}:{source_id};"
439
+ else:
440
+ # Delete all edges from this record
441
+ query = f"DELETE {edge} WHERE in = {source_table}:{source_id};"
442
+ await client.query(query)
443
+ elif self._relation_info.relation_type == "many_to_many":
444
+ through = self._relation_info.through or f"{source_table}_"
445
+ query = f"DELETE {through} WHERE in = {source_table}:{source_id};"
446
+ await client.query(query)
447
+
448
+ # Invalidate cache
449
+ self._cache = None
450
+
451
+ # ==================== Queries ====================
452
+
453
+ async def all(self) -> list[Any]:
454
+ """
455
+ Get all related objects.
456
+
457
+ Returns:
458
+ List of related model instances
459
+ """
460
+ if self._cache is not None:
461
+ return self._cache
462
+
463
+ qs = RelationQuerySet(self._instance, self._relation_info)
464
+ results = await qs.all()
465
+ self._cache = results
466
+ return results
467
+
468
+ def filter(self, **kwargs: Any) -> RelationQuerySet:
469
+ """
470
+ Filter related objects.
471
+
472
+ Returns a RelationQuerySet that can be further filtered or traversed.
473
+
474
+ Args:
475
+ **kwargs: Filter conditions
476
+
477
+ Returns:
478
+ RelationQuerySet for chaining
479
+
480
+ Example:
481
+ active_followers = await alice.followers.filter(active=True)
482
+ """
483
+ qs = RelationQuerySet(self._instance, self._relation_info)
484
+ return qs.filter(**kwargs)
485
+
486
+ async def count(self) -> int:
487
+ """
488
+ Count related objects.
489
+
490
+ Returns:
491
+ Number of related records
492
+ """
493
+ results = await self.all()
494
+ return len(results)
495
+
496
+ async def contains(self, obj: "BaseSurrealModel") -> bool:
497
+ """
498
+ Check if an object is in this relation.
499
+
500
+ Args:
501
+ obj: Model instance to check
502
+
503
+ Returns:
504
+ True if the object is related
505
+ """
506
+ if not obj.get_id():
507
+ return False
508
+
509
+ results = await self.all()
510
+ target_id = obj.get_id()
511
+
512
+ for related in results:
513
+ if hasattr(related, "get_id") and related.get_id() == target_id:
514
+ return True
515
+
516
+ return False
517
+
518
+ async def first(self) -> Any | None:
519
+ """
520
+ Get the first related object.
521
+
522
+ Returns:
523
+ First related model instance or None
524
+ """
525
+ results = await self.all()
526
+ return results[0] if results else None
527
+
528
+ async def exists(self) -> bool:
529
+ """
530
+ Check if any related objects exist.
531
+
532
+ Returns:
533
+ True if there are any related records
534
+ """
535
+ count = await self.count()
536
+ return count > 0
537
+
538
+ # ==================== Chained Traversal ====================
539
+
540
+ def __getattr__(self, name: str) -> Any:
541
+ """
542
+ Enable chained traversal via attribute access.
543
+
544
+ Example:
545
+ friends_of_friends = await alice.following.following.all()
546
+ """
547
+ qs = RelationQuerySet(self._instance, self._relation_info)
548
+ return getattr(qs, name)
549
+
550
+
551
+ class RelationDescriptor:
552
+ """
553
+ Descriptor for transparent relation access on models.
554
+
555
+ This descriptor is automatically applied to relation fields,
556
+ enabling access like `user.followers` to return a RelationManager.
557
+
558
+ Example:
559
+ class User(BaseSurrealModel):
560
+ followers: Relation("follows", "User", reverse=True)
561
+
562
+ # Access returns RelationManager
563
+ manager = user.followers
564
+ followers = await manager.all()
565
+ """
566
+
567
+ def __init__(self, field_name: str, relation_info: RelationInfo):
568
+ """
569
+ Initialize the descriptor.
570
+
571
+ Args:
572
+ field_name: Name of the relation field
573
+ relation_info: Metadata about the relation
574
+ """
575
+ self.field_name = field_name
576
+ self.relation_info = relation_info
577
+ self._cache_attr = f"_relation_cache_{field_name}"
578
+
579
+ def __get__(
580
+ self,
581
+ obj: "BaseSurrealModel | None",
582
+ objtype: type["BaseSurrealModel"] | None = None,
583
+ ) -> "RelationManager | RelationDescriptor":
584
+ """
585
+ Get the RelationManager for this field.
586
+
587
+ Args:
588
+ obj: Model instance (None if accessed on class)
589
+ objtype: Model class
590
+
591
+ Returns:
592
+ RelationManager if accessed on instance, self if on class
593
+ """
594
+ if obj is None:
595
+ # Accessed on class, return descriptor
596
+ return self
597
+
598
+ # Check for cached manager
599
+ if not hasattr(obj, self._cache_attr):
600
+ manager = RelationManager(obj, self.relation_info, self.field_name)
601
+ setattr(obj, self._cache_attr, manager)
602
+
603
+ cached_manager: RelationManager = getattr(obj, self._cache_attr)
604
+ return cached_manager
605
+
606
+ def __set__(
607
+ self,
608
+ obj: "BaseSurrealModel",
609
+ value: Any,
610
+ ) -> None:
611
+ """
612
+ Setting relation values directly is not supported.
613
+
614
+ Use the RelationManager methods (add, remove, set) instead.
615
+ """
616
+ raise AttributeError(
617
+ f"Cannot set '{self.field_name}' directly. Use await {obj.__class__.__name__}.{self.field_name}.set([...]) instead."
618
+ )
619
+
620
+
621
+ def get_related_objects(
622
+ instance: "BaseSurrealModel",
623
+ relation_name: str,
624
+ direction: Literal["out", "in", "both"] = "out",
625
+ ) -> RelationQuerySet:
626
+ """
627
+ Get related objects through a relation.
628
+
629
+ This is a utility function for programmatic access to relations.
630
+
631
+ Args:
632
+ instance: Source model instance
633
+ relation_name: Name of the edge table
634
+ direction: Traversal direction ("out", "in", or "both")
635
+
636
+ Returns:
637
+ RelationQuerySet for the relation
638
+ """
639
+ rel_info = RelationInfo(
640
+ to_model="", # Will be determined by query results
641
+ relation_type="relation",
642
+ edge_table=relation_name,
643
+ reverse=(direction == "in"),
644
+ )
645
+ return RelationQuerySet(instance, rel_info)