sqlobjects 0.1.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.
- sqlobjects/__init__.py +38 -0
- sqlobjects/config.py +519 -0
- sqlobjects/database.py +586 -0
- sqlobjects/exceptions.py +538 -0
- sqlobjects/expressions.py +1054 -0
- sqlobjects/fields.py +1866 -0
- sqlobjects/history.py +101 -0
- sqlobjects/metadata.py +1130 -0
- sqlobjects/model.py +1009 -0
- sqlobjects/objects.py +812 -0
- sqlobjects/queries.py +1059 -0
- sqlobjects/relations.py +843 -0
- sqlobjects/session.py +389 -0
- sqlobjects/signals.py +464 -0
- sqlobjects/utils/__init__.py +5 -0
- sqlobjects/utils/naming.py +53 -0
- sqlobjects/utils/pattern.py +644 -0
- sqlobjects/validators.py +294 -0
- sqlobjects-0.1.0.dist-info/METADATA +29 -0
- sqlobjects-0.1.0.dist-info/RECORD +23 -0
- sqlobjects-0.1.0.dist-info/WHEEL +5 -0
- sqlobjects-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlobjects-0.1.0.dist-info/top_level.txt +1 -0
sqlobjects/relations.py
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
"""SQLObjects relationship field system - unified relationship interface implementation"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column, ForeignKey, Table, select
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .model import ObjectModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class M2MTable:
|
|
15
|
+
"""Many-to-Many table definition with flexible field mapping.
|
|
16
|
+
|
|
17
|
+
Supports custom field names and non-primary key references for complex scenarios.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
table_name: str
|
|
21
|
+
left_model: str
|
|
22
|
+
right_model: str
|
|
23
|
+
left_field: str | None = None # M2M table left foreign key field name
|
|
24
|
+
right_field: str | None = None # M2M table right foreign key field name
|
|
25
|
+
left_ref_field: str | None = None # Left model reference field name
|
|
26
|
+
right_ref_field: str | None = None # Right model reference field name
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
"""Fill default field names if not provided."""
|
|
30
|
+
if self.left_field is None:
|
|
31
|
+
self.left_field = f"{self.left_model.lower()}_id"
|
|
32
|
+
if self.right_field is None:
|
|
33
|
+
self.right_field = f"{self.right_model.lower()}_id"
|
|
34
|
+
if self.left_ref_field is None:
|
|
35
|
+
self.left_ref_field = "id"
|
|
36
|
+
if self.right_ref_field is None:
|
|
37
|
+
self.right_ref_field = "id"
|
|
38
|
+
|
|
39
|
+
def create_table(self, metadata: Any, left_table: Any, right_table: Any) -> Table:
|
|
40
|
+
"""Create SQLAlchemy Table for this M2M relationship.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
metadata: SQLAlchemy MetaData instance
|
|
44
|
+
left_table: Left model's table
|
|
45
|
+
right_table: Right model's table
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
SQLAlchemy Table instance for the M2M relationship
|
|
49
|
+
"""
|
|
50
|
+
# Get reference columns
|
|
51
|
+
left_ref_col = left_table.c[self.left_ref_field]
|
|
52
|
+
right_ref_col = right_table.c[self.right_ref_field]
|
|
53
|
+
|
|
54
|
+
return Table(
|
|
55
|
+
self.table_name,
|
|
56
|
+
metadata,
|
|
57
|
+
Column(
|
|
58
|
+
self.left_field,
|
|
59
|
+
left_ref_col.type,
|
|
60
|
+
ForeignKey(f"{left_table.name}.{self.left_ref_field}"),
|
|
61
|
+
primary_key=True,
|
|
62
|
+
),
|
|
63
|
+
Column(
|
|
64
|
+
self.right_field,
|
|
65
|
+
right_ref_col.type,
|
|
66
|
+
ForeignKey(f"{right_table.name}.{self.right_ref_field}"),
|
|
67
|
+
primary_key=True,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"M2MTable",
|
|
74
|
+
"RelationshipType",
|
|
75
|
+
"RelationshipResolver",
|
|
76
|
+
"RelationshipProperty",
|
|
77
|
+
"RelationshipDescriptor",
|
|
78
|
+
"RelatedObjectProxy",
|
|
79
|
+
"BaseRelatedCollection",
|
|
80
|
+
"OneToManyCollection",
|
|
81
|
+
"M2MCollectionMixin",
|
|
82
|
+
"M2MRelatedCollection",
|
|
83
|
+
"RelatedCollection", # Backward compatibility alias
|
|
84
|
+
"RelatedQuerySet",
|
|
85
|
+
"NoLoadProxy",
|
|
86
|
+
"RaiseProxy",
|
|
87
|
+
"relationship",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RelationshipType:
|
|
92
|
+
"""Relationship type enumeration."""
|
|
93
|
+
|
|
94
|
+
MANY_TO_ONE = "many_to_one"
|
|
95
|
+
ONE_TO_MANY = "one_to_many"
|
|
96
|
+
ONE_TO_ONE = "one_to_one"
|
|
97
|
+
MANY_TO_MANY = "many_to_many"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RelationshipProperty:
|
|
101
|
+
"""Relationship property configuration and metadata."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
argument: str | type["ObjectModel"],
|
|
106
|
+
foreign_keys: str | list[str] | None = None,
|
|
107
|
+
back_populates: str | None = None,
|
|
108
|
+
backref: str | None = None,
|
|
109
|
+
lazy: str = "select",
|
|
110
|
+
uselist: bool | None = None,
|
|
111
|
+
secondary: str | None = None,
|
|
112
|
+
primaryjoin: str | None = None,
|
|
113
|
+
secondaryjoin: str | None = None,
|
|
114
|
+
order_by: str | list[str] | None = None,
|
|
115
|
+
cascade: str | None = None,
|
|
116
|
+
passive_deletes: bool = False,
|
|
117
|
+
**kwargs,
|
|
118
|
+
):
|
|
119
|
+
"""Initialize relationship property.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
argument: Target model class or string name
|
|
123
|
+
foreign_keys: Foreign key field name(s)
|
|
124
|
+
back_populates: Name of reverse relationship attribute
|
|
125
|
+
backref: Name for automatic reverse relationship
|
|
126
|
+
lazy: Loading strategy ('select', 'dynamic', 'noload', 'raise')
|
|
127
|
+
uselist: Whether relationship returns a list
|
|
128
|
+
secondary: M2M table name
|
|
129
|
+
primaryjoin: Custom primary join condition
|
|
130
|
+
secondaryjoin: Custom secondary join condition for M2M
|
|
131
|
+
order_by: Default ordering for collections
|
|
132
|
+
cascade: Cascade options
|
|
133
|
+
passive_deletes: Whether to use passive deletes
|
|
134
|
+
**kwargs: Additional relationship options
|
|
135
|
+
"""
|
|
136
|
+
self.argument = argument
|
|
137
|
+
self.foreign_keys = foreign_keys
|
|
138
|
+
self.back_populates = back_populates
|
|
139
|
+
self.backref = backref
|
|
140
|
+
self.lazy = lazy
|
|
141
|
+
self.uselist = uselist
|
|
142
|
+
self.secondary = secondary
|
|
143
|
+
self.m2m_definition: M2MTable | None = None # M2M table definition
|
|
144
|
+
self.primaryjoin = primaryjoin
|
|
145
|
+
self.secondaryjoin = secondaryjoin
|
|
146
|
+
self.order_by = order_by
|
|
147
|
+
self.cascade = cascade
|
|
148
|
+
self.passive_deletes = passive_deletes
|
|
149
|
+
self.name: str | None = None
|
|
150
|
+
self.resolved_model: type[ObjectModel] | None = None
|
|
151
|
+
self.relationship_type: str | None = None
|
|
152
|
+
self.is_many_to_many: bool = False # M2M relationship flag
|
|
153
|
+
|
|
154
|
+
# Store additional relationship configuration parameters
|
|
155
|
+
self.extra_kwargs = kwargs
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RelationshipResolver:
|
|
159
|
+
"""Relationship type resolver."""
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def resolve_relationship_type(property_: RelationshipProperty) -> str:
|
|
163
|
+
"""Automatically infer relationship type based on parameters.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
property_: RelationshipProperty instance to analyze
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
String representing the relationship type
|
|
170
|
+
"""
|
|
171
|
+
if property_.uselist is False:
|
|
172
|
+
return RelationshipType.MANY_TO_ONE if property_.foreign_keys else RelationshipType.ONE_TO_ONE
|
|
173
|
+
elif property_.uselist is True: # noqa
|
|
174
|
+
return RelationshipType.MANY_TO_MANY if property_.secondary else RelationshipType.ONE_TO_MANY
|
|
175
|
+
|
|
176
|
+
if property_.secondary:
|
|
177
|
+
property_.is_many_to_many = True
|
|
178
|
+
return RelationshipType.MANY_TO_MANY
|
|
179
|
+
elif property_.foreign_keys:
|
|
180
|
+
return RelationshipType.MANY_TO_ONE
|
|
181
|
+
else:
|
|
182
|
+
return RelationshipType.ONE_TO_MANY
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RelatedObjectProxy:
|
|
186
|
+
"""Proxy for single related object."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, instance: "ObjectModel", descriptor: "RelationshipDescriptor"):
|
|
189
|
+
"""Initialize related object proxy.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
instance: Parent model instance
|
|
193
|
+
descriptor: Relationship descriptor
|
|
194
|
+
"""
|
|
195
|
+
self.instance = instance
|
|
196
|
+
self.descriptor = descriptor
|
|
197
|
+
self.property = descriptor.property
|
|
198
|
+
self._cached_object = None
|
|
199
|
+
self._loaded = False
|
|
200
|
+
|
|
201
|
+
async def get(self):
|
|
202
|
+
"""Get the related object.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Related object instance or None
|
|
206
|
+
"""
|
|
207
|
+
if not self._loaded:
|
|
208
|
+
await self._load()
|
|
209
|
+
return self._cached_object
|
|
210
|
+
|
|
211
|
+
def __await__(self):
|
|
212
|
+
"""Support await syntax."""
|
|
213
|
+
return self.get().__await__()
|
|
214
|
+
|
|
215
|
+
async def _load(self):
|
|
216
|
+
"""Load related object from database."""
|
|
217
|
+
if self.property.foreign_keys and self.property.resolved_model:
|
|
218
|
+
# Handle foreign_keys as string or list
|
|
219
|
+
fk_field = self.property.foreign_keys
|
|
220
|
+
if isinstance(fk_field, list):
|
|
221
|
+
fk_field = fk_field[0] # Use first foreign key
|
|
222
|
+
|
|
223
|
+
fk_value = getattr(self.instance, fk_field)
|
|
224
|
+
if fk_value is not None:
|
|
225
|
+
related_table = self.property.resolved_model.get_table()
|
|
226
|
+
pk_col = list(related_table.primary_key.columns)[0]
|
|
227
|
+
|
|
228
|
+
query = select(related_table).where(pk_col == fk_value) # noqa
|
|
229
|
+
session = self.instance._get_session() # noqa
|
|
230
|
+
result = await session.execute(query)
|
|
231
|
+
row = result.first()
|
|
232
|
+
|
|
233
|
+
if row:
|
|
234
|
+
self._cached_object = self.property.resolved_model(**dict(row._mapping)) # noqa
|
|
235
|
+
|
|
236
|
+
self._loaded = True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class BaseRelatedCollection:
|
|
240
|
+
"""Base class for related object collections."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, instance: "ObjectModel", descriptor: "RelationshipDescriptor"):
|
|
243
|
+
"""Initialize related collection.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
instance: Parent model instance
|
|
247
|
+
descriptor: Relationship descriptor
|
|
248
|
+
"""
|
|
249
|
+
self.instance = instance
|
|
250
|
+
self.descriptor = descriptor
|
|
251
|
+
self.property = descriptor.property
|
|
252
|
+
self._cached_objects = None
|
|
253
|
+
self._loaded = False
|
|
254
|
+
|
|
255
|
+
async def all(self):
|
|
256
|
+
"""Get all related objects.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of related object instances
|
|
260
|
+
"""
|
|
261
|
+
if not self._loaded:
|
|
262
|
+
await self._load()
|
|
263
|
+
return self._cached_objects or []
|
|
264
|
+
|
|
265
|
+
def __await__(self):
|
|
266
|
+
"""Support await syntax."""
|
|
267
|
+
return self.all().__await__()
|
|
268
|
+
|
|
269
|
+
async def _load(self):
|
|
270
|
+
"""Load related object list from database - implemented by subclasses."""
|
|
271
|
+
raise NotImplementedError("Subclasses must implement _load method")
|
|
272
|
+
|
|
273
|
+
def _set_empty_result(self):
|
|
274
|
+
"""Common method to set empty result."""
|
|
275
|
+
self._cached_objects = []
|
|
276
|
+
self._loaded = True
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class OneToManyCollection(BaseRelatedCollection):
|
|
280
|
+
"""One-to-many related object collection."""
|
|
281
|
+
|
|
282
|
+
async def _load(self):
|
|
283
|
+
"""Load one-to-many relationship."""
|
|
284
|
+
if not self.property.resolved_model:
|
|
285
|
+
self._set_empty_result()
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
instance_pk = self.instance.id
|
|
289
|
+
related_table = self.property.resolved_model.get_table()
|
|
290
|
+
|
|
291
|
+
# Handle foreign_keys as string or list
|
|
292
|
+
fk_name = self.property.foreign_keys
|
|
293
|
+
if isinstance(fk_name, list):
|
|
294
|
+
fk_name = fk_name[0] # Use first foreign key
|
|
295
|
+
elif fk_name is None:
|
|
296
|
+
fk_name = f"{self.instance.__class__.__name__.lower()}_id"
|
|
297
|
+
|
|
298
|
+
fk_col = related_table.c[fk_name]
|
|
299
|
+
|
|
300
|
+
query = select(related_table).where(fk_col == instance_pk) # noqa
|
|
301
|
+
session = self.instance._get_session() # noqa
|
|
302
|
+
result = await session.execute(query)
|
|
303
|
+
|
|
304
|
+
self._cached_objects = [self.property.resolved_model(**dict(row._mapping)) for row in result] # noqa
|
|
305
|
+
self._loaded = True
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class M2MCollectionMixin:
|
|
309
|
+
"""Mixin class for M2M collection functionality."""
|
|
310
|
+
|
|
311
|
+
# Type hints for mixin attributes
|
|
312
|
+
instance: "ObjectModel"
|
|
313
|
+
property: RelationshipProperty
|
|
314
|
+
|
|
315
|
+
def _load_m2m_data(self) -> tuple[M2MTable | None, Any | None, Any | None, Any | None]:
|
|
316
|
+
"""Load M2M basic data.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Tuple of (m2m_def, registry, m2m_table, instance_id)
|
|
320
|
+
"""
|
|
321
|
+
m2m_def = self.property.m2m_definition
|
|
322
|
+
if not m2m_def:
|
|
323
|
+
return None, None, None, None
|
|
324
|
+
|
|
325
|
+
registry = getattr(self.instance.__class__, "__registry__", None)
|
|
326
|
+
if not registry:
|
|
327
|
+
return None, None, None, None
|
|
328
|
+
|
|
329
|
+
m2m_table = registry.get_m2m_table(m2m_def.table_name)
|
|
330
|
+
if not m2m_table:
|
|
331
|
+
return None, None, None, None
|
|
332
|
+
|
|
333
|
+
if not m2m_def.left_ref_field:
|
|
334
|
+
return None, None, None, None
|
|
335
|
+
|
|
336
|
+
instance_id = getattr(self.instance, m2m_def.left_ref_field)
|
|
337
|
+
if instance_id is None:
|
|
338
|
+
return None, None, None, None
|
|
339
|
+
|
|
340
|
+
return m2m_def, registry, m2m_table, instance_id
|
|
341
|
+
|
|
342
|
+
def _build_m2m_query(self, m2m_def: M2MTable, m2m_table: Any, instance_id: Any) -> Any:
|
|
343
|
+
"""Build M2M query.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
m2m_def: M2M table definition
|
|
347
|
+
m2m_table: M2M table instance
|
|
348
|
+
instance_id: Current instance ID
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
SQLAlchemy query or None
|
|
352
|
+
"""
|
|
353
|
+
if not self.property.resolved_model:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
related_table = self.property.resolved_model.get_table()
|
|
357
|
+
|
|
358
|
+
from sqlalchemy import join
|
|
359
|
+
|
|
360
|
+
if not (m2m_def.right_field and m2m_def.right_ref_field and m2m_def.left_field):
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
joined_tables = join(
|
|
364
|
+
m2m_table,
|
|
365
|
+
related_table,
|
|
366
|
+
getattr(m2m_table.c, m2m_def.right_field) == getattr(related_table.c, m2m_def.right_ref_field), # noqa
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
select(related_table)
|
|
371
|
+
.select_from(joined_tables)
|
|
372
|
+
.where(getattr(m2m_table.c, m2m_def.left_field) == instance_id) # noqa
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class M2MRelatedCollection(BaseRelatedCollection, M2MCollectionMixin):
|
|
377
|
+
"""Many-to-many related object collection."""
|
|
378
|
+
|
|
379
|
+
async def _load(self) -> None:
|
|
380
|
+
"""Load M2M related object list from database."""
|
|
381
|
+
m2m_def, registry, m2m_table, instance_id = self._load_m2m_data()
|
|
382
|
+
if not m2m_def or not registry or not m2m_table or instance_id is None:
|
|
383
|
+
self._set_empty_result()
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
query = self._build_m2m_query(m2m_def, m2m_table, instance_id)
|
|
387
|
+
if not query:
|
|
388
|
+
self._set_empty_result()
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
session = self.instance._get_session() # noqa
|
|
392
|
+
result = await session.execute(query)
|
|
393
|
+
|
|
394
|
+
if self.property.resolved_model:
|
|
395
|
+
self._cached_objects = [self.property.resolved_model(**dict(row._mapping)) for row in result] # noqa
|
|
396
|
+
else:
|
|
397
|
+
self._cached_objects = []
|
|
398
|
+
self._loaded = True
|
|
399
|
+
|
|
400
|
+
async def add(self, *objects: "ObjectModel") -> None:
|
|
401
|
+
"""Add M2M relationships.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
*objects: Objects to add to the relationship
|
|
405
|
+
"""
|
|
406
|
+
m2m_def, registry, m2m_table, instance_id = self._load_m2m_data()
|
|
407
|
+
if not m2m_def or not registry or not m2m_table or instance_id is None:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
from sqlalchemy import insert
|
|
411
|
+
|
|
412
|
+
session = self.instance._get_session(readonly=False) # noqa
|
|
413
|
+
|
|
414
|
+
if not (m2m_def.right_ref_field and m2m_def.left_field and m2m_def.right_field):
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
for obj in objects:
|
|
418
|
+
related_id = getattr(obj, m2m_def.right_ref_field)
|
|
419
|
+
if related_id is not None:
|
|
420
|
+
stmt = insert(m2m_table).values({m2m_def.left_field: instance_id, m2m_def.right_field: related_id})
|
|
421
|
+
await session.execute(stmt)
|
|
422
|
+
|
|
423
|
+
# Clear cache
|
|
424
|
+
self._loaded = False
|
|
425
|
+
self._cached_objects = None
|
|
426
|
+
|
|
427
|
+
async def remove(self, *objects: "ObjectModel") -> None:
|
|
428
|
+
"""Remove M2M relationships.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
*objects: Objects to remove from the relationship
|
|
432
|
+
"""
|
|
433
|
+
m2m_def, registry, m2m_table, instance_id = self._load_m2m_data()
|
|
434
|
+
if not m2m_def or not registry or not m2m_table or instance_id is None:
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
from sqlalchemy import and_, delete
|
|
438
|
+
|
|
439
|
+
session = self.instance._get_session(readonly=False) # noqa
|
|
440
|
+
|
|
441
|
+
if not (m2m_def.right_ref_field and m2m_def.left_field and m2m_def.right_field):
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
for obj in objects:
|
|
445
|
+
related_id = getattr(obj, m2m_def.right_ref_field)
|
|
446
|
+
if related_id is not None:
|
|
447
|
+
stmt = delete(m2m_table).where(
|
|
448
|
+
and_(
|
|
449
|
+
getattr(m2m_table.c, m2m_def.left_field) == instance_id,
|
|
450
|
+
getattr(m2m_table.c, m2m_def.right_field) == related_id,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
await session.execute(stmt)
|
|
454
|
+
|
|
455
|
+
# Clear cache
|
|
456
|
+
self._loaded = False
|
|
457
|
+
self._cached_objects = None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class RelatedQuerySet:
|
|
461
|
+
"""Related query set - inherits full QuerySet functionality (lazy='dynamic')."""
|
|
462
|
+
|
|
463
|
+
def __init__(self, instance: "ObjectModel", descriptor: "RelationshipDescriptor"):
|
|
464
|
+
"""Initialize related query set.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
instance: Parent model instance
|
|
468
|
+
descriptor: Relationship descriptor
|
|
469
|
+
"""
|
|
470
|
+
self.parent_instance = instance
|
|
471
|
+
self.relationship_desc = descriptor
|
|
472
|
+
self._queryset: Any = None
|
|
473
|
+
self._initialized = False
|
|
474
|
+
|
|
475
|
+
def _get_queryset(self) -> Any:
|
|
476
|
+
"""Lazy initialize QuerySet.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Initialized QuerySet instance
|
|
480
|
+
"""
|
|
481
|
+
if not self._initialized:
|
|
482
|
+
from .queries import QuerySet
|
|
483
|
+
|
|
484
|
+
if not self.relationship_desc.property.resolved_model:
|
|
485
|
+
raise ValueError(f"Relationship '{self.relationship_desc.name}' model not resolved")
|
|
486
|
+
|
|
487
|
+
related_model = self.relationship_desc.property.resolved_model
|
|
488
|
+
related_table = related_model.get_table()
|
|
489
|
+
|
|
490
|
+
# Create base QuerySet
|
|
491
|
+
self._queryset = QuerySet(related_table, related_model)
|
|
492
|
+
|
|
493
|
+
# Automatically add relationship filter conditions
|
|
494
|
+
self._apply_relationship_filter()
|
|
495
|
+
self._initialized = True
|
|
496
|
+
|
|
497
|
+
return self._queryset
|
|
498
|
+
|
|
499
|
+
def _apply_relationship_filter(self) -> None:
|
|
500
|
+
"""Automatically add relationship filter conditions."""
|
|
501
|
+
if not self._queryset:
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
relationship_type = RelationshipResolver.resolve_relationship_type(self.relationship_desc.property)
|
|
505
|
+
|
|
506
|
+
if relationship_type == RelationshipType.ONE_TO_MANY:
|
|
507
|
+
fk_name = self._get_foreign_key_name()
|
|
508
|
+
fk_col = self._queryset._table.c[fk_name] # noqa
|
|
509
|
+
self._queryset = self._queryset.filter(fk_col == self.parent_instance.id)
|
|
510
|
+
elif relationship_type == RelationshipType.MANY_TO_MANY:
|
|
511
|
+
self._apply_m2m_filter()
|
|
512
|
+
|
|
513
|
+
def _get_foreign_key_name(self) -> str:
|
|
514
|
+
"""Get foreign key field name.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Foreign key field name
|
|
518
|
+
"""
|
|
519
|
+
fk_name = self.relationship_desc.property.foreign_keys
|
|
520
|
+
if isinstance(fk_name, list):
|
|
521
|
+
return fk_name[0]
|
|
522
|
+
elif fk_name is None:
|
|
523
|
+
return f"{self.parent_instance.__class__.__name__.lower()}_id"
|
|
524
|
+
return fk_name
|
|
525
|
+
|
|
526
|
+
def _apply_m2m_filter(self) -> None:
|
|
527
|
+
"""Apply many-to-many relationship filtering."""
|
|
528
|
+
if not self._queryset:
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
m2m_def = self.relationship_desc.property.m2m_definition
|
|
532
|
+
if not m2m_def:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
# Get M2M table
|
|
536
|
+
registry = getattr(self.parent_instance.__class__, "__registry__", None)
|
|
537
|
+
if not registry:
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
m2m_table = registry.get_m2m_table(m2m_def.table_name)
|
|
541
|
+
if not m2m_table:
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
# Build M2M subquery
|
|
545
|
+
from sqlalchemy import select
|
|
546
|
+
|
|
547
|
+
if not (m2m_def.left_field and m2m_def.right_field and m2m_def.left_ref_field and m2m_def.right_ref_field):
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
instance_id = getattr(self.parent_instance, m2m_def.left_ref_field)
|
|
551
|
+
if instance_id is None:
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
# Subquery to get related IDs
|
|
555
|
+
subquery = select(getattr(m2m_table.c, m2m_def.right_field)).where(
|
|
556
|
+
getattr(m2m_table.c, m2m_def.left_field) == instance_id # noqa
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Apply filter
|
|
560
|
+
related_pk_col = getattr(self._queryset._table.c, m2m_def.right_ref_field) # noqa
|
|
561
|
+
self._queryset = self._queryset.filter(related_pk_col.in_(subquery))
|
|
562
|
+
|
|
563
|
+
# Proxy all QuerySet methods
|
|
564
|
+
def __getattr__(self, name: str) -> Any:
|
|
565
|
+
"""Proxy all QuerySet methods.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
name: Method name to proxy
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
Proxied method or attribute
|
|
572
|
+
"""
|
|
573
|
+
qs = self._get_queryset()
|
|
574
|
+
attr = getattr(qs, name)
|
|
575
|
+
|
|
576
|
+
# If it's a method that returns a new QuerySet, need to wrap the return value
|
|
577
|
+
if callable(attr) and name in {
|
|
578
|
+
"filter",
|
|
579
|
+
"exclude",
|
|
580
|
+
"order_by",
|
|
581
|
+
"limit",
|
|
582
|
+
"offset",
|
|
583
|
+
"distinct",
|
|
584
|
+
"only",
|
|
585
|
+
"defer",
|
|
586
|
+
"select_related",
|
|
587
|
+
"prefetch_related",
|
|
588
|
+
"annotate",
|
|
589
|
+
"group_by",
|
|
590
|
+
"having",
|
|
591
|
+
"join",
|
|
592
|
+
"leftjoin",
|
|
593
|
+
"outerjoin",
|
|
594
|
+
"select_for_update",
|
|
595
|
+
"select_for_share",
|
|
596
|
+
"extra",
|
|
597
|
+
"none",
|
|
598
|
+
"reverse",
|
|
599
|
+
"options",
|
|
600
|
+
"skip_default_ordering",
|
|
601
|
+
}:
|
|
602
|
+
|
|
603
|
+
def wrapper(*args: Any, **kwargs: Any) -> "RelatedQuerySet":
|
|
604
|
+
new_qs = attr(*args, **kwargs)
|
|
605
|
+
# Create new RelatedQuerySet instance
|
|
606
|
+
related_qs = RelatedQuerySet(self.parent_instance, self.relationship_desc)
|
|
607
|
+
related_qs._queryset = new_qs
|
|
608
|
+
related_qs._initialized = True
|
|
609
|
+
return related_qs
|
|
610
|
+
|
|
611
|
+
return wrapper
|
|
612
|
+
|
|
613
|
+
return attr
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class NoLoadProxy:
|
|
617
|
+
"""No-load proxy (lazy='noload')."""
|
|
618
|
+
|
|
619
|
+
def __init__(self, instance: "ObjectModel", descriptor: "RelationshipDescriptor"):
|
|
620
|
+
"""Initialize no-load proxy.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
instance: Parent model instance
|
|
624
|
+
descriptor: Relationship descriptor
|
|
625
|
+
"""
|
|
626
|
+
self.instance = instance
|
|
627
|
+
self.descriptor = descriptor
|
|
628
|
+
self.property = descriptor.property
|
|
629
|
+
|
|
630
|
+
def __await__(self) -> Any:
|
|
631
|
+
"""Async access returns empty result."""
|
|
632
|
+
return self._empty_result().__await__()
|
|
633
|
+
|
|
634
|
+
async def _empty_result(self) -> list[Any] | None:
|
|
635
|
+
"""Return empty result.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Empty list for collections, None for single objects
|
|
639
|
+
"""
|
|
640
|
+
return [] if self.property.uselist else None
|
|
641
|
+
|
|
642
|
+
def __iter__(self) -> Any:
|
|
643
|
+
"""Iterator returns empty."""
|
|
644
|
+
return iter([])
|
|
645
|
+
|
|
646
|
+
def __len__(self) -> int:
|
|
647
|
+
"""Length is 0."""
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
def __bool__(self) -> bool:
|
|
651
|
+
"""Boolean value is False."""
|
|
652
|
+
return False
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class RaiseProxy:
|
|
656
|
+
"""Raise exception proxy (lazy='raise')."""
|
|
657
|
+
|
|
658
|
+
def __init__(self, instance: "ObjectModel", descriptor: "RelationshipDescriptor"):
|
|
659
|
+
"""Initialize raise proxy.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
instance: Parent model instance
|
|
663
|
+
descriptor: Relationship descriptor
|
|
664
|
+
"""
|
|
665
|
+
self.instance = instance
|
|
666
|
+
self.descriptor = descriptor
|
|
667
|
+
self.property = descriptor.property
|
|
668
|
+
|
|
669
|
+
def __await__(self) -> Any:
|
|
670
|
+
"""Async access raises exception."""
|
|
671
|
+
raise AttributeError(
|
|
672
|
+
f"Relationship '{self.property.name}' is configured with lazy='raise'. "
|
|
673
|
+
f"Use explicit loading with select_related() or prefetch_related()."
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
def __iter__(self) -> Any:
|
|
677
|
+
"""Iterator access raises exception."""
|
|
678
|
+
raise AttributeError(
|
|
679
|
+
f"Relationship '{self.property.name}' is configured with lazy='raise'. "
|
|
680
|
+
f"Use explicit loading with select_related() or prefetch_related()."
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def __len__(self) -> int:
|
|
684
|
+
"""Length access raises exception."""
|
|
685
|
+
raise AttributeError(
|
|
686
|
+
f"Relationship '{self.property.name}' is configured with lazy='raise'. "
|
|
687
|
+
f"Use explicit loading with select_related() or prefetch_related()."
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def __bool__(self) -> bool:
|
|
691
|
+
"""Boolean access raises exception."""
|
|
692
|
+
raise AttributeError(
|
|
693
|
+
f"Relationship '{self.property.name}' is configured with lazy='raise'. "
|
|
694
|
+
f"Use explicit loading with select_related() or prefetch_related()."
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class RelationshipDescriptor:
|
|
699
|
+
"""Unified relationship field descriptor."""
|
|
700
|
+
|
|
701
|
+
def __init__(self, property_: RelationshipProperty):
|
|
702
|
+
"""Initialize relationship descriptor.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
property_: Relationship property configuration
|
|
706
|
+
"""
|
|
707
|
+
self.property = property_
|
|
708
|
+
self.name: str | None = None
|
|
709
|
+
|
|
710
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
711
|
+
"""Set descriptor name and register with model.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
owner: Model class that owns this descriptor
|
|
715
|
+
name: Field name
|
|
716
|
+
"""
|
|
717
|
+
self.name = name
|
|
718
|
+
self.property.name = name
|
|
719
|
+
|
|
720
|
+
# Register relationship with model
|
|
721
|
+
if not hasattr(owner, "_relationships"):
|
|
722
|
+
owner._relationships = {}
|
|
723
|
+
owner._relationships[name] = self
|
|
724
|
+
|
|
725
|
+
def __get__(self, instance: "ObjectModel | None", owner: type) -> Any:
|
|
726
|
+
"""Get relationship value.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
instance: Model instance or None for class access
|
|
730
|
+
owner: Model class
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Appropriate relationship proxy based on lazy strategy
|
|
734
|
+
"""
|
|
735
|
+
if instance is None:
|
|
736
|
+
return self
|
|
737
|
+
|
|
738
|
+
# Ensure relationships are resolved
|
|
739
|
+
registry = getattr(instance.__class__, "__registry__", None)
|
|
740
|
+
if registry:
|
|
741
|
+
registry.resolve_all_relationships()
|
|
742
|
+
|
|
743
|
+
# Check if already preloaded
|
|
744
|
+
if self.name:
|
|
745
|
+
cache_attr = f"_{self.name}_cache"
|
|
746
|
+
if hasattr(instance, cache_attr):
|
|
747
|
+
return getattr(instance, cache_attr)
|
|
748
|
+
|
|
749
|
+
# Return different objects based on lazy strategy
|
|
750
|
+
if self.property.lazy == "dynamic":
|
|
751
|
+
return RelatedQuerySet(instance, self)
|
|
752
|
+
elif self.property.lazy == "noload":
|
|
753
|
+
return NoLoadProxy(instance, self)
|
|
754
|
+
elif self.property.lazy == "raise":
|
|
755
|
+
return RaiseProxy(instance, self)
|
|
756
|
+
elif self.property.is_many_to_many:
|
|
757
|
+
return M2MRelatedCollection(instance, self)
|
|
758
|
+
elif self.property.uselist:
|
|
759
|
+
return OneToManyCollection(instance, self)
|
|
760
|
+
else:
|
|
761
|
+
return RelatedObjectProxy(instance, self)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def relationship(
|
|
765
|
+
argument: str | type["ObjectModel"],
|
|
766
|
+
*,
|
|
767
|
+
foreign_keys: str | list[str] | None = None,
|
|
768
|
+
back_populates: str | None = None,
|
|
769
|
+
backref: str | None = None,
|
|
770
|
+
lazy: str = "select",
|
|
771
|
+
uselist: bool | None = None,
|
|
772
|
+
secondary: str | M2MTable | None = None,
|
|
773
|
+
primaryjoin: str | None = None,
|
|
774
|
+
secondaryjoin: str | None = None,
|
|
775
|
+
order_by: str | list[str] | None = None,
|
|
776
|
+
cascade: str | None = None,
|
|
777
|
+
passive_deletes: bool = False,
|
|
778
|
+
**kwargs: Any,
|
|
779
|
+
) -> RelationshipDescriptor:
|
|
780
|
+
"""Define model relationship.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
argument: Target model class or string name
|
|
784
|
+
foreign_keys: Foreign key field name(s)
|
|
785
|
+
back_populates: Name of reverse relationship attribute
|
|
786
|
+
backref: Name for automatic reverse relationship
|
|
787
|
+
lazy: Loading strategy ('select', 'dynamic', 'noload', 'raise')
|
|
788
|
+
uselist: Whether relationship returns a list
|
|
789
|
+
secondary: M2M table name or M2MTable instance
|
|
790
|
+
primaryjoin: Custom primary join condition
|
|
791
|
+
secondaryjoin: Custom secondary join condition for M2M
|
|
792
|
+
order_by: Default ordering for collections
|
|
793
|
+
cascade: Cascade options
|
|
794
|
+
passive_deletes: Whether to use passive deletes
|
|
795
|
+
**kwargs: Additional relationship options
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
RelationshipDescriptor instance
|
|
799
|
+
|
|
800
|
+
Raises:
|
|
801
|
+
ValueError: If both back_populates and backref are specified
|
|
802
|
+
"""
|
|
803
|
+
|
|
804
|
+
# Validate mutually exclusive parameters
|
|
805
|
+
if back_populates and backref:
|
|
806
|
+
raise ValueError("Cannot specify both 'back_populates' and 'backref'")
|
|
807
|
+
|
|
808
|
+
# Handle M2M table definition
|
|
809
|
+
secondary_table_name = None
|
|
810
|
+
m2m_def = None
|
|
811
|
+
|
|
812
|
+
if isinstance(secondary, M2MTable):
|
|
813
|
+
m2m_def = secondary
|
|
814
|
+
secondary_table_name = secondary.table_name
|
|
815
|
+
elif isinstance(secondary, str):
|
|
816
|
+
secondary_table_name = secondary
|
|
817
|
+
|
|
818
|
+
property_ = RelationshipProperty(
|
|
819
|
+
argument=argument,
|
|
820
|
+
foreign_keys=foreign_keys,
|
|
821
|
+
back_populates=back_populates,
|
|
822
|
+
backref=backref,
|
|
823
|
+
lazy=lazy,
|
|
824
|
+
uselist=uselist,
|
|
825
|
+
secondary=secondary_table_name,
|
|
826
|
+
primaryjoin=primaryjoin,
|
|
827
|
+
secondaryjoin=secondaryjoin,
|
|
828
|
+
order_by=order_by,
|
|
829
|
+
cascade=cascade,
|
|
830
|
+
passive_deletes=passive_deletes,
|
|
831
|
+
**kwargs,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Set M2M definition if provided
|
|
835
|
+
if m2m_def:
|
|
836
|
+
property_.m2m_definition = m2m_def
|
|
837
|
+
property_.is_many_to_many = True
|
|
838
|
+
|
|
839
|
+
return RelationshipDescriptor(property_)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
# Keep RelatedCollection alias for backward compatibility
|
|
843
|
+
RelatedCollection = OneToManyCollection
|