duo-orm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
duo_orm/patch.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ Convenience extensions for SQLAlchemy InstrumentedAttribute objects.
3
+
4
+ These wrappers enforce string-only usage and provide case-insensitive helpers
5
+ so callers can stick to a consistent, pythonic API without remembering SQL
6
+ patterns.
7
+ """
8
+
9
+ from sqlalchemy.orm.attributes import InstrumentedAttribute
10
+ from sqlalchemy.sql import sqltypes
11
+
12
+
13
+ STRING_TYPES = (sqltypes.String,)
14
+
15
+ _SQLA_CONTAINS = InstrumentedAttribute.contains
16
+ _SQLA_STARTSWITH = InstrumentedAttribute.startswith
17
+ _SQLA_ENDSWITH = InstrumentedAttribute.endswith
18
+
19
+
20
+ def _assert_string_column(attr: InstrumentedAttribute, op_name: str) -> None:
21
+ column_type = getattr(attr, "type", None)
22
+ if column_type is None or not isinstance(column_type, STRING_TYPES):
23
+ raise TypeError(f"{op_name}() is only available on string-like columns.")
24
+
25
+
26
+ def contains(self: InstrumentedAttribute, value: str):
27
+ """
28
+ Case-sensitive substring match (wraps SQLAlchemy's contains for consistency).
29
+ """
30
+ _assert_string_column(self, "contains")
31
+ return _SQLA_CONTAINS(self, value)
32
+
33
+
34
+ def startswith(self: InstrumentedAttribute, prefix: str):
35
+ _assert_string_column(self, "startswith")
36
+ return _SQLA_STARTSWITH(self, prefix)
37
+
38
+
39
+ def endswith(self: InstrumentedAttribute, suffix: str):
40
+ _assert_string_column(self, "endswith")
41
+ return _SQLA_ENDSWITH(self, suffix)
42
+
43
+
44
+ def icontains(self: InstrumentedAttribute, value: str):
45
+ """
46
+ Case-insensitive substring match (equivalent to ILIKE %value%).
47
+ """
48
+ _assert_string_column(self, "icontains")
49
+ return self.ilike(f"%{value}%")
50
+
51
+
52
+ def istartswith(self: InstrumentedAttribute, prefix: str):
53
+ """
54
+ Case-insensitive prefix match.
55
+ """
56
+ _assert_string_column(self, "istartswith")
57
+ return self.ilike(f"{prefix}%")
58
+
59
+
60
+ def iendswith(self: InstrumentedAttribute, suffix: str):
61
+ """
62
+ Case-insensitive suffix match.
63
+ """
64
+ _assert_string_column(self, "iendswith")
65
+ return self.ilike(f"%{suffix}")
66
+
67
+
68
+ InstrumentedAttribute.contains = contains # type: ignore[assignment]
69
+ InstrumentedAttribute.startswith = startswith # type: ignore[assignment]
70
+ InstrumentedAttribute.endswith = endswith # type: ignore[assignment]
71
+ InstrumentedAttribute.icontains = icontains # type: ignore[attr-defined]
72
+ InstrumentedAttribute.istartswith = istartswith # type: ignore[attr-defined]
73
+ InstrumentedAttribute.iendswith = iendswith # type: ignore[attr-defined]
duo_orm/query.py ADDED
@@ -0,0 +1,630 @@
1
+ # duo_orm/query.py
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, replace
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Type,
8
+ TypeVar,
9
+ List,
10
+ Optional,
11
+ Sequence,
12
+ Callable,
13
+ Any,
14
+ Tuple,
15
+ Iterable,
16
+ )
17
+
18
+ from sqlalchemy import (
19
+ select,
20
+ func,
21
+ and_,
22
+ true,
23
+ inspect as sa_inspect,
24
+ Boolean,
25
+ Float,
26
+ Integer,
27
+ Text,
28
+ cast,
29
+ ARRAY as SQLAlchemyARRAY,
30
+ )
31
+ from sqlalchemy.orm import RelationshipProperty, joinedload, selectinload
32
+ from sqlalchemy.orm.attributes import InstrumentedAttribute
33
+ from sqlalchemy.sql.operators import ColumnOperators
34
+ from sqlalchemy.sql.elements import ClauseElement
35
+ from sqlalchemy.types import JSON as SQLAlchemyJSON
36
+
37
+ try: # Optional dependency – present on Postgres dialects.
38
+ from sqlalchemy.dialects.postgresql import JSONB, ARRAY as PG_ARRAY
39
+ except Exception: # pragma: no cover - dialect may not be installed.
40
+ JSONB = None # type: ignore[misc,assignment]
41
+ PG_ARRAY = None # type: ignore[misc,assignment]
42
+
43
+ from .executor import _first, _all, _update, _delete, _count, _one, _exists
44
+
45
+ # This helps with type hinting for the model class itself.
46
+ T = TypeVar("T")
47
+
48
+ JSON_TYPES: Tuple[type, ...] = (SQLAlchemyJSON,)
49
+ if JSONB is not None:
50
+ JSON_TYPES = JSON_TYPES + (JSONB,)
51
+
52
+ ARRAY_TYPES: Tuple[type, ...] = (SQLAlchemyARRAY,)
53
+ if PG_ARRAY is not None and PG_ARRAY not in ARRAY_TYPES:
54
+ ARRAY_TYPES = ARRAY_TYPES + (PG_ARRAY,)
55
+
56
+
57
+ def _is_json_column(attr: InstrumentedAttribute) -> bool:
58
+ column_type = getattr(attr, "type", None)
59
+ if column_type is None:
60
+ return False
61
+ return isinstance(column_type, JSON_TYPES)
62
+
63
+
64
+ def _is_array_column(attr: InstrumentedAttribute) -> bool:
65
+ column_type = getattr(attr, "type", None)
66
+ if column_type is None:
67
+ return False
68
+ if isinstance(column_type, ARRAY_TYPES):
69
+ return True
70
+ return bool(getattr(column_type, "_is_array", False))
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class JSONExpression:
75
+ column: InstrumentedAttribute
76
+ path: Tuple[Any, ...] = ()
77
+ cast_as: str | None = None
78
+
79
+ def __post_init__(self):
80
+ if not isinstance(self.column, InstrumentedAttribute):
81
+ raise TypeError("json() expects an InstrumentedAttribute column.")
82
+ if not _is_json_column(self.column):
83
+ raise TypeError("json() can only target JSON-capable columns.")
84
+
85
+ def __getitem__(self, key: Any) -> "JSONExpression":
86
+ if not isinstance(key, (str, int)):
87
+ raise TypeError("JSON paths only accept string or integer keys.")
88
+ return replace(self, path=self.path + (key,))
89
+
90
+ # --- Casting helpers -------------------------------------------------
91
+
92
+ def as_text(self) -> "JSONExpression":
93
+ return replace(self, cast_as="text")
94
+
95
+ def as_integer(self) -> "JSONExpression":
96
+ return replace(self, cast_as="integer")
97
+
98
+ def as_float(self) -> "JSONExpression":
99
+ return replace(self, cast_as="float")
100
+
101
+ def as_boolean(self) -> "JSONExpression":
102
+ return replace(self, cast_as="boolean")
103
+
104
+ # --- Predicates ------------------------------------------------------
105
+
106
+ def equals(self, value: Any) -> ClauseElement:
107
+ if value is None:
108
+ return self.is_null()
109
+ left = (
110
+ self._json_expr()
111
+ if isinstance(value, (dict, list))
112
+ else _cast_scalar_expr(self, value)
113
+ )
114
+ return left == value
115
+
116
+ def not_equals(self, value: Any) -> ClauseElement:
117
+ if value is None:
118
+ return self.is_not_null()
119
+ left = (
120
+ self._json_expr()
121
+ if isinstance(value, (dict, list))
122
+ else _cast_scalar_expr(self, value)
123
+ )
124
+ return left != value
125
+
126
+ def is_null(self) -> ClauseElement:
127
+ return self._json_expr().is_(None)
128
+
129
+ def is_not_null(self) -> ClauseElement:
130
+ return self._json_expr().is_not(None)
131
+
132
+ def is_true(self) -> ClauseElement:
133
+ return self.as_boolean()._scalar_expr().is_(True)
134
+
135
+ def is_false(self) -> ClauseElement:
136
+ return self.as_boolean()._scalar_expr().is_(False)
137
+
138
+ def contains(self, fragment: Any) -> ClauseElement:
139
+ return self._json_expr().contains(fragment)
140
+
141
+ def has_key(self, key: Any) -> ClauseElement:
142
+ expr = self._json_expr()
143
+ if not hasattr(expr, "has_key"):
144
+ raise TypeError("The current dialect does not expose JSON has_key().")
145
+ return expr.has_key(key) # type: ignore[attr-defined]
146
+
147
+ # Convenience to surface raw SQLAlchemy expression when needed.
148
+ def expression(self, *, as_text: bool = False) -> ClauseElement:
149
+ return self._scalar_expr() if as_text or self.cast_as else self._json_expr()
150
+
151
+ # Rich comparisons to keep syntax pythonic.
152
+ def __eq__(self, other: Any) -> ClauseElement: # type: ignore[override]
153
+ return self.equals(other)
154
+
155
+ def __ne__(self, other: Any) -> ClauseElement: # type: ignore[override]
156
+ return self.not_equals(other)
157
+
158
+ # --- Internals -------------------------------------------------------
159
+
160
+ def _json_expr(self) -> ColumnOperators:
161
+ expr: ColumnOperators = self.column
162
+ for key in self.path:
163
+ expr = expr[key] # type: ignore[index]
164
+ return expr
165
+
166
+ def _scalar_expr(self) -> ClauseElement:
167
+ expr = self._json_expr()
168
+ match self.cast_as:
169
+ case "integer":
170
+ return cast(self._as_text(expr), Integer)
171
+ case "float":
172
+ return cast(self._as_text(expr), Float)
173
+ case "boolean":
174
+ return cast(self._as_text(expr), Boolean)
175
+ case "text" | None:
176
+ return self._as_text(expr)
177
+ case _:
178
+ return self._as_text(expr)
179
+
180
+ def _as_text(self, expr: ColumnOperators) -> ClauseElement:
181
+ text_expr = getattr(expr, "astext", None)
182
+ if text_expr is not None:
183
+ return text_expr
184
+ return cast(expr, Text)
185
+
186
+
187
+ def json(column: InstrumentedAttribute) -> JSONExpression:
188
+ """
189
+ Entry point for building JSON-aware predicates inside QueryBuilder.where.
190
+ """
191
+ return JSONExpression(column)
192
+
193
+
194
+ def _cast_scalar_expr(expr: JSONExpression, value: Any) -> ClauseElement:
195
+ """Choose an appropriate cast for scalar comparisons based on the Python value."""
196
+ if isinstance(value, bool):
197
+ return expr.as_boolean()._scalar_expr()
198
+ if isinstance(value, int):
199
+ return expr.as_integer()._scalar_expr()
200
+ if isinstance(value, float):
201
+ return expr.as_float()._scalar_expr()
202
+ return expr._scalar_expr()
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class ArrayExpression:
207
+ column: InstrumentedAttribute
208
+
209
+ def __post_init__(self):
210
+ if not isinstance(self.column, InstrumentedAttribute):
211
+ raise TypeError("array() expects an InstrumentedAttribute column.")
212
+ if not _is_array_column(self.column):
213
+ raise TypeError("array() can only target ARRAY-capable columns.")
214
+
215
+ def includes(self, value: Any) -> ClauseElement:
216
+ expr = self._array_expr()
217
+ contains_member = getattr(expr, "any", None)
218
+ if contains_member is None:
219
+ raise TypeError("The current dialect does not support array membership checks.")
220
+ return contains_member(value)
221
+
222
+ def includes_all(self, values: Iterable[Any]) -> ClauseElement:
223
+ prepared = self._prepare_values(values)
224
+ comparator_contains = getattr(self._array_expr().comparator, "contains", None)
225
+ if comparator_contains:
226
+ return comparator_contains(prepared)
227
+ return self._array_expr().contains(prepared)
228
+
229
+ def includes_any(self, values: Iterable[Any]) -> ClauseElement:
230
+ prepared = self._prepare_values(values)
231
+ overlap = getattr(self._array_expr(), "overlap", None)
232
+ if overlap is None:
233
+ raise TypeError("The current dialect does not support array overlap checks.")
234
+ return overlap(prepared)
235
+
236
+ def equals(self, values: Iterable[Any]) -> ClauseElement:
237
+ return self._array_expr() == list(values)
238
+
239
+ def not_equals(self, values: Iterable[Any]) -> ClauseElement:
240
+ return self._array_expr() != list(values)
241
+
242
+ def length(self) -> ClauseElement:
243
+ expr = self._array_expr()
244
+ if hasattr(func, "cardinality"):
245
+ return func.cardinality(expr)
246
+ # Fallback: array_length(expr, 1) counts elements in the first dimension.
247
+ return func.array_length(expr, 1)
248
+
249
+ def expression(self) -> ColumnOperators:
250
+ return self._array_expr()
251
+
252
+ def _array_expr(self) -> ColumnOperators:
253
+ return self.column
254
+
255
+ def _prepare_values(self, values: Iterable[Any]) -> List[Any]:
256
+ if values is None:
257
+ raise ValueError("Array helpers require at least one value.")
258
+ if isinstance(values, (list, tuple)):
259
+ seq = list(values)
260
+ else:
261
+ seq = list(values)
262
+ if not seq:
263
+ raise ValueError("Array helpers require at least one value.")
264
+ return seq
265
+
266
+
267
+ def array(column: InstrumentedAttribute) -> ArrayExpression:
268
+ """
269
+ Entry point for building ARRAY-aware predicates inside QueryBuilder.where.
270
+ """
271
+ return ArrayExpression(column)
272
+
273
+ if TYPE_CHECKING:
274
+ from .db import Database
275
+
276
+
277
+ class QueryBuilder:
278
+ """
279
+ A chainable, fluent query builder.
280
+
281
+ This class is the core of the ORM's query-building API. It constructs
282
+ a SQLAlchemy statement internally and provides terminal methods
283
+ (like .first(), .all()) to execute it.
284
+ """
285
+
286
+ def __init__(self, model_cls: Type[T], db: "Database"):
287
+ """
288
+ Initializes the QueryBuilder.
289
+
290
+ Args:
291
+ model_cls: The user's model class (e.g., User).
292
+ db: The configured Database instance.
293
+ """
294
+ if not db:
295
+ raise RuntimeError(
296
+ "QueryBuilder cannot be initialized without a Database instance. "
297
+ "Ensure your BaseModel is correctly associated with your db object."
298
+ )
299
+ self._model_cls = model_cls
300
+ self.db = db
301
+ # The internal state: a SQLAlchemy Select object.
302
+ self._statement = select(self._model_cls)
303
+ self._related_used = False
304
+
305
+ def where(self, *args) -> "QueryBuilder[T]":
306
+ """
307
+ Adds a WHERE clause to the query.
308
+
309
+ Accepts one or more SQLAlchemy expressions.
310
+
311
+ Example:
312
+ User.where(User.name == "Alice", User.age > 30)
313
+ """
314
+ self._statement = self._statement.where(*args)
315
+ return self
316
+
317
+ def order_by(self, *args: str) -> "QueryBuilder[T]":
318
+ """
319
+ Adds an ORDER BY clause to the query.
320
+
321
+ Accepts multiple field names. A '-' prefix indicates
322
+ descending order.
323
+
324
+ Example:
325
+ User.order_by("-id", "name")
326
+ """
327
+ for field in args:
328
+ if not field:
329
+ continue
330
+ desc = field.startswith("-")
331
+ field_name = field.lstrip("-")
332
+
333
+ if not hasattr(self._model_cls, field_name):
334
+ raise AttributeError(
335
+ f"'{self._model_cls.__name__}' has no attribute '{field_name}'"
336
+ )
337
+ column = getattr(self._model_cls, field_name)
338
+
339
+ if desc:
340
+ self._statement = self._statement.order_by(column.desc())
341
+ else:
342
+ self._statement = self._statement.order_by(column.asc())
343
+ return self
344
+
345
+ def limit(self, number: int) -> "QueryBuilder[T]":
346
+ """
347
+ Adds a LIMIT clause to the query.
348
+ """
349
+ self._statement = self._statement.limit(number)
350
+ return self
351
+
352
+ def offset(self, number: int) -> "QueryBuilder[T]":
353
+ """
354
+ Adds an OFFSET clause to the query.
355
+ """
356
+ self._statement = self._statement.offset(number)
357
+ return self
358
+
359
+ def paginate(self, limit: int, offset: int = 0) -> "QueryBuilder[T]":
360
+ """
361
+ Convenience helper that applies both LIMIT and OFFSET in one call.
362
+ """
363
+ self._statement = self._statement.limit(limit).offset(offset)
364
+ return self
365
+
366
+ def related(
367
+ self,
368
+ relationship_attr,
369
+ *,
370
+ where=None,
371
+ aggregate: Optional[str] = None,
372
+ having=None,
373
+ order_by=None,
374
+ loader: str = "selectin",
375
+ ) -> "QueryBuilder[T]":
376
+ """
377
+ Adds filters/order/eager loading based on a relationship.
378
+
379
+ Args:
380
+ relationship_attr: A SQLAlchemy relationship attribute (e.g., User.posts).
381
+ where: Clause or list of clauses applied to the related entity.
382
+ aggregate: One of {"exists", "all", "count"}.
383
+ having: Clause or list evaluated against aggregate expressions (only for "count").
384
+ order_by: Ordering directives (only for "count").
385
+ eager: False, True (defaults to selectinload), "selectin", or "joined".
386
+
387
+ Note:
388
+ `related()` currently only supports direct (single-hop) relationships off the root model.
389
+ Multi-level paths (e.g., `User.posts.comments`) must be expressed via SQLAlchemy directly or
390
+ broken into multiple queries.
391
+ """
392
+ if self._related_used:
393
+ raise ValueError("related() can only be invoked once per query.")
394
+
395
+ path = self._resolve_relationship_path(relationship_attr)
396
+ agg = (aggregate or "exists").lower()
397
+ where_clauses = self._ensure_sequence(where)
398
+ having_clauses = self._ensure_sequence(having)
399
+ order_clauses = self._ensure_sequence(order_by)
400
+
401
+ loader_choice = self._determine_loader(path, loader)
402
+ self._apply_eager_option(path, loader_choice)
403
+
404
+ if agg == "exists":
405
+ self._apply_exists(path, where_clauses)
406
+ elif agg == "all":
407
+ self._apply_all(path, where_clauses)
408
+ elif agg == "count":
409
+ self._apply_count(path, where_clauses, having_clauses, order_clauses)
410
+ else:
411
+ raise ValueError("aggregate must be one of {'exists', 'all', 'count'}.")
412
+
413
+ self._related_used = True
414
+ return self
415
+
416
+ def alchemize(self):
417
+ """
418
+ The "escape hatch".
419
+
420
+ Transmutes the current high-level query into a raw
421
+ SQLAlchemy Select object for advanced customization.
422
+
423
+ Returns:
424
+ sqlalchemy.sql.Select: The underlying query object.
425
+ """
426
+ return self._statement
427
+
428
+ # --- Terminal Methods ---
429
+
430
+ def first(self) -> Optional[T]:
431
+ """
432
+ Fetches the first record matched by the query.
433
+ This is a terminal method.
434
+
435
+ Returns:
436
+ A model instance or None if no record is found.
437
+ """
438
+ return _first(self)
439
+
440
+ def all(self) -> List[T]:
441
+ """
442
+ Fetches all records matched by the query.
443
+ This is a terminal method.
444
+
445
+ Returns:
446
+ A list of model instances.
447
+ """
448
+ return _all(self)
449
+
450
+ def one(self) -> T:
451
+ """
452
+ Fetches exactly one record matched by the query.
453
+ Raises ObjectNotFoundError or MultipleObjectsFoundError as appropriate.
454
+ """
455
+ return _one(self)
456
+
457
+ def count(self) -> int:
458
+ """
459
+ Returns the total number of records matched by the query.
460
+ This is a terminal method.
461
+ """
462
+ return _count(self)
463
+
464
+ def exists(self) -> bool:
465
+ """
466
+ Returns True if the query matches at least one record.
467
+ """
468
+ return _exists(self)
469
+
470
+ def update(self, **values) -> None:
471
+ """
472
+ Performs a bulk update on the records matched by the query.
473
+ This is a terminal method and does not return any records.
474
+ """
475
+ return _update(self, **values)
476
+
477
+ def delete(self) -> None:
478
+ """
479
+ Performs a bulk delete on the records matched by the query.
480
+ This is a terminal method and does not return any records.
481
+ """
482
+ return _delete(self)
483
+
484
+ # --- Internal helpers ---
485
+
486
+ def _resolve_relationship_path(self, relationship_attr) -> List:
487
+ """Validates and returns a single-step relationship path."""
488
+ if not hasattr(relationship_attr, "property"):
489
+ raise TypeError("related() expects a SQLAlchemy relationship attribute.")
490
+ prop = relationship_attr.property
491
+ if not isinstance(prop, RelationshipProperty):
492
+ raise TypeError("related() expects a relationship attribute, not a column.")
493
+
494
+ parent_cls = relationship_attr.parent.class_
495
+ if parent_cls is not self._model_cls:
496
+ raise ValueError(
497
+ "related() currently supports only direct relationships from the root model."
498
+ )
499
+ return [relationship_attr]
500
+
501
+ def _ensure_sequence(self, value) -> List:
502
+ if value is None:
503
+ return []
504
+ if isinstance(value, (list, tuple)):
505
+ return list(value)
506
+ return [value]
507
+
508
+ def _combine_clauses(self, clauses: Sequence) -> Any:
509
+ if not clauses:
510
+ return true()
511
+ return and_(*clauses)
512
+
513
+ def _apply_exists(self, path: List, where_clauses: Sequence) -> None:
514
+ """
515
+ aggregate="exists" uses SQLAlchemy's .any() to test whether at least one related row matches.
516
+ """
517
+ if not where_clauses:
518
+ return
519
+ predicate = self._combine_clauses(where_clauses)
520
+ expr = self._build_exists_expression(path, predicate)
521
+ self._statement = self._statement.where(expr)
522
+
523
+ def _apply_all(self, path: List, where_clauses: Sequence) -> None:
524
+ """
525
+ aggregate="all" uses the double-negative trick: ALL(pred) == NOT EXISTS(not pred).
526
+ """
527
+ if not where_clauses:
528
+ raise ValueError("aggregate='all' requires at least one WHERE predicate.")
529
+ predicate = self._combine_clauses(where_clauses)
530
+ expr = self._build_all_expression(path, predicate)
531
+ self._statement = self._statement.where(expr)
532
+
533
+ def _apply_count(
534
+ self,
535
+ path: List,
536
+ where_clauses: Sequence,
537
+ having_clauses: Sequence,
538
+ order_clauses: Sequence,
539
+ ) -> None:
540
+ """
541
+ aggregate="count" builds a correlated subquery counting related rows, then allows HAVING/ORDER BY on it.
542
+ """
543
+ count_expr = self._build_count_expression(path, where_clauses)
544
+ for clause in having_clauses:
545
+ rendered = clause(count_expr) if callable(clause) else clause
546
+ self._statement = self._statement.where(rendered)
547
+ for clause in order_clauses:
548
+ self._statement = self._statement.order_by(
549
+ self._build_order_clause(clause, count_expr)
550
+ )
551
+
552
+ def _build_exists_expression(self, path: List, predicate) -> Any:
553
+ expr = predicate
554
+ for attr in reversed(path):
555
+ expr = attr.any(expr)
556
+ return expr
557
+
558
+ def _build_all_expression(self, path: List, predicate) -> Any:
559
+ expr = predicate
560
+ for attr in reversed(path):
561
+ expr = ~attr.any(~expr)
562
+ return expr
563
+
564
+ def _build_count_expression(self, path: List, where_clauses: Sequence) -> Any:
565
+ """
566
+ Build a correlated COUNT(*) subquery tied back to the parent entity.
567
+
568
+ Steps:
569
+ 1. Start from the related entity's selectable, applying any WHERE clauses.
570
+ 2. Walk the relationship path backwards to join secondary tables / parent tables.
571
+ 3. Use correlate(parent_table) so SQLAlchemy links the inner COUNT to the outer statement.
572
+ """
573
+ target_cls = path[-1].property.entity.class_
574
+ mapper = sa_inspect(target_cls)
575
+ target_table = mapper.selectable
576
+ pk_cols = mapper.primary_key
577
+ if pk_cols:
578
+ count_target = pk_cols[0]
579
+ else:
580
+ count_target = next(iter(target_table.c.values()))
581
+
582
+ stmt = select(func.count(func.distinct(count_target))).select_from(target_table)
583
+ if where_clauses:
584
+ stmt = stmt.where(*where_clauses)
585
+
586
+ from_clause = target_table
587
+ reversed_path = list(reversed(path))
588
+
589
+ for attr in reversed_path:
590
+ rel = attr.property
591
+ parent_cls = rel.parent.class_
592
+ parent_table = sa_inspect(parent_cls).selectable
593
+
594
+ if rel.secondary is not None:
595
+ from_clause = from_clause.join(rel.secondary, rel.secondaryjoin)
596
+
597
+ if parent_cls is self._model_cls:
598
+ stmt = stmt.where(rel.primaryjoin)
599
+ stmt = stmt.correlate(parent_table)
600
+ else:
601
+ from_clause = from_clause.join(parent_table, rel.primaryjoin)
602
+
603
+ stmt = stmt.select_from(from_clause)
604
+ return stmt.scalar_subquery()
605
+
606
+ def _build_order_clause(self, clause, aggregate_expr):
607
+ if isinstance(clause, str):
608
+ key = clause.lstrip("-").lower()
609
+ if key != "count":
610
+ raise ValueError("order_by only supports 'count' when aggregate='count'.")
611
+ return aggregate_expr.desc() if clause.startswith("-") else aggregate_expr.asc()
612
+ if callable(clause):
613
+ return clause(aggregate_expr)
614
+ return clause
615
+
616
+ def _determine_loader(self, path: List, loader_option) -> str:
617
+ if loader_option not in {"selectin", "joined"}:
618
+ raise ValueError("loader must be 'selectin' or 'joined'.")
619
+ if loader_option == "selectin" and not path[0].property.uselist:
620
+ return "joined"
621
+ return loader_option
622
+
623
+ def _apply_eager_option(self, path: List, loader_type: str):
624
+ loader = selectinload(path[0]) if loader_type == "selectin" else joinedload(path[0])
625
+ for attr in path[1:]:
626
+ loader = (
627
+ loader.selectinload(attr) if loader_type == "selectin" else loader.joinedload(attr)
628
+ )
629
+
630
+ self._statement = self._statement.options(loader)