surrealdb-orm 0.1.3__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.
- surreal_orm/__init__.py +78 -3
- surreal_orm/aggregations.py +164 -0
- surreal_orm/auth/__init__.py +15 -0
- surreal_orm/auth/access.py +167 -0
- surreal_orm/auth/mixins.py +302 -0
- surreal_orm/cli/__init__.py +15 -0
- surreal_orm/cli/commands.py +369 -0
- surreal_orm/connection_manager.py +58 -18
- surreal_orm/fields/__init__.py +36 -0
- surreal_orm/fields/encrypted.py +166 -0
- surreal_orm/fields/relation.py +465 -0
- surreal_orm/migrations/__init__.py +51 -0
- surreal_orm/migrations/executor.py +380 -0
- surreal_orm/migrations/generator.py +272 -0
- surreal_orm/migrations/introspector.py +305 -0
- surreal_orm/migrations/migration.py +188 -0
- surreal_orm/migrations/operations.py +531 -0
- surreal_orm/migrations/state.py +406 -0
- surreal_orm/model_base.py +594 -135
- surreal_orm/py.typed +0 -0
- surreal_orm/query_set.py +609 -34
- surreal_orm/relations.py +645 -0
- surreal_orm/surreal_function.py +95 -0
- surreal_orm/surreal_ql.py +113 -0
- surreal_orm/types.py +86 -0
- surreal_sdk/README.md +79 -0
- surreal_sdk/__init__.py +151 -0
- surreal_sdk/connection/__init__.py +17 -0
- surreal_sdk/connection/base.py +516 -0
- surreal_sdk/connection/http.py +421 -0
- surreal_sdk/connection/pool.py +244 -0
- surreal_sdk/connection/websocket.py +519 -0
- surreal_sdk/exceptions.py +71 -0
- surreal_sdk/functions.py +607 -0
- surreal_sdk/protocol/__init__.py +13 -0
- surreal_sdk/protocol/rpc.py +218 -0
- surreal_sdk/py.typed +0 -0
- surreal_sdk/pyproject.toml +49 -0
- surreal_sdk/streaming/__init__.py +31 -0
- surreal_sdk/streaming/change_feed.py +278 -0
- surreal_sdk/streaming/live_query.py +265 -0
- surreal_sdk/streaming/live_select.py +369 -0
- surreal_sdk/transaction.py +386 -0
- surreal_sdk/types.py +346 -0
- surrealdb_orm-0.5.0.dist-info/METADATA +465 -0
- surrealdb_orm-0.5.0.dist-info/RECORD +52 -0
- {surrealdb_orm-0.1.3.dist-info → surrealdb_orm-0.5.0.dist-info}/WHEEL +1 -1
- surrealdb_orm-0.5.0.dist-info/entry_points.txt +2 -0
- {surrealdb_orm-0.1.3.dist-info → surrealdb_orm-0.5.0.dist-info}/licenses/LICENSE +1 -1
- surrealdb_orm-0.1.3.dist-info/METADATA +0 -184
- surrealdb_orm-0.1.3.dist-info/RECORD +0 -11
surreal_orm/relations.py
ADDED
|
@@ -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)
|