sqlobjects 1.2.3__py3-none-any.whl → 1.2.4__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/fields/proxies.py +54 -35
- sqlobjects/fields/relations/descriptors.py +17 -23
- sqlobjects/fields/relations/prefetch.py +82 -164
- sqlobjects/fields/relations/utils.py +115 -172
- sqlobjects/metadata.py +1 -1
- sqlobjects/queryset.py +3 -0
- sqlobjects/session.py +12 -3
- {sqlobjects-1.2.3.dist-info → sqlobjects-1.2.4.dist-info}/METADATA +1 -1
- {sqlobjects-1.2.3.dist-info → sqlobjects-1.2.4.dist-info}/RECORD +21 -21
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/01-database-session-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/02-model-definition-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/03-query-operations-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/04-crud-operations-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/05-relationships-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/06-validation-signals-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/07-performance-guide.md +0 -0
- {sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/README.md +0 -0
- {sqlobjects-1.2.3.dist-info → sqlobjects-1.2.4.dist-info}/WHEEL +0 -0
- {sqlobjects-1.2.3.dist-info → sqlobjects-1.2.4.dist-info}/entry_points.txt +0 -0
- {sqlobjects-1.2.3.dist-info → sqlobjects-1.2.4.dist-info}/licenses/LICENSE +0 -0
- {sqlobjects-1.2.3.dist-info → sqlobjects-1.2.4.dist-info}/top_level.txt +0 -0
sqlobjects/fields/proxies.py
CHANGED
|
@@ -96,6 +96,14 @@ class BaseRelated(Generic[T]):
|
|
|
96
96
|
self.property = descriptor.property
|
|
97
97
|
self._cached_value = None
|
|
98
98
|
self._loaded = False
|
|
99
|
+
self._rel_info = None
|
|
100
|
+
|
|
101
|
+
def _get_relationship_info(self):
|
|
102
|
+
if self._rel_info is None:
|
|
103
|
+
from .relations.utils import RelationshipAnalyzer
|
|
104
|
+
|
|
105
|
+
self._rel_info = RelationshipAnalyzer.analyze_relationship(self.instance.__class__, self.property.name)
|
|
106
|
+
return self._rel_info
|
|
99
107
|
|
|
100
108
|
async def fetch(self) -> T:
|
|
101
109
|
"""Fetch related object(s)."""
|
|
@@ -122,23 +130,28 @@ class RelatedObject(BaseRelated[T]):
|
|
|
122
130
|
|
|
123
131
|
async def _load(self):
|
|
124
132
|
"""Load related object from database."""
|
|
125
|
-
if
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
fk_field = fk_field[0]
|
|
133
|
+
if not self.property.resolved_model:
|
|
134
|
+
self._loaded = True
|
|
135
|
+
return
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
info = self._get_relationship_info()
|
|
138
|
+
if not info:
|
|
139
|
+
self._loaded = True
|
|
140
|
+
return
|
|
134
141
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
row = result.first()
|
|
142
|
+
fk_field = info["foreign_key_fields"][0]
|
|
143
|
+
ref_field = info["ref_fields"][0]
|
|
144
|
+
fk_value = getattr(self.instance, fk_field)
|
|
139
145
|
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
if fk_value is not None:
|
|
147
|
+
related_table = self.property.resolved_model.get_table()
|
|
148
|
+
ref_col = related_table.c[ref_field]
|
|
149
|
+
query = select(related_table).where(ref_col == fk_value)
|
|
150
|
+
session = self.instance.get_session()
|
|
151
|
+
result = await session.execute(query)
|
|
152
|
+
row = result.first()
|
|
153
|
+
if row:
|
|
154
|
+
self._cached_value = self.property.resolved_model.from_dict(dict(row._mapping), validate=False)
|
|
142
155
|
|
|
143
156
|
self._loaded = True
|
|
144
157
|
|
|
@@ -191,12 +204,18 @@ class OneToManyRelation(RelatedCollection[T]):
|
|
|
191
204
|
self._set_empty_result()
|
|
192
205
|
return
|
|
193
206
|
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
info = self._get_relationship_info()
|
|
208
|
+
if not info:
|
|
209
|
+
self._set_empty_result()
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
fk_field = info["foreign_key_fields"][0]
|
|
213
|
+
ref_field = info["ref_fields"][0]
|
|
214
|
+
ref_value = getattr(self.instance, ref_field)
|
|
196
215
|
|
|
197
|
-
|
|
198
|
-
fk_col = related_table.c[
|
|
199
|
-
query = select(related_table).where(fk_col ==
|
|
216
|
+
related_table = self.property.resolved_model.get_table()
|
|
217
|
+
fk_col = related_table.c[fk_field]
|
|
218
|
+
query = select(related_table).where(fk_col == ref_value)
|
|
200
219
|
session = self.instance.get_session()
|
|
201
220
|
result = await session.execute(query)
|
|
202
221
|
|
|
@@ -207,19 +226,32 @@ class OneToManyRelation(RelatedCollection[T]):
|
|
|
207
226
|
|
|
208
227
|
async def add(self, *objs: T, session=None) -> None:
|
|
209
228
|
"""Add objects to the relationship."""
|
|
229
|
+
info = self._get_relationship_info()
|
|
230
|
+
if not info:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"Cannot resolve relationship '{self.property.name}' on "
|
|
233
|
+
f"'{self.instance.__class__.__name__}'. Define it explicitly with relationship()."
|
|
234
|
+
)
|
|
210
235
|
session = session or self.instance.get_session()
|
|
211
|
-
fk_field =
|
|
236
|
+
fk_field = info["foreign_key_fields"][0]
|
|
237
|
+
ref_value = getattr(self.instance, info["ref_fields"][0])
|
|
212
238
|
|
|
213
239
|
for obj in objs:
|
|
214
|
-
setattr(obj, fk_field,
|
|
240
|
+
setattr(obj, fk_field, ref_value)
|
|
215
241
|
await obj.using(session).save() # type: ignore
|
|
216
242
|
|
|
217
243
|
self._invalidate_cache()
|
|
218
244
|
|
|
219
245
|
async def remove(self, *objs: T, session=None) -> None:
|
|
220
246
|
"""Remove objects from the relationship."""
|
|
247
|
+
info = self._get_relationship_info()
|
|
248
|
+
if not info:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"Cannot resolve relationship '{self.property.name}' on "
|
|
251
|
+
f"'{self.instance.__class__.__name__}'. Define it explicitly with relationship()."
|
|
252
|
+
)
|
|
221
253
|
session = session or self.instance.get_session()
|
|
222
|
-
fk_field =
|
|
254
|
+
fk_field = info["foreign_key_fields"][0]
|
|
223
255
|
|
|
224
256
|
for obj in objs:
|
|
225
257
|
setattr(obj, fk_field, None)
|
|
@@ -227,19 +259,6 @@ class OneToManyRelation(RelatedCollection[T]):
|
|
|
227
259
|
|
|
228
260
|
self._invalidate_cache()
|
|
229
261
|
|
|
230
|
-
def _get_fk_field(self):
|
|
231
|
-
"""Get foreign key field name."""
|
|
232
|
-
fk_name = self.property.foreign_keys
|
|
233
|
-
if isinstance(fk_name, list):
|
|
234
|
-
fk_name = fk_name[0]
|
|
235
|
-
elif fk_name is None:
|
|
236
|
-
fk_name = (
|
|
237
|
-
f"{self.property.back_populates}_id"
|
|
238
|
-
if self.property.back_populates
|
|
239
|
-
else f"{self.instance.__class__.__name__.lower()}_id"
|
|
240
|
-
)
|
|
241
|
-
return fk_name
|
|
242
|
-
|
|
243
262
|
def __str__(self):
|
|
244
263
|
return f"<OneToManyRelation: {self.property.name}>"
|
|
245
264
|
|
|
@@ -3,6 +3,12 @@ from typing import TYPE_CHECKING, Generic, TypeVar, overload
|
|
|
3
3
|
from ...cascade import OnDelete
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _normalize_fields(fields: str | list[str] | None) -> list[str] | None:
|
|
7
|
+
if fields is None:
|
|
8
|
+
return None
|
|
9
|
+
return [fields] if isinstance(fields, str) else list(fields)
|
|
10
|
+
|
|
11
|
+
|
|
6
12
|
if TYPE_CHECKING:
|
|
7
13
|
from ...model import ObjectModel
|
|
8
14
|
from ..proxies import BaseRelated
|
|
@@ -27,6 +33,7 @@ class RelationshipProperty:
|
|
|
27
33
|
self,
|
|
28
34
|
argument: str | type["ObjectModel"],
|
|
29
35
|
foreign_keys: str | list[str] | None = None,
|
|
36
|
+
remote_fields: str | list[str] | None = None,
|
|
30
37
|
back_populates: str | None = None,
|
|
31
38
|
backref: str | None = None,
|
|
32
39
|
lazy: str = "select",
|
|
@@ -40,32 +47,21 @@ class RelationshipProperty:
|
|
|
40
47
|
passive_deletes: bool = False,
|
|
41
48
|
**kwargs,
|
|
42
49
|
):
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
backref: Name for automatic reverse relationship
|
|
50
|
-
lazy: Loading strategy ('select', 'dynamic', 'noload', 'raise')
|
|
51
|
-
uselist: Whether relationship returns a list
|
|
52
|
-
secondary: M2M table name
|
|
53
|
-
primaryjoin: Custom primary join condition
|
|
54
|
-
secondaryjoin: Custom secondary join condition for M2M
|
|
55
|
-
order_by: Default ordering for collections
|
|
56
|
-
cascade: Cascade behavior (bool for simple on/off, str for SQLAlchemy cascade options)
|
|
57
|
-
on_delete: Behavior when related object is deleted
|
|
58
|
-
passive_deletes: Whether to use passive deletes
|
|
59
|
-
**kwargs: Additional relationship options
|
|
60
|
-
"""
|
|
50
|
+
if foreign_keys and remote_fields:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"Cannot specify both 'foreign_keys' and 'remote_fields'. "
|
|
53
|
+
"Use 'foreign_keys' when the FK is on this model, "
|
|
54
|
+
"'remote_fields' when the FK is on the related model."
|
|
55
|
+
)
|
|
61
56
|
self.argument = argument
|
|
62
|
-
self.foreign_keys = foreign_keys
|
|
57
|
+
self.foreign_keys: list[str] | None = _normalize_fields(foreign_keys)
|
|
58
|
+
self.remote_fields: list[str] | None = _normalize_fields(remote_fields)
|
|
63
59
|
self.back_populates = back_populates
|
|
64
60
|
self.backref = backref
|
|
65
61
|
self.lazy = lazy
|
|
66
62
|
self.uselist = uselist
|
|
67
63
|
self.secondary = secondary
|
|
68
|
-
self.m2m_definition = None
|
|
64
|
+
self.m2m_definition = None
|
|
69
65
|
self.primaryjoin = primaryjoin
|
|
70
66
|
self.secondaryjoin = secondaryjoin
|
|
71
67
|
self.order_by = order_by
|
|
@@ -75,9 +71,7 @@ class RelationshipProperty:
|
|
|
75
71
|
self.name: str | None = None
|
|
76
72
|
self.resolved_model: type[ObjectModel] | None = None
|
|
77
73
|
self.relationship_type: str | None = None
|
|
78
|
-
self.is_many_to_many: bool = False
|
|
79
|
-
|
|
80
|
-
# Store additional relationship configuration parameters
|
|
74
|
+
self.is_many_to_many: bool = False
|
|
81
75
|
self.extra_kwargs = kwargs
|
|
82
76
|
|
|
83
77
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from sqlalchemy import tuple_
|
|
2
|
+
|
|
1
3
|
from .utils import RelationshipAnalyzer
|
|
2
4
|
|
|
3
5
|
|
|
@@ -8,19 +10,9 @@ class PrefetchHandler:
|
|
|
8
10
|
self.session = session
|
|
9
11
|
|
|
10
12
|
async def handle_prefetch_relationships(self, instances, prefetch_relationships):
|
|
11
|
-
"""Handle prefetch_related relationship prefetching.
|
|
12
|
-
|
|
13
|
-
Args:
|
|
14
|
-
instances: List of model instances
|
|
15
|
-
prefetch_relationships: Set of relationship names to prefetch
|
|
16
|
-
|
|
17
|
-
Returns:
|
|
18
|
-
list: Instances with prefetched relationships attached
|
|
19
|
-
"""
|
|
20
13
|
if not instances or not prefetch_relationships:
|
|
21
14
|
return instances
|
|
22
15
|
|
|
23
|
-
# Prefetch each relationship field
|
|
24
16
|
for relationship_name in prefetch_relationships:
|
|
25
17
|
relationship_info = RelationshipAnalyzer.analyze_relationship(instances[0].__class__, relationship_name)
|
|
26
18
|
if relationship_info:
|
|
@@ -29,98 +21,81 @@ class PrefetchHandler:
|
|
|
29
21
|
return instances
|
|
30
22
|
|
|
31
23
|
async def _prefetch_single_relationship(self, instances, relationship_name, relationship_info):
|
|
32
|
-
"""Prefetch single relationship field."""
|
|
33
24
|
rel_type = relationship_info["type"]
|
|
34
25
|
|
|
35
26
|
if rel_type == "reverse_fk":
|
|
36
|
-
await self.
|
|
27
|
+
await self._prefetch_by_fields(
|
|
28
|
+
instances,
|
|
29
|
+
relationship_name,
|
|
30
|
+
relationship_info["related_model"],
|
|
31
|
+
relationship_info["ref_fields"],
|
|
32
|
+
relationship_info["foreign_key_fields"],
|
|
33
|
+
[],
|
|
34
|
+
)
|
|
37
35
|
elif rel_type == "one_to_one":
|
|
38
|
-
|
|
36
|
+
info = relationship_info
|
|
37
|
+
await self._prefetch_by_fields(
|
|
38
|
+
instances,
|
|
39
|
+
relationship_name,
|
|
40
|
+
info["related_model"],
|
|
41
|
+
info["ref_fields"],
|
|
42
|
+
info["foreign_key_fields"],
|
|
43
|
+
None,
|
|
44
|
+
)
|
|
45
|
+
elif rel_type == "many_to_one":
|
|
46
|
+
info = relationship_info
|
|
47
|
+
await self._prefetch_by_fields(
|
|
48
|
+
instances,
|
|
49
|
+
relationship_name,
|
|
50
|
+
info["related_model"],
|
|
51
|
+
info["foreign_key_fields"],
|
|
52
|
+
info["ref_fields"],
|
|
53
|
+
None,
|
|
54
|
+
)
|
|
39
55
|
elif rel_type == "many_to_many":
|
|
40
56
|
await self._prefetch_many_to_many(instances, relationship_name, relationship_info)
|
|
41
|
-
elif rel_type == "many_to_one":
|
|
42
|
-
await self._prefetch_forward_foreign_key(instances, relationship_name, relationship_info)
|
|
43
|
-
|
|
44
|
-
async def _prefetch_reverse_foreign_key(self, instances, relationship_name, relationship_info):
|
|
45
|
-
"""Prefetch reverse foreign key relationship (one-to-many)."""
|
|
46
|
-
related_model = relationship_info["related_model"]
|
|
47
|
-
foreign_key_field = relationship_info["foreign_key_field"]
|
|
48
|
-
ref_field = relationship_info["ref_field"]
|
|
49
|
-
|
|
50
|
-
# Collect reference values from main instances
|
|
51
|
-
instance_values = [
|
|
52
|
-
getattr(instance, ref_field)
|
|
53
|
-
for instance in instances
|
|
54
|
-
if hasattr(instance, ref_field) and getattr(instance, ref_field) is not None
|
|
55
|
-
]
|
|
56
|
-
if not instance_values:
|
|
57
|
-
# Still need to attach empty lists to all instances
|
|
58
|
-
for instance in instances:
|
|
59
|
-
instance._update_cache(relationship_name, [])
|
|
60
|
-
return
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
58
|
+
async def _prefetch_by_fields(
|
|
59
|
+
self, instances, relationship_name, related_model, lookup_fields, group_fields, empty
|
|
60
|
+
):
|
|
61
|
+
"""Unified prefetch for FK-based relationships."""
|
|
62
|
+
composite = len(lookup_fields) > 1
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
for obj in related_objects:
|
|
72
|
-
fk_value = getattr(obj, foreign_key_field, None)
|
|
73
|
-
if fk_value is not None:
|
|
74
|
-
if fk_value not in grouped_objects:
|
|
75
|
-
grouped_objects[fk_value] = []
|
|
76
|
-
grouped_objects[fk_value].append(obj)
|
|
77
|
-
|
|
78
|
-
# Attach to main instances using unified proxy_cache
|
|
79
|
-
for instance in instances:
|
|
80
|
-
instance_value = getattr(instance, ref_field, None)
|
|
81
|
-
related_list = grouped_objects.get(instance_value, [])
|
|
82
|
-
instance._update_cache(relationship_name, related_list)
|
|
83
|
-
|
|
84
|
-
async def _prefetch_one_to_one(self, instances, relationship_name, relationship_info):
|
|
85
|
-
"""Prefetch one-to-one relationship (reverse FK with uselist=False)."""
|
|
86
|
-
related_model = relationship_info["related_model"]
|
|
87
|
-
foreign_key_field = relationship_info["foreign_key_field"]
|
|
88
|
-
ref_field = relationship_info["ref_field"]
|
|
64
|
+
def make_key(obj, fields):
|
|
65
|
+
return tuple(getattr(obj, f) for f in fields) if composite else getattr(obj, fields[0])
|
|
89
66
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
for
|
|
94
|
-
if hasattr(instance, ref_field) and getattr(instance, ref_field) is not None
|
|
67
|
+
lookup_values = [
|
|
68
|
+
make_key(inst, lookup_fields)
|
|
69
|
+
for inst in instances
|
|
70
|
+
if all(getattr(inst, f, None) is not None for f in lookup_fields)
|
|
95
71
|
]
|
|
96
|
-
if not
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
instance._update_cache(relationship_name, None)
|
|
72
|
+
if not lookup_values:
|
|
73
|
+
for inst in instances:
|
|
74
|
+
inst._update_cache(relationship_name, [] if isinstance(empty, list) else empty)
|
|
100
75
|
return
|
|
101
76
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
77
|
+
if composite:
|
|
78
|
+
cols = [getattr(related_model, f) for f in group_fields]
|
|
79
|
+
qs = related_model.objects.using(self.session).filter(tuple_(*cols).in_(lookup_values))
|
|
80
|
+
else:
|
|
81
|
+
qs = related_model.objects.using(self.session).filter(
|
|
82
|
+
getattr(related_model, group_fields[0]).in_(lookup_values)
|
|
83
|
+
)
|
|
84
|
+
related_objects = await qs.all()
|
|
85
|
+
|
|
86
|
+
if isinstance(empty, list):
|
|
87
|
+
grouped = {}
|
|
88
|
+
for obj in related_objects:
|
|
89
|
+
grouped.setdefault(make_key(obj, group_fields), []).append(obj)
|
|
90
|
+
for inst in instances:
|
|
91
|
+
inst._update_cache(relationship_name, grouped.get(make_key(inst, lookup_fields), []))
|
|
92
|
+
else:
|
|
93
|
+
related_map = {make_key(obj, group_fields): obj for obj in related_objects}
|
|
94
|
+
for inst in instances:
|
|
95
|
+
inst._update_cache(relationship_name, related_map.get(make_key(inst, lookup_fields)))
|
|
121
96
|
|
|
122
97
|
async def _prefetch_many_to_many(self, instances, relationship_name, relationship_info):
|
|
123
|
-
"""Prefetch many-to-many
|
|
98
|
+
"""Prefetch many-to-many via through table."""
|
|
124
99
|
related_model = relationship_info["related_model"]
|
|
125
100
|
through_table = relationship_info["through_table"]
|
|
126
101
|
left_field = relationship_info["left_field"]
|
|
@@ -128,114 +103,57 @@ class PrefetchHandler:
|
|
|
128
103
|
left_ref_field = relationship_info["left_ref_field"]
|
|
129
104
|
right_ref_field = relationship_info["right_ref_field"]
|
|
130
105
|
|
|
131
|
-
# Collect reference values from main instances
|
|
132
106
|
instance_values = [
|
|
133
|
-
getattr(
|
|
134
|
-
for instance in instances
|
|
135
|
-
if hasattr(instance, left_ref_field) and getattr(instance, left_ref_field) is not None
|
|
107
|
+
getattr(inst, left_ref_field) for inst in instances if getattr(inst, left_ref_field, None) is not None
|
|
136
108
|
]
|
|
137
109
|
if not instance_values:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
instance._update_cache(relationship_name, [])
|
|
110
|
+
for inst in instances:
|
|
111
|
+
inst._update_cache(relationship_name, [])
|
|
141
112
|
return
|
|
142
113
|
|
|
143
|
-
# Find through model
|
|
144
114
|
through_model = self._find_through_model(through_table)
|
|
145
115
|
if not through_model:
|
|
146
116
|
return
|
|
147
117
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await through_model.objects.using(self.session)
|
|
118
|
+
through_objects = await (
|
|
119
|
+
through_model.objects.using(self.session)
|
|
151
120
|
.filter(getattr(through_model, left_field).in_(instance_values))
|
|
152
121
|
.all()
|
|
153
122
|
)
|
|
154
123
|
|
|
155
|
-
# Collect related object reference values
|
|
156
124
|
related_values = [getattr(obj, right_field) for obj in through_objects]
|
|
157
125
|
if not related_values:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
instance._update_cache(relationship_name, [])
|
|
126
|
+
for inst in instances:
|
|
127
|
+
inst._update_cache(relationship_name, [])
|
|
161
128
|
return
|
|
162
129
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await related_model.objects.using(self.session)
|
|
130
|
+
related_objects = await (
|
|
131
|
+
related_model.objects.using(self.session)
|
|
166
132
|
.filter(getattr(related_model, right_ref_field).in_(related_values))
|
|
167
133
|
.all()
|
|
168
134
|
)
|
|
169
135
|
|
|
170
|
-
# Create related object mapping
|
|
171
136
|
related_map = {getattr(obj, right_ref_field): obj for obj in related_objects}
|
|
172
137
|
|
|
173
|
-
|
|
174
|
-
grouped_relations = {}
|
|
138
|
+
grouped = {}
|
|
175
139
|
for through_obj in through_objects:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
grouped_relations[main_value] = []
|
|
181
|
-
|
|
182
|
-
if related_value in related_map:
|
|
183
|
-
grouped_relations[main_value].append(related_map[related_value])
|
|
184
|
-
|
|
185
|
-
# Attach to main instances using unified proxy_cache
|
|
186
|
-
for instance in instances:
|
|
187
|
-
instance_value = getattr(instance, left_ref_field, None)
|
|
188
|
-
related_list = grouped_relations.get(instance_value, [])
|
|
189
|
-
instance._update_cache(relationship_name, related_list)
|
|
190
|
-
|
|
191
|
-
async def _prefetch_forward_foreign_key(self, instances, relationship_name, relationship_info):
|
|
192
|
-
"""Prefetch forward foreign key relationship (many-to-one)."""
|
|
193
|
-
related_model = relationship_info["related_model"]
|
|
194
|
-
foreign_key_field = relationship_info["foreign_key_field"]
|
|
195
|
-
ref_field = relationship_info["ref_field"]
|
|
196
|
-
|
|
197
|
-
# Collect foreign key values
|
|
198
|
-
fk_values = [
|
|
199
|
-
getattr(instance, foreign_key_field)
|
|
200
|
-
for instance in instances
|
|
201
|
-
if hasattr(instance, foreign_key_field) and getattr(instance, foreign_key_field) is not None
|
|
202
|
-
]
|
|
203
|
-
if not fk_values:
|
|
204
|
-
# Still need to attach None to all instances
|
|
205
|
-
for instance in instances:
|
|
206
|
-
instance._update_cache(relationship_name, None)
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
# Query related objects
|
|
210
|
-
related_objects = (
|
|
211
|
-
await related_model.objects.using(self.session)
|
|
212
|
-
.filter(getattr(related_model, ref_field).in_(fk_values))
|
|
213
|
-
.all()
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
# Create mapping
|
|
217
|
-
related_map = {getattr(obj, ref_field): obj for obj in related_objects}
|
|
140
|
+
main_val = getattr(through_obj, left_field)
|
|
141
|
+
rel_val = getattr(through_obj, right_field)
|
|
142
|
+
if rel_val in related_map:
|
|
143
|
+
grouped.setdefault(main_val, []).append(related_map[rel_val])
|
|
218
144
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if fk_value in related_map:
|
|
223
|
-
instance._update_cache(relationship_name, related_map[fk_value])
|
|
224
|
-
else:
|
|
225
|
-
# Store None for instances without related objects
|
|
226
|
-
instance._update_cache(relationship_name, None)
|
|
145
|
+
for inst in instances:
|
|
146
|
+
key = getattr(inst, left_ref_field, None)
|
|
147
|
+
inst._update_cache(relationship_name, grouped.get(key, []))
|
|
227
148
|
|
|
228
149
|
@staticmethod
|
|
229
150
|
def _find_through_model(through_table):
|
|
230
|
-
"""Find through table model."""
|
|
231
151
|
from ...model import ObjectModel
|
|
232
152
|
|
|
233
153
|
for subclass in ObjectModel.__subclasses__():
|
|
234
154
|
try:
|
|
235
|
-
if hasattr(subclass, "get_table"):
|
|
236
|
-
|
|
237
|
-
if table.name == through_table:
|
|
238
|
-
return subclass
|
|
155
|
+
if hasattr(subclass, "get_table") and subclass.get_table().name == through_table:
|
|
156
|
+
return subclass
|
|
239
157
|
except Exception: # noqa
|
|
240
158
|
continue
|
|
241
159
|
return None
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
-
from
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
3
4
|
|
|
4
5
|
from sqlalchemy import Column, ForeignKey, Table
|
|
5
6
|
|
|
@@ -77,90 +78,26 @@ class RelationshipResolver:
|
|
|
77
78
|
|
|
78
79
|
@staticmethod
|
|
79
80
|
def resolve_relationship_type(property_: RelationshipProperty) -> str:
|
|
80
|
-
"""Automatically infer relationship type based on parameters.
|
|
81
|
-
|
|
82
|
-
Args:
|
|
83
|
-
property_: RelationshipProperty instance to analyze
|
|
84
|
-
|
|
85
|
-
Returns:
|
|
86
|
-
String representing the relationship type
|
|
87
|
-
"""
|
|
88
|
-
# Handle explicit uselist setting
|
|
89
|
-
if property_.uselist is False:
|
|
90
|
-
return RelationshipType.MANY_TO_ONE if property_.foreign_keys else RelationshipType.ONE_TO_ONE
|
|
91
|
-
elif property_.uselist:
|
|
92
|
-
return RelationshipType.MANY_TO_MANY if property_.secondary else RelationshipType.ONE_TO_MANY
|
|
93
|
-
|
|
94
|
-
# Auto-infer based on parameters
|
|
95
81
|
if property_.secondary:
|
|
96
82
|
property_.is_many_to_many = True
|
|
97
83
|
property_.uselist = True
|
|
98
84
|
return RelationshipType.MANY_TO_MANY
|
|
99
85
|
elif property_.foreign_keys:
|
|
100
|
-
property_.uselist
|
|
86
|
+
if property_.uselist is None:
|
|
87
|
+
property_.uselist = False
|
|
101
88
|
return RelationshipType.MANY_TO_ONE
|
|
102
89
|
else:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@overload
|
|
108
|
-
def relationship(
|
|
109
|
-
argument: str | type,
|
|
110
|
-
*,
|
|
111
|
-
uselist: Literal[False],
|
|
112
|
-
foreign_keys: str | list[str] | None = None,
|
|
113
|
-
back_populates: str | None = None,
|
|
114
|
-
backref: str | None = None,
|
|
115
|
-
lazy: Literal["select"] = "select",
|
|
116
|
-
primaryjoin: str | None = None,
|
|
117
|
-
order_by: str | list[str] | None = None,
|
|
118
|
-
cascade: CascadeType = None,
|
|
119
|
-
passive_deletes: bool = False,
|
|
120
|
-
**kwargs: Any,
|
|
121
|
-
) -> Any: ...
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
@overload
|
|
125
|
-
def relationship(
|
|
126
|
-
argument: str | type,
|
|
127
|
-
*,
|
|
128
|
-
secondary: str | M2MTable,
|
|
129
|
-
foreign_keys: str | list[str] | None = None,
|
|
130
|
-
back_populates: str | None = None,
|
|
131
|
-
backref: str | None = None,
|
|
132
|
-
lazy: str = "select",
|
|
133
|
-
uselist: bool | None = None,
|
|
134
|
-
primaryjoin: str | None = None,
|
|
135
|
-
secondaryjoin: str | None = None,
|
|
136
|
-
order_by: str | list[str] | None = None,
|
|
137
|
-
cascade: CascadeType = None,
|
|
138
|
-
passive_deletes: bool = False,
|
|
139
|
-
**kwargs: Any,
|
|
140
|
-
) -> Any: ...
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
@overload
|
|
144
|
-
def relationship(
|
|
145
|
-
argument: str | type,
|
|
146
|
-
*,
|
|
147
|
-
foreign_keys: str | list[str] | None = None,
|
|
148
|
-
back_populates: str | None = None,
|
|
149
|
-
backref: str | None = None,
|
|
150
|
-
lazy: str = "select",
|
|
151
|
-
uselist: bool | None = None,
|
|
152
|
-
primaryjoin: str | None = None,
|
|
153
|
-
order_by: str | list[str] | None = None,
|
|
154
|
-
cascade: CascadeType = None,
|
|
155
|
-
passive_deletes: bool = False,
|
|
156
|
-
**kwargs: Any,
|
|
157
|
-
) -> Any: ...
|
|
90
|
+
# remote_fields or no hint — one_to_many or one_to_one
|
|
91
|
+
if property_.uselist is None:
|
|
92
|
+
property_.uselist = True
|
|
93
|
+
return RelationshipType.ONE_TO_ONE if property_.uselist is False else RelationshipType.ONE_TO_MANY
|
|
158
94
|
|
|
159
95
|
|
|
160
96
|
def relationship(
|
|
161
97
|
argument: str | type["ObjectModel"],
|
|
162
98
|
*,
|
|
163
99
|
foreign_keys: str | list[str] | None = None,
|
|
100
|
+
remote_fields: str | list[str] | None = None,
|
|
164
101
|
back_populates: str | None = None,
|
|
165
102
|
backref: str | None = None,
|
|
166
103
|
lazy: str = "select",
|
|
@@ -173,49 +110,30 @@ def relationship(
|
|
|
173
110
|
passive_deletes: bool = False,
|
|
174
111
|
**kwargs: Any,
|
|
175
112
|
) -> Any:
|
|
176
|
-
"""Define model relationship
|
|
113
|
+
"""Define model relationship.
|
|
177
114
|
|
|
178
115
|
Args:
|
|
179
116
|
argument: Target model class or string name
|
|
180
|
-
foreign_keys:
|
|
117
|
+
foreign_keys: FK field name(s) on this model (many_to_one side)
|
|
118
|
+
remote_fields: FK field name(s) on the related model (one_to_many/one_to_one side)
|
|
181
119
|
back_populates: Name of reverse relationship attribute
|
|
182
120
|
backref: Name for automatic reverse relationship
|
|
183
|
-
lazy: Loading strategy
|
|
121
|
+
lazy: Loading strategy
|
|
184
122
|
uselist: Whether relationship returns a list
|
|
185
123
|
secondary: M2M table name or M2MTable instance
|
|
186
124
|
primaryjoin: Custom primary join condition
|
|
187
125
|
secondaryjoin: Custom secondary join condition for M2M
|
|
188
126
|
order_by: Default ordering for collections
|
|
189
|
-
cascade:
|
|
127
|
+
cascade: Cascade behavior
|
|
190
128
|
passive_deletes: Whether to use passive deletes
|
|
191
|
-
**kwargs: Additional relationship options
|
|
192
|
-
|
|
193
|
-
Returns:
|
|
194
|
-
Column instance wrapping RelationshipDescriptor for type compatibility
|
|
195
|
-
|
|
196
|
-
Raises:
|
|
197
|
-
ValueError: If both back_populates and backref are specified
|
|
198
|
-
|
|
199
|
-
Example:
|
|
200
|
-
# With type annotation
|
|
201
|
-
posts: Column[list["Post"]] = relationship("Post", back_populates="author")
|
|
202
|
-
author: Column[User] = relationship("User", back_populates="posts")
|
|
203
|
-
|
|
204
|
-
# With cascade
|
|
205
|
-
posts = relationship("Post", cascade={CascadeOption.ALL, CascadeOption.DELETE_ORPHAN})
|
|
206
129
|
"""
|
|
207
|
-
|
|
208
|
-
# Validate mutually exclusive parameters
|
|
209
130
|
if back_populates and backref:
|
|
210
131
|
raise ValueError("Cannot specify both 'back_populates' and 'backref'")
|
|
211
132
|
|
|
212
|
-
# Normalize cascade parameter to SQLAlchemy string format
|
|
213
133
|
cascade_str = normalize_cascade(cascade)
|
|
214
134
|
|
|
215
|
-
# Handle M2M table definition
|
|
216
135
|
secondary_table_name = None
|
|
217
136
|
m2m_def = None
|
|
218
|
-
|
|
219
137
|
if isinstance(secondary, M2MTable):
|
|
220
138
|
m2m_def = secondary
|
|
221
139
|
secondary_table_name = secondary.table_name
|
|
@@ -225,6 +143,7 @@ def relationship(
|
|
|
225
143
|
property_ = RelationshipProperty(
|
|
226
144
|
argument=argument,
|
|
227
145
|
foreign_keys=foreign_keys,
|
|
146
|
+
remote_fields=remote_fields,
|
|
228
147
|
back_populates=back_populates,
|
|
229
148
|
backref=backref,
|
|
230
149
|
lazy=lazy,
|
|
@@ -233,20 +152,17 @@ def relationship(
|
|
|
233
152
|
primaryjoin=primaryjoin,
|
|
234
153
|
secondaryjoin=secondaryjoin,
|
|
235
154
|
order_by=order_by,
|
|
236
|
-
cascade=cascade_str,
|
|
155
|
+
cascade=cascade_str,
|
|
237
156
|
passive_deletes=passive_deletes,
|
|
238
157
|
**kwargs,
|
|
239
158
|
)
|
|
240
159
|
|
|
241
|
-
# Set M2M definition if provided
|
|
242
160
|
if m2m_def:
|
|
243
161
|
property_.m2m_definition = m2m_def # type: ignore[reportAttributeAccessIssue]
|
|
244
162
|
property_.is_many_to_many = True
|
|
245
163
|
|
|
246
|
-
# Import Related here to avoid circular import
|
|
247
164
|
from ..core import Related
|
|
248
165
|
|
|
249
|
-
# Return Related container for relationship fields
|
|
250
166
|
return Related(is_relationship=True, relationship_property=property_, m2m_definition=m2m_def)
|
|
251
167
|
|
|
252
168
|
|
|
@@ -254,117 +170,147 @@ class RelationshipAnalyzer:
|
|
|
254
170
|
"""Analyze model relationships and extract metadata for prefetch operations."""
|
|
255
171
|
|
|
256
172
|
@staticmethod
|
|
173
|
+
@lru_cache(maxsize=256)
|
|
257
174
|
def analyze_relationship(model_class, relationship_name):
|
|
258
|
-
"""Analyze relationship type and extract related information.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
model_class: Main model class
|
|
262
|
-
relationship_name: Relationship field name
|
|
263
|
-
|
|
264
|
-
Returns:
|
|
265
|
-
dict: Relationship info dict with type, related model, field mappings
|
|
266
|
-
"""
|
|
267
175
|
try:
|
|
268
|
-
# Check explicit relationship definition
|
|
269
176
|
if hasattr(model_class, relationship_name):
|
|
270
177
|
field_attr = getattr(model_class, relationship_name)
|
|
271
178
|
if hasattr(field_attr, "property"):
|
|
272
179
|
return RelationshipAnalyzer._extract_relationship_info(model_class, field_attr.property)
|
|
273
|
-
|
|
274
|
-
# Infer reverse relationship
|
|
275
180
|
return RelationshipAnalyzer._infer_reverse_relationship(model_class, relationship_name)
|
|
276
181
|
except Exception: # noqa
|
|
277
182
|
return None
|
|
278
183
|
|
|
279
184
|
@staticmethod
|
|
280
185
|
def _extract_relationship_info(model_class, prop):
|
|
281
|
-
"""Extract information from relationship property."""
|
|
282
186
|
related_model = RelationshipAnalyzer._resolve_model_class(prop.argument)
|
|
283
187
|
if not related_model:
|
|
284
188
|
return None
|
|
285
189
|
|
|
286
|
-
|
|
190
|
+
# many_to_many
|
|
191
|
+
if prop.secondary:
|
|
287
192
|
m2m_def = getattr(prop, "m2m_definition", None)
|
|
288
|
-
if m2m_def:
|
|
289
|
-
return {
|
|
290
|
-
"type": "many_to_many",
|
|
291
|
-
"related_model": related_model,
|
|
292
|
-
"through_table": prop.secondary,
|
|
293
|
-
"left_field": m2m_def.left_field,
|
|
294
|
-
"right_field": m2m_def.right_field,
|
|
295
|
-
"left_ref_field": m2m_def.left_ref_field,
|
|
296
|
-
"right_ref_field": m2m_def.right_ref_field,
|
|
297
|
-
}
|
|
298
|
-
else:
|
|
299
|
-
# String-only secondary table - cannot determine field mappings without M2MTable definition
|
|
193
|
+
if not m2m_def:
|
|
300
194
|
return None
|
|
195
|
+
return {
|
|
196
|
+
"type": "many_to_many",
|
|
197
|
+
"related_model": related_model,
|
|
198
|
+
"through_table": prop.secondary,
|
|
199
|
+
"left_field": m2m_def.left_field,
|
|
200
|
+
"right_field": m2m_def.right_field,
|
|
201
|
+
"left_ref_field": m2m_def.left_ref_field,
|
|
202
|
+
"right_ref_field": m2m_def.right_ref_field,
|
|
203
|
+
}
|
|
301
204
|
|
|
302
|
-
|
|
205
|
+
# many_to_one — FK is on this model
|
|
206
|
+
if prop.foreign_keys:
|
|
207
|
+
from .descriptors import _normalize_fields
|
|
208
|
+
|
|
209
|
+
fks = _normalize_fields(prop.foreign_keys)
|
|
210
|
+
ref_fields = RelationshipAnalyzer._scan_ref_fields(model_class, fks, related_model)
|
|
303
211
|
return {
|
|
304
212
|
"type": "many_to_one",
|
|
305
213
|
"related_model": related_model,
|
|
306
|
-
"
|
|
307
|
-
"
|
|
214
|
+
"foreign_key_fields": fks,
|
|
215
|
+
"ref_fields": ref_fields,
|
|
308
216
|
}
|
|
309
217
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
foreign_key_field = f"{model_class.__name__.lower()}_id" # Default
|
|
218
|
+
# one_to_many / one_to_one — FK is on the related model
|
|
219
|
+
rel_type = "one_to_one" if prop.uselist is False else "reverse_fk"
|
|
313
220
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if hasattr(related_model, prop.back_populates):
|
|
317
|
-
back_attr = getattr(related_model, prop.back_populates)
|
|
318
|
-
if hasattr(back_attr, "property") and back_attr.property.foreign_keys:
|
|
319
|
-
foreign_key_field = back_attr.property.foreign_keys
|
|
221
|
+
if prop.remote_fields:
|
|
222
|
+
from .descriptors import _normalize_fields
|
|
320
223
|
|
|
321
|
-
|
|
322
|
-
|
|
224
|
+
rf = prop.remote_fields
|
|
225
|
+
foreign_key_fields = _normalize_fields(rf) if isinstance(rf, (str, list)) else None
|
|
226
|
+
if not foreign_key_fields:
|
|
227
|
+
foreign_key_fields, ref_fields = RelationshipAnalyzer._scan_fk_fields(related_model, model_class, prop)
|
|
228
|
+
else:
|
|
229
|
+
ref_fields = RelationshipAnalyzer._scan_ref_fields(related_model, foreign_key_fields, model_class)
|
|
230
|
+
else:
|
|
231
|
+
foreign_key_fields, ref_fields = RelationshipAnalyzer._scan_fk_fields(related_model, model_class, prop)
|
|
323
232
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
233
|
+
return {
|
|
234
|
+
"type": rel_type,
|
|
235
|
+
"related_model": related_model,
|
|
236
|
+
"foreign_key_fields": foreign_key_fields,
|
|
237
|
+
"ref_fields": ref_fields,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _scan_fk_fields(related_model, current_model, prop):
|
|
242
|
+
"""Scan related model's columns to find FK(s) pointing to current model's table."""
|
|
243
|
+
try:
|
|
244
|
+
current_table = current_model.__table__
|
|
245
|
+
related_table = related_model.__table__
|
|
246
|
+
except AttributeError:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Cannot resolve relationship: model tables not available. "
|
|
249
|
+
f"Use 'remote_fields' to specify the FK field on {related_model.__name__}."
|
|
250
|
+
) from None
|
|
251
|
+
|
|
252
|
+
candidates = [
|
|
253
|
+
(col.name, [fk.column.name for fk in col.foreign_keys if fk.column.table == current_table])
|
|
254
|
+
for col in related_table.columns
|
|
255
|
+
if any(fk.column.table == current_table for fk in col.foreign_keys)
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
if not candidates:
|
|
259
|
+
# Fallback: try back_populates
|
|
260
|
+
if prop.back_populates and hasattr(related_model, prop.back_populates):
|
|
261
|
+
back_attr = getattr(related_model, prop.back_populates)
|
|
262
|
+
if hasattr(back_attr, "property") and back_attr.property.foreign_keys:
|
|
263
|
+
fks = back_attr.property.foreign_keys
|
|
264
|
+
refs = [RelationshipAnalyzer._extract_ref_field(fk) for fk in fks]
|
|
265
|
+
return fks, refs
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"No foreign key found on '{related_model.__name__}' pointing to '{current_model.__name__}'. "
|
|
268
|
+
f"Use 'remote_fields' to specify the FK field."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if len(candidates) > 1:
|
|
272
|
+
names = [c[0] for c in candidates]
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"Multiple foreign keys found on '{related_model.__name__}' pointing to "
|
|
275
|
+
f"'{current_model.__name__}': {names}. Use 'remote_fields' to specify which one."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
fk_col_name, ref_col_names = candidates[0]
|
|
279
|
+
return [fk_col_name], ref_col_names if ref_col_names else ["id"]
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _scan_ref_fields(related_model, foreign_key_fields, current_model):
|
|
283
|
+
"""Given FK field names on related model, find the referenced columns on current model."""
|
|
284
|
+
try:
|
|
285
|
+
related_table = related_model.__table__
|
|
286
|
+
except AttributeError:
|
|
287
|
+
return ["id"] * len(foreign_key_fields)
|
|
288
|
+
|
|
289
|
+
ref_fields = []
|
|
290
|
+
for fk_name in foreign_key_fields:
|
|
291
|
+
if fk_name in related_table.c:
|
|
292
|
+
col = related_table.c[fk_name]
|
|
293
|
+
refs = [fk.column.name for fk in col.foreign_keys]
|
|
294
|
+
ref_fields.append(refs[0] if refs else "id")
|
|
295
|
+
else:
|
|
296
|
+
ref_fields.append("id")
|
|
297
|
+
return ref_fields
|
|
330
298
|
|
|
331
299
|
@staticmethod
|
|
332
300
|
def _extract_ref_field(foreign_key_spec):
|
|
333
|
-
"""Extract reference field from foreign key specification."""
|
|
334
301
|
if isinstance(foreign_key_spec, str) and "." in foreign_key_spec:
|
|
335
302
|
return foreign_key_spec.split(".", 1)[1]
|
|
336
|
-
return
|
|
303
|
+
return foreign_key_spec if isinstance(foreign_key_spec, str) else "id"
|
|
337
304
|
|
|
338
305
|
@staticmethod
|
|
339
306
|
def _infer_reverse_relationship(model_class, relationship_name):
|
|
340
|
-
"""Infer reverse relationship (e.g., User.posts)."""
|
|
341
|
-
# posts -> Post, comments -> Comment
|
|
342
|
-
related_model_name = relationship_name.rstrip("s").capitalize()
|
|
343
|
-
related_model = RelationshipAnalyzer._resolve_model_class(related_model_name)
|
|
344
|
-
|
|
345
|
-
if related_model:
|
|
346
|
-
# Check if related model has foreign key pointing to current model
|
|
347
|
-
foreign_key_field = f"{model_class.__name__.lower()}_id"
|
|
348
|
-
try:
|
|
349
|
-
if hasattr(related_model, foreign_key_field):
|
|
350
|
-
return {
|
|
351
|
-
"type": "reverse_fk",
|
|
352
|
-
"related_model": related_model,
|
|
353
|
-
"foreign_key_field": foreign_key_field,
|
|
354
|
-
"ref_field": "id",
|
|
355
|
-
}
|
|
356
|
-
except Exception: # noqa
|
|
357
|
-
pass
|
|
358
|
-
|
|
359
307
|
return None
|
|
360
308
|
|
|
361
309
|
@staticmethod
|
|
362
310
|
def _resolve_model_class(argument):
|
|
363
|
-
"""Resolve model class from string or class argument."""
|
|
364
311
|
if isinstance(argument, str):
|
|
365
312
|
from ...model import ObjectModel
|
|
366
313
|
|
|
367
|
-
# Try to get any ObjectModel subclass to access the registry
|
|
368
314
|
for subclass in ObjectModel.__subclasses__():
|
|
369
315
|
if hasattr(subclass, "__registry__"):
|
|
370
316
|
try:
|
|
@@ -372,13 +318,10 @@ class RelationshipAnalyzer:
|
|
|
372
318
|
except Exception:
|
|
373
319
|
continue
|
|
374
320
|
|
|
375
|
-
# Fallback to recursive search if registry lookup fails
|
|
376
321
|
def find_subclass(base_class):
|
|
377
|
-
"""Recursively find subclass by name."""
|
|
378
322
|
for subclass in base_class.__subclasses__():
|
|
379
323
|
if subclass.__name__ == argument:
|
|
380
324
|
return subclass
|
|
381
|
-
# Recursively search in subclasses
|
|
382
325
|
found = find_subclass(subclass)
|
|
383
326
|
if found:
|
|
384
327
|
return found
|
sqlobjects/metadata.py
CHANGED
|
@@ -215,7 +215,7 @@ class ModelRegistry(SqlAlchemyMetaData):
|
|
|
215
215
|
if fk.column.table.name == target_table_name:
|
|
216
216
|
property_.uselist = False
|
|
217
217
|
if not property_.foreign_keys:
|
|
218
|
-
property_.foreign_keys = col.name
|
|
218
|
+
property_.foreign_keys = [col.name]
|
|
219
219
|
return
|
|
220
220
|
|
|
221
221
|
# No FK found, assume one-to-many
|
sqlobjects/queryset.py
CHANGED
|
@@ -181,6 +181,9 @@ class Q:
|
|
|
181
181
|
_, field_name, value = expr
|
|
182
182
|
field_column = table.c[field_name]
|
|
183
183
|
conditions.append(field_column == value)
|
|
184
|
+
elif isinstance(expr, Q):
|
|
185
|
+
# Nested Q object passed as positional argument
|
|
186
|
+
conditions.append(expr._to_sqlalchemy(table))
|
|
184
187
|
elif hasattr(expr, "resolve"):
|
|
185
188
|
# Resolve SQLObjects expressions
|
|
186
189
|
conditions.append(expr.resolve(table)) # type: ignore[reportAttributeAccessIssue]
|
sqlobjects/session.py
CHANGED
|
@@ -62,7 +62,7 @@ class AsyncSession:
|
|
|
62
62
|
# Return engine if no connection yet
|
|
63
63
|
return get_database(self._db_name).engine
|
|
64
64
|
|
|
65
|
-
async def execute(self, statement: Any, parameters: Any = None) -> CursorResult[Any]:
|
|
65
|
+
async def execute(self, statement: Any, parameters: Any = None) -> "CursorResult[Any]":
|
|
66
66
|
"""Execute statement with automatic transaction management.
|
|
67
67
|
|
|
68
68
|
Supports both SQLAlchemy statement objects and raw SQL strings.
|
|
@@ -85,12 +85,21 @@ class AsyncSession:
|
|
|
85
85
|
if self.auto_commit:
|
|
86
86
|
await self.commit()
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
# Buffer rows before closing so the connection can be returned to the pool.
|
|
89
|
+
# Readonly sessions only run SELECT queries, so freeze()() is safe.
|
|
90
|
+
# Write sessions with auto_commit need rowcount/inserted_primary_key which
|
|
91
|
+
# are already captured in the CursorResult before close() is called.
|
|
92
|
+
if self.readonly:
|
|
93
|
+
result = result.freeze()()
|
|
94
|
+
|
|
95
|
+
return result # type: ignore[return-value]
|
|
89
96
|
except Exception as e:
|
|
90
97
|
await self._handle_exception(e)
|
|
98
|
+
raise # unreachable, _handle_exception always raises
|
|
91
99
|
finally:
|
|
92
100
|
# Auto-close connection for implicit sessions to prevent resource leaks
|
|
93
|
-
|
|
101
|
+
# This covers both auto_commit write sessions and readonly sessions
|
|
102
|
+
if self.auto_commit or self.readonly:
|
|
94
103
|
await self.close()
|
|
95
104
|
|
|
96
105
|
async def stream(self, statement: Any, parameters: Any = None) -> AsyncResult[Any]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlobjects
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.4
|
|
4
4
|
Summary: Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -2,11 +2,11 @@ sqlobjects/__init__.py,sha256=AiZiXBEjvrSItmhnlgyvMDCipU49XmvQPAujg-EoEDg,977
|
|
|
2
2
|
sqlobjects/_install_rules.py,sha256=ndw9815-60WY_ORGUefijUBnoUHPd0NBzUkCL8mQLKc,4053
|
|
3
3
|
sqlobjects/cascade.py,sha256=HLHB4TEoqK7m2i4X9JM0oX3nbltuTntaX3tMX1B1ym4,38389
|
|
4
4
|
sqlobjects/exceptions.py,sha256=IEp5XV6N3hc3SAU-C3F-5D6jEZU_p72mufvwVhaStHo,16592
|
|
5
|
-
sqlobjects/metadata.py,sha256=
|
|
5
|
+
sqlobjects/metadata.py,sha256=ftumbrSTJGN73jtg0e88vPwGURQAxRkho6B41YOMU-Q,41481
|
|
6
6
|
sqlobjects/mixins.py,sha256=TBZFYAfa9RKF0UpxloBwREoWRPrWFHQE8MjOu7Aepu0,25501
|
|
7
7
|
sqlobjects/model.py,sha256=PwP-0XhlVktICRCEye0gYlXrkVQG13yErjOwwf2ssJ0,19273
|
|
8
|
-
sqlobjects/queryset.py,sha256=
|
|
9
|
-
sqlobjects/session.py,sha256=
|
|
8
|
+
sqlobjects/queryset.py,sha256=nIouDo4oL6a3fdrNNAeicFHeYYIx0fpdOpFND5wugAg,50079
|
|
9
|
+
sqlobjects/session.py,sha256=2XaAkboxcL8qHOcUHWc6wXUqiN1IzNoBRpozQLae8C0,14887
|
|
10
10
|
sqlobjects/signals.py,sha256=N4mhotmMy_Ub-XJCvdlfsQ767KVzPbbJdF0JlJ405Oc,17454
|
|
11
11
|
sqlobjects/validators.py,sha256=aqP7YgCnx-cjthqRo2RD-Iz2lvFsJsDgT73HwLwZGxE,10334
|
|
12
12
|
sqlobjects/database/__init__.py,sha256=6lSRw4RzaWhomWEMyPWnpza7UxACQ6JhcFWxnLZZXlU,434
|
|
@@ -26,15 +26,15 @@ sqlobjects/expressions/window.py,sha256=0Olsi422n04SuemGjhIB_WwA9vTGJvMqOjHPWBFj
|
|
|
26
26
|
sqlobjects/fields/__init__.py,sha256=SXYpmfS3l0CqKTIJsOD68wiQIE_x2ORYk2D7gb_E7Qc,1459
|
|
27
27
|
sqlobjects/fields/core.py,sha256=IUGofZ-BlPuMzQBsqH25Ri9eGvLkenur7969u_wMniU,38712
|
|
28
28
|
sqlobjects/fields/functions.py,sha256=KuCdtjTdfXo_1c2MNSlcPhyQmjPo8xFIt4CNUZnvU88,4281
|
|
29
|
-
sqlobjects/fields/proxies.py,sha256=
|
|
29
|
+
sqlobjects/fields/proxies.py,sha256=nUSKYWDdmv91--0TzWh5-YK-fauW-piVVAiLZtMWjqM,17541
|
|
30
30
|
sqlobjects/fields/shortcuts.py,sha256=euYyW0DliR77jLZDRQLNSsCpIHJBoRc7kzTBOIvHQFo,22794
|
|
31
31
|
sqlobjects/fields/utils.py,sha256=HokUdlkyrUT85U468_ngWH7wbGj-VSjSet1AYG64tqI,3549
|
|
32
32
|
sqlobjects/fields/relations/__init__.py,sha256=Bw64TIxt_HlRzRkjlu1t2hMRSvktWGxtI3BXdleTcVQ,629
|
|
33
|
-
sqlobjects/fields/relations/descriptors.py,sha256=
|
|
33
|
+
sqlobjects/fields/relations/descriptors.py,sha256=Dws4L_GyF8WljU__jSDkgc559Syx6ERz8elj_vse15o,5222
|
|
34
34
|
sqlobjects/fields/relations/managers.py,sha256=k0dU4xisRCvUTTTowEQpJUM1BXVt3J65lDInAC9W5zg,2919
|
|
35
|
-
sqlobjects/fields/relations/prefetch.py,sha256=
|
|
35
|
+
sqlobjects/fields/relations/prefetch.py,sha256=ccWO8hKfUUJVPoL5CPLUSMtscwT-XZg82hNArxh2nmc,6206
|
|
36
36
|
sqlobjects/fields/relations/strategies.py,sha256=X4KkeBxwpCcBHWhcL9EGH2WTQ2NdZWBJzm3SMZagpIQ,566
|
|
37
|
-
sqlobjects/fields/relations/utils.py,sha256=
|
|
37
|
+
sqlobjects/fields/relations/utils.py,sha256=G1PbW1heCShUSfjaDIbqj4I0yODdy4j3udf0Ex70cOg,12487
|
|
38
38
|
sqlobjects/fields/types/__init__.py,sha256=PaEslOWuQVZVUoVnUFtqF-sNHOvF9AEVhuDseqRr-GA,651
|
|
39
39
|
sqlobjects/fields/types/base.py,sha256=aK-8UDYtUcqJtwxw_emR-WefKDhrQyp0BK7s-UKx4yw,463
|
|
40
40
|
sqlobjects/fields/types/comparators.py,sha256=d9azQGpUGg7eNF_aR0wWjZDzL3sFClHNh-fyObQudgY,15556
|
|
@@ -54,17 +54,17 @@ sqlobjects/utils/__init__.py,sha256=NsJ52Fl_PaB-2WlOIJVGNPmgOnqPmvYRmp7uaq5zfYs,
|
|
|
54
54
|
sqlobjects/utils/inspect.py,sha256=u-Q9VBoSGYCE_3Soy8jMuCAdM-P7IkSzTxqrJop_Ilk,2612
|
|
55
55
|
sqlobjects/utils/naming.py,sha256=PmfNuEz-Twly2qREjKfMNulJt8TmOUJaxzFTuts3COg,1411
|
|
56
56
|
sqlobjects/utils/pattern.py,sha256=FGCgobTpnmKjdAtQidHIg4Xa2CQLeP35F0VuX_1T1dk,14740
|
|
57
|
-
sqlobjects-1.2.
|
|
58
|
-
sqlobjects-1.2.
|
|
59
|
-
sqlobjects-1.2.
|
|
60
|
-
sqlobjects-1.2.
|
|
61
|
-
sqlobjects-1.2.
|
|
62
|
-
sqlobjects-1.2.
|
|
63
|
-
sqlobjects-1.2.
|
|
64
|
-
sqlobjects-1.2.
|
|
65
|
-
sqlobjects-1.2.
|
|
66
|
-
sqlobjects-1.2.
|
|
67
|
-
sqlobjects-1.2.
|
|
68
|
-
sqlobjects-1.2.
|
|
69
|
-
sqlobjects-1.2.
|
|
70
|
-
sqlobjects-1.2.
|
|
57
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/01-database-session-guide.md,sha256=lnqRpcDhYKdH-f0avW1jXcWt3qi7XBwkMt7clTs9Zvg,7601
|
|
58
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/02-model-definition-guide.md,sha256=i5gNJM-0bIJ2xSUu3mij8uB9kJC9EAc_439sCBqRph8,10977
|
|
59
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/03-query-operations-guide.md,sha256=BEZE_WUUhY6sEZDiDtrbInlV_UVIhbYETABJTNhu2JQ,10550
|
|
60
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/04-crud-operations-guide.md,sha256=abUEvRPSiSjX8enclXO5Uryox9Rt0WFeRcFkvHXfPf8,10085
|
|
61
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/05-relationships-guide.md,sha256=MJ23fYOTWqrLd54ENZLu3S3QRqbLf7joq-YhF6-V8OQ,12204
|
|
62
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/06-validation-signals-guide.md,sha256=i9WLLhyCDoWGkIYqH2D_XLn98oMbF1pB3-L-b5gRbT4,14294
|
|
63
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/07-performance-guide.md,sha256=rjR0DjT39poEBVaUxAzJBEaMaBrZKLVer1zXaNNYfxg,12851
|
|
64
|
+
sqlobjects-1.2.4.data/data/share/sqlobjects/rules/README.md,sha256=d2n5ZZT8LGvjL60u_HlzXn2uA8FwoCgqG3fuAhvMFvM,2280
|
|
65
|
+
sqlobjects-1.2.4.dist-info/licenses/LICENSE,sha256=ScFR7nvWIhar0d32Y-LA4vqp9zNmy1HSJia7UX7jU6c,1068
|
|
66
|
+
sqlobjects-1.2.4.dist-info/METADATA,sha256=Wql2U7dUxjC3mK9nGp7iF7YoVpHJBbynF798QO4Kc0c,13740
|
|
67
|
+
sqlobjects-1.2.4.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
68
|
+
sqlobjects-1.2.4.dist-info/entry_points.txt,sha256=9Q3Ci55MawxkQhMlR_eLJui00_rYYIVO_Aq_Dm0u5MQ,76
|
|
69
|
+
sqlobjects-1.2.4.dist-info/top_level.txt,sha256=brO5vDtCpn4L9dpX0EHpehbu4kcHaK_2Bbnwze35YKc,11
|
|
70
|
+
sqlobjects-1.2.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlobjects-1.2.3.data → sqlobjects-1.2.4.data}/data/share/sqlobjects/rules/07-performance-guide.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|