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.
@@ -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