exdrf-al 0.0.1.dev0__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.
Files changed (42) hide show
  1. exdrf_al/__init__.py +0 -0
  2. exdrf_al/__version__.py +24 -0
  3. exdrf_al/al2r_read.py +531 -0
  4. exdrf_al/base.py +92 -0
  5. exdrf_al/calc_q.py +350 -0
  6. exdrf_al/click_support/__init__.py +10 -0
  7. exdrf_al/click_support/auto_db_migration.py +85 -0
  8. exdrf_al/click_support/downgrade_db.py +47 -0
  9. exdrf_al/click_support/get_base.py +20 -0
  10. exdrf_al/click_support/get_conn.py +41 -0
  11. exdrf_al/click_support/get_dset.py +23 -0
  12. exdrf_al/click_support/list_db_version.py +30 -0
  13. exdrf_al/click_support/set_db_version.py +43 -0
  14. exdrf_al/click_support/upgrade_db.py +54 -0
  15. exdrf_al/connection.py +382 -0
  16. exdrf_al/db_ver/__init__.py +0 -0
  17. exdrf_al/db_ver/alembic/env.py +125 -0
  18. exdrf_al/db_ver/alembic/script.py.mako +28 -0
  19. exdrf_al/db_ver/db_ver.py +316 -0
  20. exdrf_al/export.py +246 -0
  21. exdrf_al/loader.py +502 -0
  22. exdrf_al/persist.py +244 -0
  23. exdrf_al/py.typed +0 -0
  24. exdrf_al/schema_comp.py +239 -0
  25. exdrf_al/table_counter.py +68 -0
  26. exdrf_al/tools.py +228 -0
  27. exdrf_al/ua_companion.py +41 -0
  28. exdrf_al/utils.py +86 -0
  29. exdrf_al/visitor.py +125 -0
  30. exdrf_al-0.0.1.dev0.dist-info/METADATA +134 -0
  31. exdrf_al-0.0.1.dev0.dist-info/RECORD +42 -0
  32. exdrf_al-0.0.1.dev0.dist-info/WHEEL +5 -0
  33. exdrf_al-0.0.1.dev0.dist-info/top_level.txt +3 -0
  34. exdrf_al_tests/__init__.py +0 -0
  35. exdrf_al_tests/base_test.py +120 -0
  36. exdrf_al_tests/calc_q_test.py +371 -0
  37. exdrf_al_tests/conftest.py +147 -0
  38. exdrf_al_tests/connection_test.py +205 -0
  39. exdrf_al_tests/loader_test.py +472 -0
  40. exdrf_al_tests/persist_test.py +151 -0
  41. exdrf_al_tests/schema_comp_test.py +157 -0
  42. exdrf_al_tests/ua_companion_test.py +41 -0
exdrf_al/__init__.py ADDED
File without changes
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1-dev'
22
+ __version_tuple__ = version_tuple = (0, 0, 1, 'dev0')
23
+
24
+ __commit_id__ = commit_id = None
exdrf_al/al2r_read.py ADDED
@@ -0,0 +1,531 @@
1
+ """SQLAlchemy-backed reads for AL2R list APIs.
2
+
3
+ Builds filtered, sorted, paged queries for root resources and for nested
4
+ relation lists (one-to-many FK, many-to-many, bridge, and child-row shapes).
5
+ Results are mapped to Pydantic ``Ex`` / related DTOs and wrapped in
6
+ :class:`exdrf_pd.paged.PagedList`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from typing import Any, Sequence, TypeVar
14
+
15
+ from pydantic import BaseModel
16
+ from sqlalchemy import Select, and_, func, select
17
+ from sqlalchemy.inspection import inspect
18
+ from sqlalchemy.orm import Session
19
+
20
+ from exdrf.sa_filter_op import filter_op_registry
21
+ from exdrf_pd.filter_item import FilterItem
22
+ from exdrf_pd.paged import PagedList
23
+ from exdrf_pd.sort_item import SortItem
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ TEx = TypeVar("TEx", bound=BaseModel)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RelationListSpec:
32
+ """Static description of how to load one nested list on an ``*Ex`` row.
33
+
34
+ ``kind`` matches the relation shapes emitted by ``exdrf-gen-al2r`` sync
35
+ metadata. Callers pair each spec with the parent ORM row and optional
36
+ filter/sort JSON to produce a :class:`PagedList` of ``related_schema``
37
+ instances.
38
+
39
+ Attributes:
40
+ kind: Relation shape: ``o2m_fk``, ``m2m``, ``o2m_bridge``, or
41
+ ``o2m_child_rows``.
42
+ parent_pk_attrs: Names of parent PK columns on the owning ORM row.
43
+ related_model: SQLAlchemy mapped class for related table rows.
44
+ related_schema: Pydantic model (base related type, not ``*Ex``) for
45
+ each item in the nested list.
46
+ related_pk_col: Single ORM column used for ``m2m`` / ``o2m_bridge``
47
+ join keys (the junction FK targets this column on ``related_model``).
48
+ related_pk_order: ORM column names for default ``ORDER BY`` and
49
+ tie-breakers; use every PK column when the related table has a
50
+ composite key. When ``None``, callers use ``(related_pk_col,)``.
51
+ child_fk_col: On ``o2m_fk`` / ``o2m_child_rows``, FK column on
52
+ ``related_model`` pointing at the parent.
53
+ assoc_model: Junction ORM class for ``m2m`` and ``o2m_bridge``.
54
+ parent_fk_cols: Junction column names that reference the parent PK.
55
+ related_fk_col: Junction column that references ``related_pk_col`` on
56
+ ``related_model`` (``m2m`` / ``o2m_bridge`` only).
57
+ """
58
+
59
+ kind: str
60
+ parent_pk_attrs: tuple[str, ...]
61
+ related_model: type[Any]
62
+ related_schema: type[BaseModel]
63
+ related_pk_col: str
64
+ related_pk_order: tuple[str, ...] | None = None
65
+ child_fk_col: str | None = None
66
+ assoc_model: type[Any] | None = None
67
+ parent_fk_cols: tuple[str, ...] | None = None
68
+ related_fk_col: str | None = None
69
+
70
+
71
+ def relation_list_spec_from_sync(
72
+ sync: dict[str, Any],
73
+ *,
74
+ related_model: type[Any],
75
+ related_schema: type[BaseModel],
76
+ related_pk_col: str,
77
+ related_pk_order: tuple[str, ...] | None = None,
78
+ ) -> RelationListSpec:
79
+ """Build a :class:`RelationListSpec` from string-only sync dict plus ORM.
80
+
81
+ Args:
82
+ sync: Relation metadata from
83
+ :func:`~exdrf_gen_al2r.relation_specs.build_al2r_relation_sync_specs`
84
+ (no ORM classes inside the dict).
85
+ related_model: SQLAlchemy mapped class for the related table.
86
+ related_schema: Pydantic model for list items.
87
+ related_pk_col: Single PK column name for junction joins where needed.
88
+ related_pk_order: Optional tuple of all PK columns for ordering.
89
+
90
+ Returns:
91
+ Immutable spec suitable for :func:`list_relation_subresource_page`.
92
+
93
+ Raises:
94
+ ValueError: If ``sync`` is incomplete for its ``kind`` or ``kind`` is
95
+ unknown.
96
+ """
97
+
98
+ kind = str(sync["kind"])
99
+ parent_pk_attrs = tuple(str(x) for x in sync["parent_pk_attrs"])
100
+
101
+ # Dispatch by relation shape and attach ORM / FK fields from ``sync``.
102
+ if kind == "o2m_fk":
103
+ fk = str(sync["child_fk_col"])
104
+ return RelationListSpec(
105
+ kind=kind,
106
+ parent_pk_attrs=parent_pk_attrs,
107
+ related_model=related_model,
108
+ related_schema=related_schema,
109
+ related_pk_col=related_pk_col,
110
+ related_pk_order=related_pk_order,
111
+ child_fk_col=fk,
112
+ )
113
+ if kind in ("m2m", "o2m_bridge"):
114
+ if sync.get("assoc_model") is None:
115
+ raise ValueError("m2m sync requires assoc_model")
116
+ p_cols = tuple(str(x) for x in (sync.get("parent_fk_cols") or ()))
117
+ rfk = sync.get("related_fk_col")
118
+ if not p_cols or rfk is None:
119
+ raise ValueError("m2m sync requires parent_fk_cols and related_fk_col")
120
+ return RelationListSpec(
121
+ kind=kind,
122
+ parent_pk_attrs=parent_pk_attrs,
123
+ related_model=related_model,
124
+ related_schema=related_schema,
125
+ related_pk_col=related_pk_col,
126
+ related_pk_order=related_pk_order,
127
+ assoc_model=sync["assoc_model"],
128
+ parent_fk_cols=p_cols,
129
+ related_fk_col=str(rfk),
130
+ )
131
+ if kind == "o2m_child_rows":
132
+ p_cols = tuple(str(x) for x in (sync.get("parent_fk_cols") or ()))
133
+ if len(p_cols) != 1:
134
+ raise ValueError("o2m_child_rows expects a single parent FK column")
135
+ return RelationListSpec(
136
+ kind=kind,
137
+ parent_pk_attrs=parent_pk_attrs,
138
+ related_model=related_model,
139
+ related_schema=related_schema,
140
+ related_pk_col=related_pk_col,
141
+ related_pk_order=related_pk_order,
142
+ child_fk_col=p_cols[0],
143
+ )
144
+ raise ValueError("unsupported sync kind %r" % (kind,))
145
+
146
+
147
+ def column_values_for_ex(row: Any) -> dict[str, Any]:
148
+ """Expose scalar ORM columns as a dict for Pydantic validation.
149
+
150
+ Args:
151
+ row: Loaded SQLAlchemy mapped instance.
152
+
153
+ Returns:
154
+ Mapping from mapped column attribute names to Python values.
155
+ """
156
+
157
+ # One entry per mapped column; relationship collections are omitted.
158
+ mapper = inspect(row).mapper
159
+ return {attr.key: getattr(row, attr.key) for attr in mapper.column_attrs}
160
+
161
+
162
+ def ex_model_from_orm_columns(row: Any, ex_model: type[TEx]) -> TEx:
163
+ """Construct an ``*Ex`` DTO from ORM scalars only (no nested lists).
164
+
165
+ Args:
166
+ row: SQLAlchemy row for the root resource.
167
+ ex_model: Target ``*Ex`` Pydantic class.
168
+
169
+ Returns:
170
+ Validated ``ex_model`` instance with relation list fields at defaults.
171
+ """
172
+
173
+ return ex_model.model_validate(column_values_for_ex(row))
174
+
175
+
176
+ def filter_items_to_clauses(model: type[Any], items: list[FilterItem]) -> list[Any]:
177
+ """Translate JSON filter items into SQLAlchemy boolean AND clauses.
178
+
179
+ Args:
180
+ model: ORM mapped class whose columns are referenced by ``FilterItem``.
181
+ items: Parsed filter items (field, op, value).
182
+
183
+ Returns:
184
+ List of SQLAlchemy expressions combined with :func:`sqlalchemy.and_`.
185
+
186
+ Raises:
187
+ ValueError: If an operator or field name is not supported on ``model``.
188
+ """
189
+
190
+ out: list[Any] = []
191
+
192
+ # Each item becomes one predicate; unknown op or column raises.
193
+ for it in items:
194
+ ff = it.as_op
195
+ fi = filter_op_registry.get(ff.op)
196
+ if fi is None:
197
+ raise ValueError("unknown filter op %r for field %r" % (ff.op, ff.fld))
198
+ if not hasattr(model, ff.fld):
199
+ raise ValueError("unknown field %r on %s" % (ff.fld, model.__name__))
200
+ col = getattr(model, ff.fld)
201
+ out.append(fi.predicate(col, ff.vl))
202
+ return out
203
+
204
+
205
+ def sort_items_to_order_by(
206
+ model: type[Any],
207
+ sort_items: list[SortItem],
208
+ pk_names: Sequence[str],
209
+ ) -> list[Any]:
210
+ """Build SQLAlchemy ``ORDER BY`` with stable PK tie-breakers.
211
+
212
+ When ``sort_items`` is empty, order by ``pk_names`` ascending only. When it
213
+ is non-empty, apply requested columns first, then append any PK columns not
214
+ yet used so paging is deterministic.
215
+
216
+ Args:
217
+ model: ORM mapped class providing column descriptors.
218
+ sort_items: Client sort directives (may be empty).
219
+ pk_names: ORM attribute names forming the default / tie-break order.
220
+
221
+ Returns:
222
+ List of unary ``asc()`` / ``desc()`` column clauses.
223
+
224
+ Raises:
225
+ ValueError: If a sort field is not a mapped column on ``model``.
226
+ """
227
+
228
+ cols: list[Any] = []
229
+
230
+ # Default path: no explicit sort, use primary key columns only.
231
+ if not sort_items:
232
+ for pk in pk_names:
233
+ cols.append(getattr(model, pk).asc())
234
+ return cols
235
+
236
+ # Explicit sort keys first, then any PK columns not already listed.
237
+ seen: set[str] = set()
238
+ for s in sort_items:
239
+ if not hasattr(model, s.attr):
240
+ raise ValueError("unknown sort field %r on %s" % (s.attr, model.__name__))
241
+ c = getattr(model, s.attr)
242
+ cols.append(c.asc() if s.order == "asc" else c.desc())
243
+ seen.add(s.attr)
244
+ for pk in pk_names:
245
+ if pk not in seen:
246
+ cols.append(getattr(model, pk).asc())
247
+ return cols
248
+
249
+
250
+ def select_paged_rows(
251
+ db: Session,
252
+ model: type[Any],
253
+ *,
254
+ filters: list[FilterItem],
255
+ sort: list[SortItem],
256
+ pk_names: Sequence[str],
257
+ offset: int,
258
+ limit: int,
259
+ ) -> tuple[int, list[Any]]:
260
+ """Return total row count and one page of ORM instances for a root list.
261
+
262
+ Args:
263
+ db: Open SQLAlchemy session.
264
+ model: Root ORM mapped class.
265
+ filters: Parsed filter list (may be empty).
266
+ sort: Parsed sort list (may be empty; PK tie-break still applied).
267
+ pk_names: PK column names on ``model`` for ordering.
268
+ offset: Zero-based row offset.
269
+ limit: Maximum rows to return (page size).
270
+
271
+ Returns:
272
+ ``(total, rows)`` where ``total`` counts matching rows and ``rows`` is
273
+ the current page.
274
+ """
275
+
276
+ clauses = filter_items_to_clauses(model, filters)
277
+
278
+ # With filters: count and select under the same WHERE.
279
+ if clauses:
280
+ where = and_(*clauses)
281
+ total = int(
282
+ db.scalar(select(func.count()).select_from(model).where(where)) or 0
283
+ )
284
+ order_by = sort_items_to_order_by(model, sort, pk_names)
285
+ stmt: Select[Any] = (
286
+ select(model).where(where).order_by(*order_by).offset(offset).limit(limit)
287
+ )
288
+ else:
289
+ # No filters: full-table count, ordered scan with offset/limit.
290
+ total = int(db.scalar(select(func.count()).select_from(model)) or 0)
291
+ order_by = sort_items_to_order_by(model, sort, pk_names)
292
+ stmt = select(model).order_by(*order_by).offset(offset).limit(limit)
293
+
294
+ rows = list(db.scalars(stmt).unique().all())
295
+ return total, rows
296
+
297
+
298
+ def list_relation_subresource_page(
299
+ db: Session,
300
+ *,
301
+ parent_row: Any,
302
+ spec: RelationListSpec,
303
+ filters: list[FilterItem],
304
+ sort: list[SortItem],
305
+ offset: int,
306
+ limit: int,
307
+ ) -> PagedList[BaseModel]:
308
+ """Load one page of related rows for a single parent (nested list).
309
+
310
+ Supports ``o2m_fk`` (FK on child), ``m2m`` / ``o2m_bridge`` (via junction),
311
+ and ``o2m_child_rows`` (child rows keyed by parent FK). Items are validated
312
+ with ``spec.related_schema``; return type is :class:`PagedList` of
313
+ :class:`pydantic.BaseModel` because the concrete schema varies by spec.
314
+
315
+ Args:
316
+ db: Open SQLAlchemy session.
317
+ parent_row: ORM instance of the parent resource (provides PK values).
318
+ spec: Frozen relation metadata including ORM and Pydantic types.
319
+ filters: Filter items scoped to ``spec.related_model``.
320
+ sort: Sort items scoped to ``spec.related_model``.
321
+ offset: Zero-based offset within the related set.
322
+ limit: Maximum related rows (inner list page size).
323
+
324
+ Returns:
325
+ :class:`PagedList` whose ``items`` are ``spec.related_schema`` instances.
326
+
327
+ Raises:
328
+ ValueError: If ``spec`` is inconsistent with ``kind`` or ``kind`` is
329
+ unsupported.
330
+ """
331
+
332
+ rel = spec.related_model
333
+ schema = spec.related_schema
334
+ pkc = spec.related_pk_col
335
+ pk_order = spec.related_pk_order if spec.related_pk_order else (pkc,)
336
+
337
+ # One-to-many: child rows reference parent via a single FK column.
338
+ if spec.kind == "o2m_fk":
339
+ if spec.child_fk_col is None or len(spec.parent_pk_attrs) != 1:
340
+ raise ValueError("invalid o2m_fk RelationListSpec")
341
+ p_val = getattr(parent_row, spec.parent_pk_attrs[0])
342
+ fk = getattr(rel, spec.child_fk_col)
343
+ base = [fk == p_val]
344
+ base.extend(filter_items_to_clauses(rel, filters))
345
+ where = and_(*base)
346
+ total = int(db.scalar(select(func.count()).select_from(rel).where(where)) or 0)
347
+ order_by = sort_items_to_order_by(rel, sort, pk_order)
348
+ stmt = select(rel).where(where).order_by(*order_by).offset(offset).limit(limit)
349
+ elif spec.kind in ("m2m", "o2m_bridge"):
350
+ # Many-to-many (or bridge): join related through association table.
351
+ if (
352
+ spec.assoc_model is None
353
+ or spec.parent_fk_cols is None
354
+ or spec.related_fk_col is None
355
+ ):
356
+ raise ValueError("invalid m2m RelationListSpec")
357
+ assoc = spec.assoc_model
358
+ parent_clauses = [
359
+ getattr(assoc, pc) == getattr(parent_row, pa)
360
+ for pc, pa in zip(spec.parent_fk_cols, spec.parent_pk_attrs)
361
+ ]
362
+ join_on = getattr(assoc, spec.related_fk_col) == getattr(rel, pkc)
363
+ rel_clauses = filter_items_to_clauses(rel, filters)
364
+ where = (
365
+ and_(*parent_clauses, *rel_clauses)
366
+ if rel_clauses
367
+ else and_(*parent_clauses)
368
+ )
369
+ base_stmt = select(rel).join(assoc, join_on).where(where)
370
+ subq = base_stmt.subquery()
371
+ total = int(db.scalar(select(func.count()).select_from(subq)) or 0)
372
+ order_by = sort_items_to_order_by(rel, sort, pk_order)
373
+ stmt = base_stmt.order_by(*order_by).offset(offset).limit(limit)
374
+ elif spec.kind == "o2m_child_rows":
375
+ # Child table rows: filter by FK to parent, no junction join.
376
+ if spec.child_fk_col is None or len(spec.parent_pk_attrs) != 1:
377
+ raise ValueError("invalid o2m_child_rows RelationListSpec")
378
+ p_val = getattr(parent_row, spec.parent_pk_attrs[0])
379
+ fk = getattr(rel, spec.child_fk_col)
380
+ base = [fk == p_val]
381
+ base.extend(filter_items_to_clauses(rel, filters))
382
+ where = and_(*base)
383
+ total = int(db.scalar(select(func.count()).select_from(rel).where(where)) or 0)
384
+ order_by = sort_items_to_order_by(rel, sort, pk_order)
385
+ stmt = select(rel).where(where).order_by(*order_by).offset(offset).limit(limit)
386
+ else:
387
+ raise ValueError("unsupported kind %r" % (spec.kind,))
388
+
389
+ # Materialize ORM rows and map scalars to the declared Pydantic list item.
390
+ rows = list(db.scalars(stmt).unique().all())
391
+ items = [schema.model_validate(column_values_for_ex(r)) for r in rows]
392
+ return PagedList(
393
+ total=total,
394
+ offset=offset,
395
+ page_size=limit,
396
+ items=items,
397
+ )
398
+
399
+
400
+ def hydrate_ex_inner_lists(
401
+ db: Session,
402
+ *,
403
+ parent_row: Any,
404
+ ex: TEx,
405
+ inner_page: int,
406
+ inner_filters: dict[str, list[FilterItem]],
407
+ inner_sort: dict[str, list[SortItem]],
408
+ inner_specs: Sequence[tuple[str, RelationListSpec]],
409
+ ) -> TEx:
410
+ """Populate ``PagedList`` fields on one ``*Ex`` from the parent ORM row.
411
+
412
+ Each ``(attr, spec)`` loads ``inner_page`` related rows into ``ex.attr``.
413
+ When ``inner_page`` is zero or ``inner_specs`` is empty, ``ex`` is returned
414
+ unchanged.
415
+
416
+ Args:
417
+ db: Open SQLAlchemy session.
418
+ parent_row: ORM row matching ``ex`` (same PK).
419
+ ex: Partial ``*Ex`` built from ``parent_row`` scalars only.
420
+ inner_page: Maximum rows per nested list (``<= 0`` disables hydration).
421
+ inner_filters: Per-relation filter lists keyed by ``attr`` name.
422
+ inner_sort: Per-relation sort lists keyed by ``attr`` name.
423
+ inner_specs: Ordered ``(relation_field_name, RelationListSpec)`` pairs.
424
+
425
+ Returns:
426
+ Copy of ``ex`` with nested ``PagedList`` fields set, or ``ex`` if nothing
427
+ was loaded.
428
+
429
+ Raises:
430
+ ValueError: Re-raised after logging if a nested list query is invalid.
431
+ """
432
+
433
+ if inner_page <= 0 or not inner_specs:
434
+ return ex
435
+
436
+ # Load each configured inner list and merge into a model_copy update.
437
+ updates: dict[str, Any] = {}
438
+ for attr, rspec in inner_specs:
439
+ try:
440
+ pl = list_relation_subresource_page(
441
+ db,
442
+ parent_row=parent_row,
443
+ spec=rspec,
444
+ filters=inner_filters.get(attr, []),
445
+ sort=inner_sort.get(attr, []),
446
+ offset=0,
447
+ limit=inner_page,
448
+ )
449
+ except ValueError as exc:
450
+ logger.error(
451
+ "inner list %s failed for parent %s: %s",
452
+ attr,
453
+ parent_row,
454
+ exc,
455
+ exc_info=True,
456
+ )
457
+ raise
458
+ updates[attr] = pl
459
+ return ex.model_copy(update=updates) if updates else ex
460
+
461
+
462
+ def list_root_ex_page(
463
+ db: Session,
464
+ orm_model: type[Any],
465
+ ex_model: type[TEx],
466
+ *,
467
+ pk_names: Sequence[str],
468
+ offset: int,
469
+ page_size: int,
470
+ filters: list[FilterItem],
471
+ sort: list[SortItem],
472
+ inner_page: int,
473
+ inner_filters: dict[str, list[FilterItem]],
474
+ inner_sort: dict[str, list[SortItem]],
475
+ inner_specs: Sequence[tuple[str, RelationListSpec]],
476
+ ) -> PagedList[TEx]:
477
+ """List root resources as ``PagedList`` of ``*Ex`` with optional inner pages.
478
+
479
+ Loads one page of ``orm_model`` rows, maps each to ``ex_model``, then when
480
+ ``inner_page > 0`` fills nested relation lists via
481
+ :func:`hydrate_ex_inner_lists`.
482
+
483
+ Args:
484
+ db: Open SQLAlchemy session.
485
+ orm_model: Root ORM mapped class.
486
+ ex_model: Root ``*Ex`` Pydantic class.
487
+ pk_names: PK column names on ``orm_model`` for root list ordering.
488
+ offset: Zero-based offset into the filtered root set.
489
+ page_size: Root list page size.
490
+ filters: Root-level filter items.
491
+ sort: Root-level sort items.
492
+ inner_page: Inner list page size (``<= 0`` skips nested loads).
493
+ inner_filters: Nested list filters keyed by relation attribute name.
494
+ inner_sort: Nested list sorts keyed by relation attribute name.
495
+ inner_specs: Nested list specs in field order.
496
+
497
+ Returns:
498
+ :class:`PagedList` of hydrated ``ex_model`` instances.
499
+ """
500
+
501
+ total, rows = select_paged_rows(
502
+ db,
503
+ orm_model,
504
+ filters=filters,
505
+ sort=sort,
506
+ pk_names=pk_names,
507
+ offset=offset,
508
+ limit=page_size,
509
+ )
510
+
511
+ # Build each Ex from ORM columns, optionally attaching inner PagedLists.
512
+ items: list[TEx] = []
513
+ for row in rows:
514
+ ex = ex_model_from_orm_columns(row, ex_model)
515
+ if inner_page > 0 and inner_specs:
516
+ ex = hydrate_ex_inner_lists(
517
+ db,
518
+ parent_row=row,
519
+ ex=ex,
520
+ inner_page=inner_page,
521
+ inner_filters=inner_filters,
522
+ inner_sort=inner_sort,
523
+ inner_specs=inner_specs,
524
+ )
525
+ items.append(ex)
526
+ return PagedList(
527
+ total=total,
528
+ offset=offset,
529
+ page_size=page_size,
530
+ items=items,
531
+ )
exdrf_al/base.py ADDED
@@ -0,0 +1,92 @@
1
+ from typing import TYPE_CHECKING, Any, Generator, Type, cast
2
+
3
+ from sqlalchemy.ext.hybrid import HybridExtensionType
4
+ from sqlalchemy.inspection import inspect as sa_inspect
5
+ from sqlalchemy.orm import DeclarativeBase
6
+
7
+ if TYPE_CHECKING:
8
+ from exdrf_al.visitor import DbVisitor
9
+
10
+
11
+ class _RegistryVisitorMixin:
12
+ """SQLAlchemy registry helpers shared by application and isolated bases.
13
+
14
+ Attributes:
15
+ None
16
+ """
17
+
18
+ @classmethod
19
+ def all_models(cls) -> Generator[Type[Any], None, None]:
20
+ """Yield each ORM class registered on this declarative base."""
21
+ reg_base = cast(Type[DeclarativeBase], cls)
22
+ for mapper in reg_base.registry.mappers:
23
+ yield mapper.class_
24
+
25
+ @classmethod
26
+ def visit(
27
+ cls,
28
+ visitor: "DbVisitor",
29
+ ) -> None:
30
+ """Walk all mapped models and forward them to ``visitor``."""
31
+ for model in cls.all_models():
32
+ # Compute the categories (list of modules) of the model.
33
+ categories = visitor.category(model)
34
+
35
+ # Create the tree of categories.
36
+ m_map = visitor.categ_map
37
+ for c in categories:
38
+ # Get the category at this level.
39
+ c_map = m_map.get(c, None)
40
+ if c_map is None:
41
+ # It does not exist, so create it.
42
+ c_map = m_map[c] = {}
43
+
44
+ # Move to the next level.
45
+ m_map = c_map
46
+
47
+ # The leaf receives the model.
48
+ m_map[model.__name__] = model
49
+
50
+ # Visit the model itself.
51
+ visitor.visit_model(model) # type: ignore
52
+
53
+ # Then visit all fields of the model.
54
+ for column in model.__table__.columns:
55
+ visitor.visit_column(model, column) # type: ignore
56
+
57
+ # Then visit all hybrid properties of the model.
58
+ for name, desc in sa_inspect(model).all_orm_descriptors.items():
59
+ ext_ty = getattr(desc, "extension_type", None)
60
+ if ext_ty is not None:
61
+ if ext_ty == HybridExtensionType.HYBRID_PROPERTY:
62
+ visitor.visit_hybrid(model, name, desc) # type: ignore
63
+
64
+ # Then visit all relationships of the model.
65
+ for rel in model.__mapper__.relationships:
66
+ visitor.visit_relation(model, rel) # type: ignore
67
+
68
+
69
+ class Base(_RegistryVisitorMixin, DeclarativeBase):
70
+ """Application-wide declarative base for Ex-DRF SQLAlchemy models.
71
+
72
+ Attributes:
73
+ None
74
+ """
75
+
76
+
77
+ def isolated_declarative_base() -> type[DeclarativeBase]:
78
+ """Return a new declarative base with its own metadata/registry.
79
+
80
+ Used by tests that define temporary models and must not see classes
81
+ registered on the process-wide :class:`Base` (for example models from
82
+ optional dev packages imported earlier in the suite).
83
+
84
+ Returns:
85
+ A fresh declarative base class with :meth:`all_models` and
86
+ :meth:`visit` identical to :class:`Base`.
87
+ """
88
+
89
+ class _IsolatedBase(_RegistryVisitorMixin, DeclarativeBase):
90
+ pass
91
+
92
+ return _IsolatedBase