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.
Files changed (78) hide show
  1. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/CHANGELOG.md +23 -0
  2. {sqlobjects-1.2.2/sqlobjects.egg-info → sqlobjects-1.2.4}/PKG-INFO +1 -1
  3. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/pyproject.toml +1 -2
  4. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/database/manager.py +22 -12
  5. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/exceptions.py +13 -0
  6. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/function.py +10 -10
  7. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/core.py +1 -0
  8. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/proxies.py +54 -35
  9. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/descriptors.py +17 -23
  10. sqlobjects-1.2.4/sqlobjects/fields/relations/prefetch.py +159 -0
  11. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/utils.py +115 -172
  12. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/metadata.py +70 -209
  13. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/executor.py +15 -2
  14. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queryset.py +12 -6
  15. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/session.py +21 -3
  16. {sqlobjects-1.2.2 → sqlobjects-1.2.4/sqlobjects.egg-info}/PKG-INFO +1 -1
  17. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/tests/test_config.py +3 -3
  18. sqlobjects-1.2.2/sqlobjects/fields/relations/prefetch.py +0 -241
  19. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/LICENSE +0 -0
  20. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/README.md +0 -0
  21. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/01-database-session-guide.md +0 -0
  22. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/02-model-definition-guide.md +0 -0
  23. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/03-query-operations-guide.md +0 -0
  24. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/04-crud-operations-guide.md +0 -0
  25. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/05-relationships-guide.md +0 -0
  26. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/06-validation-signals-guide.md +0 -0
  27. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/07-performance-guide.md +0 -0
  28. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/docs/rules/README.md +0 -0
  29. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/setup.cfg +0 -0
  30. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/__init__.py +0 -0
  31. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/_install_rules.py +0 -0
  32. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/cascade.py +0 -0
  33. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/database/__init__.py +0 -0
  34. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/database/config.py +0 -0
  35. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/__init__.py +0 -0
  36. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/aggregate.py +0 -0
  37. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/base.py +0 -0
  38. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/cte.py +0 -0
  39. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/explain.py +0 -0
  40. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/mixins.py +0 -0
  41. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/scalar.py +0 -0
  42. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/subquery.py +0 -0
  43. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/terminal.py +0 -0
  44. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/expressions/window.py +0 -0
  45. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/__init__.py +0 -0
  46. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/functions.py +0 -0
  47. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/__init__.py +0 -0
  48. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/managers.py +0 -0
  49. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/relations/strategies.py +0 -0
  50. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/shortcuts.py +0 -0
  51. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/__init__.py +0 -0
  52. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/base.py +0 -0
  53. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/comparators.py +0 -0
  54. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/types/registry.py +0 -0
  55. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/fields/utils.py +0 -0
  56. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/internal/__init__.py +0 -0
  57. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/internal/operations.py +0 -0
  58. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/internal/results.py +0 -0
  59. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/mixins.py +0 -0
  60. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/model.py +0 -0
  61. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/__init__.py +0 -0
  62. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/bulk.py +0 -0
  63. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/core.py +0 -0
  64. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/objects/upsert.py +0 -0
  65. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/__init__.py +0 -0
  66. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/builder.py +0 -0
  67. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/queries/dialect.py +0 -0
  68. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/signals.py +0 -0
  69. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/__init__.py +0 -0
  70. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/inspect.py +0 -0
  71. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/naming.py +0 -0
  72. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/utils/pattern.py +0 -0
  73. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects/validators.py +0 -0
  74. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/SOURCES.txt +0 -0
  75. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/dependency_links.txt +0 -0
  76. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/entry_points.txt +0 -0
  77. {sqlobjects-1.2.2 → sqlobjects-1.2.4}/sqlobjects.egg-info/requires.txt +0 -0
  78. {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.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.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
- async with self.engine.begin() as conn:
193
- if tables is None:
194
- await conn.run_sync(base_class.__registry__.create_all)
195
- else:
196
- table_objects = [model.__table__ for model in tables]
197
- await conn.run_sync(base_class.__registry__.create_all, tables=table_objects)
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
- async with self.engine.begin() as conn:
215
- if tables is None:
216
- await conn.run_sync(base_class.__registry__.drop_all)
217
- else:
218
- table_objects = [model.__table__ for model in tables]
219
- await conn.run_sync(base_class.__registry__.drop_all, tables=table_objects)
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() # type: ignore
436
- func.rank = lambda: RankFunction() # type: ignore
437
- func.dense_rank = lambda: DenseRankFunction() # type: ignore
438
- func.percent_rank = lambda: PercentRankFunction() # type: ignore
439
- func.ntile = lambda n: NtileFunction(n) # type: ignore
440
- func.lag = lambda col, offset=1, default=None: LagFunction(col, offset, default) # type: ignore
441
- func.lead = lambda col, offset=1, default=None: LeadFunction(col, offset, default) # type: ignore
442
- func.first_value = lambda col: FirstValueFunction(col) # type: ignore
443
- func.last_value = lambda col: LastValueFunction(col) # type: ignore
444
- func.nth_value = lambda col, n: NthValueFunction(col, n) # type: ignore
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 self.property.foreign_keys and self.property.resolved_model:
126
- fk_field = self.property.foreign_keys
127
- if isinstance(fk_field, list):
128
- fk_field = fk_field[0]
133
+ if not self.property.resolved_model:
134
+ self._loaded = True
135
+ return
129
136
 
130
- fk_value = getattr(self.instance, fk_field)
131
- if fk_value is not None:
132
- related_table = self.property.resolved_model.get_table()
133
- pk_col = list(related_table.primary_key.columns)[0]
137
+ info = self._get_relationship_info()
138
+ if not info:
139
+ self._loaded = True
140
+ return
134
141
 
135
- query = select(related_table).where(pk_col == fk_value)
136
- session = self.instance.get_session()
137
- result = await session.execute(query)
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
- if row:
141
- self._cached_value = self.property.resolved_model.from_dict(dict(row._mapping), validate=False)
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
- instance_pk = self.instance.id
195
- related_table = self.property.resolved_model.get_table()
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
- fk_name = self._get_fk_field()
198
- fk_col = related_table.c[fk_name]
199
- query = select(related_table).where(fk_col == instance_pk)
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 = self._get_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, self.instance.id)
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 = self._get_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
- """Initialize relationship property with cascade and deletion behavior.
44
-
45
- Args:
46
- argument: Target model class or string name
47
- foreign_keys: Foreign key field name(s)
48
- back_populates: Name of reverse relationship attribute
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 # M2M table definition
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 # M2M relationship flag
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