sqlobjects 1.2.2__tar.gz → 1.2.4__tar.gz
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-1.2.2 → sqlobjects-1.2.4}/CHANGELOG.md +23 -0
- {sqlobjects-1.2.2/sqlobjects.egg-info → sqlobjects-1.2.4}/PKG-INFO +1 -1
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/pyproject.toml +1 -2
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/database/manager.py +22 -12
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/exceptions.py +13 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/function.py +10 -10
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/core.py +1 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/proxies.py +54 -35
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/descriptors.py +17 -23
- sqlobjects-1.2.4/sqlobjects/fields/relations/prefetch.py +159 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/utils.py +115 -172
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/metadata.py +70 -209
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/executor.py +15 -2
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queryset.py +12 -6
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/session.py +21 -3
- {sqlobjects-1.2.2 → sqlobjects-1.2.4/sqlobjects.egg-info}/PKG-INFO +1 -1
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/tests/test_config.py +3 -3
- sqlobjects-1.2.2/sqlobjects/fields/relations/prefetch.py +0 -241
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/LICENSE +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/README.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/01-database-session-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/02-model-definition-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/03-query-operations-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/04-crud-operations-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/05-relationships-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/06-validation-signals-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/07-performance-guide.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/README.md +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/setup.cfg +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/_install_rules.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/cascade.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/database/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/database/config.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/aggregate.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/base.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/cte.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/explain.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/mixins.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/scalar.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/subquery.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/terminal.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/window.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/functions.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/managers.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/strategies.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/shortcuts.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/base.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/comparators.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/registry.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/utils.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/internal/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/internal/operations.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/internal/results.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/mixins.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/model.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/bulk.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/core.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/upsert.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/builder.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/dialect.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/signals.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/__init__.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/inspect.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/naming.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/pattern.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/validators.py +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/SOURCES.txt +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/dependency_links.txt +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/entry_points.txt +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/requires.txt +0 -0
- {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/top_level.txt +0 -0
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 1.2.4 (2026-02-28)
|
|
2
|
+
|
|
3
|
+
### Fix
|
|
4
|
+
|
|
5
|
+
- handle nested Q objects in Q._to_sqlalchemy
|
|
6
|
+
|
|
7
|
+
### Refactor
|
|
8
|
+
|
|
9
|
+
- **relationships**: overhaul relationship resolution and prefetch system
|
|
10
|
+
|
|
11
|
+
## 1.2.3 (2026-02-26)
|
|
12
|
+
|
|
13
|
+
### Fix
|
|
14
|
+
|
|
15
|
+
- resolve PostgreSQL test failures and cross-test data pollution
|
|
16
|
+
- **executor**: add overloads to execute() and fix iterator type narrowing
|
|
17
|
+
- **queryset**: replace non-existent executor.session with _get_session()
|
|
18
|
+
- enhance exception handling to surface detailed SQLAlchemy errors
|
|
19
|
+
|
|
20
|
+
### Refactor
|
|
21
|
+
|
|
22
|
+
- **metadata**: simplify index handling and config parsing
|
|
23
|
+
|
|
1
24
|
## 1.2.2 (2026-02-26)
|
|
2
25
|
|
|
3
26
|
### Fix
|
|
@@ -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>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlobjects"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.4"
|
|
4
4
|
description = "Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -98,7 +98,6 @@ pythonVersion = "3.12"
|
|
|
98
98
|
reportMissingImports = "error"
|
|
99
99
|
reportUnnecessaryTypeIgnoreComment = "warning"
|
|
100
100
|
reportMissingTypeStubs = false
|
|
101
|
-
lang = "en"
|
|
102
101
|
|
|
103
102
|
[tool.ruff]
|
|
104
103
|
line-length = 120
|
|
@@ -189,12 +189,17 @@ class Database:
|
|
|
189
189
|
>>> await db.create_tables(ObjectModel) # Create all tables
|
|
190
190
|
>>> await db.create_tables(ObjectModel, [User, Post]) # Create specific tables
|
|
191
191
|
"""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
try:
|
|
193
|
+
async with self.engine.begin() as conn:
|
|
194
|
+
if tables is None:
|
|
195
|
+
await conn.run_sync(base_class.__registry__.create_all)
|
|
196
|
+
else:
|
|
197
|
+
table_objects = [model.__table__ for model in tables]
|
|
198
|
+
await conn.run_sync(base_class.__registry__.create_all, tables=table_objects)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
from ..exceptions import convert_sqlalchemy_error
|
|
201
|
+
|
|
202
|
+
raise convert_sqlalchemy_error(e) from e
|
|
198
203
|
|
|
199
204
|
async def drop_tables(self, base_class, tables: list[type] | None = None) -> None:
|
|
200
205
|
"""Drop tables defined in the model registry of SQLObjects base class
|
|
@@ -211,12 +216,17 @@ class Database:
|
|
|
211
216
|
>>> await db.drop_tables(ObjectModel) # Drop all tables
|
|
212
217
|
>>> await db.drop_tables(ObjectModel, [User, Post]) # Drop specific tables
|
|
213
218
|
"""
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
try:
|
|
220
|
+
async with self.engine.begin() as conn:
|
|
221
|
+
if tables is None:
|
|
222
|
+
await conn.run_sync(base_class.__registry__.drop_all)
|
|
223
|
+
else:
|
|
224
|
+
table_objects = [model.__table__ for model in tables]
|
|
225
|
+
await conn.run_sync(base_class.__registry__.drop_all, tables=table_objects)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
from ..exceptions import convert_sqlalchemy_error
|
|
228
|
+
|
|
229
|
+
raise convert_sqlalchemy_error(e) from e
|
|
220
230
|
|
|
221
231
|
async def disconnect(self) -> None:
|
|
222
232
|
"""Disconnect database and clean up resources
|
|
@@ -458,8 +458,21 @@ def convert_sqlalchemy_error(error: Exception) -> SQLObjectsError:
|
|
|
458
458
|
... except SQLAlchemyError as e:
|
|
459
459
|
... raise convert_sqlalchemy_error(e)
|
|
460
460
|
"""
|
|
461
|
+
# Build detailed error message with context
|
|
461
462
|
error_msg = str(error)
|
|
462
463
|
|
|
464
|
+
# Add SQL statement if available
|
|
465
|
+
if hasattr(error, "statement") and getattr(error, "statement", None):
|
|
466
|
+
error_msg += f"\nSQL: {error.statement}" # type: ignore[reportAttributeAccessIssue]
|
|
467
|
+
|
|
468
|
+
# Add parameters if available
|
|
469
|
+
if hasattr(error, "params") and getattr(error, "params", None):
|
|
470
|
+
error_msg += f"\nParams: {error.params}" # type: ignore[reportAttributeAccessIssue]
|
|
471
|
+
|
|
472
|
+
# Add original database error if available
|
|
473
|
+
if hasattr(error, "orig") and getattr(error, "orig", None):
|
|
474
|
+
error_msg += f"\nOriginal: {error.orig}" # type: ignore[reportAttributeAccessIssue]
|
|
475
|
+
|
|
463
476
|
if isinstance(error, SQLAIntegrityError):
|
|
464
477
|
return IntegrityError(error_msg, original_error=error)
|
|
465
478
|
elif isinstance(error, SQLAOperationalError):
|
|
@@ -432,13 +432,13 @@ class _FuncWrapper:
|
|
|
432
432
|
func = _FuncWrapper()
|
|
433
433
|
|
|
434
434
|
# Add window functions at runtime
|
|
435
|
-
func.row_number = lambda: RowNumberFunction()
|
|
436
|
-
func.rank = lambda: RankFunction()
|
|
437
|
-
func.dense_rank = lambda: DenseRankFunction()
|
|
438
|
-
func.percent_rank = lambda: PercentRankFunction()
|
|
439
|
-
func.ntile = lambda n: NtileFunction(n)
|
|
440
|
-
func.lag = lambda col, offset=1, default=None: LagFunction(col, offset, default)
|
|
441
|
-
func.lead = lambda col, offset=1, default=None: LeadFunction(col, offset, default)
|
|
442
|
-
func.first_value = lambda col: FirstValueFunction(col)
|
|
443
|
-
func.last_value = lambda col: LastValueFunction(col)
|
|
444
|
-
func.nth_value = lambda col, n: NthValueFunction(col, n)
|
|
435
|
+
func.row_number = lambda: RowNumberFunction()
|
|
436
|
+
func.rank = lambda: RankFunction()
|
|
437
|
+
func.dense_rank = lambda: DenseRankFunction()
|
|
438
|
+
func.percent_rank = lambda: PercentRankFunction()
|
|
439
|
+
func.ntile = lambda n: NtileFunction(n)
|
|
440
|
+
func.lag = lambda col, offset=1, default=None: LagFunction(col, offset, default)
|
|
441
|
+
func.lead = lambda col, offset=1, default=None: LeadFunction(col, offset, default)
|
|
442
|
+
func.first_value = lambda col: FirstValueFunction(col)
|
|
443
|
+
func.last_value = lambda col: LastValueFunction(col)
|
|
444
|
+
func.nth_value = lambda col, n: NthValueFunction(col, n)
|
|
@@ -317,6 +317,7 @@ class ColumnAttribute(ColumnAttributeFunctionMixin, Generic[T]):
|
|
|
317
317
|
"""
|
|
318
318
|
|
|
319
319
|
inherit_cache = True # make use of the cache key generated by the superclass from SQLAlchemy
|
|
320
|
+
is_clause_element = False # force SQLAlchemy to call __clause_element__() for proper coercion
|
|
320
321
|
|
|
321
322
|
def __getattr__(self, name):
|
|
322
323
|
"""Handle attribute access with proper priority.
|
|
@@ -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
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from sqlalchemy import tuple_
|
|
2
|
+
|
|
3
|
+
from .utils import RelationshipAnalyzer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PrefetchHandler:
|
|
7
|
+
"""Handle prefetch_related operations for model relationships."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, session):
|
|
10
|
+
self.session = session
|
|
11
|
+
|
|
12
|
+
async def handle_prefetch_relationships(self, instances, prefetch_relationships):
|
|
13
|
+
if not instances or not prefetch_relationships:
|
|
14
|
+
return instances
|
|
15
|
+
|
|
16
|
+
for relationship_name in prefetch_relationships:
|
|
17
|
+
relationship_info = RelationshipAnalyzer.analyze_relationship(instances[0].__class__, relationship_name)
|
|
18
|
+
if relationship_info:
|
|
19
|
+
await self._prefetch_single_relationship(instances, relationship_name, relationship_info)
|
|
20
|
+
|
|
21
|
+
return instances
|
|
22
|
+
|
|
23
|
+
async def _prefetch_single_relationship(self, instances, relationship_name, relationship_info):
|
|
24
|
+
rel_type = relationship_info["type"]
|
|
25
|
+
|
|
26
|
+
if rel_type == "reverse_fk":
|
|
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
|
+
)
|
|
35
|
+
elif rel_type == "one_to_one":
|
|
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
|
+
)
|
|
55
|
+
elif rel_type == "many_to_many":
|
|
56
|
+
await self._prefetch_many_to_many(instances, relationship_name, relationship_info)
|
|
57
|
+
|
|
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
|
|
63
|
+
|
|
64
|
+
def make_key(obj, fields):
|
|
65
|
+
return tuple(getattr(obj, f) for f in fields) if composite else getattr(obj, fields[0])
|
|
66
|
+
|
|
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)
|
|
71
|
+
]
|
|
72
|
+
if not lookup_values:
|
|
73
|
+
for inst in instances:
|
|
74
|
+
inst._update_cache(relationship_name, [] if isinstance(empty, list) else empty)
|
|
75
|
+
return
|
|
76
|
+
|
|
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)))
|
|
96
|
+
|
|
97
|
+
async def _prefetch_many_to_many(self, instances, relationship_name, relationship_info):
|
|
98
|
+
"""Prefetch many-to-many via through table."""
|
|
99
|
+
related_model = relationship_info["related_model"]
|
|
100
|
+
through_table = relationship_info["through_table"]
|
|
101
|
+
left_field = relationship_info["left_field"]
|
|
102
|
+
right_field = relationship_info["right_field"]
|
|
103
|
+
left_ref_field = relationship_info["left_ref_field"]
|
|
104
|
+
right_ref_field = relationship_info["right_ref_field"]
|
|
105
|
+
|
|
106
|
+
instance_values = [
|
|
107
|
+
getattr(inst, left_ref_field) for inst in instances if getattr(inst, left_ref_field, None) is not None
|
|
108
|
+
]
|
|
109
|
+
if not instance_values:
|
|
110
|
+
for inst in instances:
|
|
111
|
+
inst._update_cache(relationship_name, [])
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
through_model = self._find_through_model(through_table)
|
|
115
|
+
if not through_model:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
through_objects = await (
|
|
119
|
+
through_model.objects.using(self.session)
|
|
120
|
+
.filter(getattr(through_model, left_field).in_(instance_values))
|
|
121
|
+
.all()
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
related_values = [getattr(obj, right_field) for obj in through_objects]
|
|
125
|
+
if not related_values:
|
|
126
|
+
for inst in instances:
|
|
127
|
+
inst._update_cache(relationship_name, [])
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
related_objects = await (
|
|
131
|
+
related_model.objects.using(self.session)
|
|
132
|
+
.filter(getattr(related_model, right_ref_field).in_(related_values))
|
|
133
|
+
.all()
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
related_map = {getattr(obj, right_ref_field): obj for obj in related_objects}
|
|
137
|
+
|
|
138
|
+
grouped = {}
|
|
139
|
+
for through_obj in through_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])
|
|
144
|
+
|
|
145
|
+
for inst in instances:
|
|
146
|
+
key = getattr(inst, left_ref_field, None)
|
|
147
|
+
inst._update_cache(relationship_name, grouped.get(key, []))
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _find_through_model(through_table):
|
|
151
|
+
from ...model import ObjectModel
|
|
152
|
+
|
|
153
|
+
for subclass in ObjectModel.__subclasses__():
|
|
154
|
+
try:
|
|
155
|
+
if hasattr(subclass, "get_table") and subclass.get_table().name == through_table:
|
|
156
|
+
return subclass
|
|
157
|
+
except Exception: # noqa
|
|
158
|
+
continue
|
|
159
|
+
return None
|