tigrbl-ops-oltp 0.4.5.dev4__tar.gz → 0.4.5.dev6__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 (16) hide show
  1. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/PKG-INFO +7 -7
  2. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/README.md +5 -5
  3. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/pyproject.toml +2 -2
  4. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/bulk.py +1 -2
  5. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/helpers/__init__.py +2 -4
  6. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/helpers/filters.py +18 -12
  7. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/helpers/model.py +5 -56
  8. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/ops.py +68 -16
  9. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/LICENSE +0 -0
  10. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/NOTICE +0 -0
  11. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/__init__.py +0 -0
  12. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/__init__.py +0 -0
  13. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/helpers/db.py +0 -0
  14. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/helpers/enum.py +0 -0
  15. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/helpers/normalize.py +0 -0
  16. {tigrbl_ops_oltp-0.4.5.dev4 → tigrbl_ops_oltp-0.4.5.dev6}/tigrbl_ops_oltp/crud/params.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrbl-ops-oltp
3
- Version: 0.4.5.dev4
3
+ Version: 0.4.5.dev6
4
4
  Summary: Transactional OLTP operation handlers for Tigrbl CRUD, bulk, REST, JSON-RPC, and database-backed workloads.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -232,7 +232,7 @@ Project-URL: Discord, https://discord.gg/K4YTAPapjR
232
232
  Project-URL: Homepage, https://github.com/tigrbl/tigrbl
233
233
  Project-URL: Issues, https://github.com/tigrbl/tigrbl/issues
234
234
  Project-URL: Organization, https://github.com/tigrbl
235
- Project-URL: Repository, https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_ops_oltp
235
+ Project-URL: Repository, https://github.com/tigrbl/tigrbl/tree/master/pkgs/60_ops/tigrbl_ops_oltp
236
236
  Description-Content-Type: text/markdown
237
237
 
238
238
  <div align="center">
@@ -242,7 +242,7 @@ Description-Content-Type: text/markdown
242
242
  <a href="https://pypi.org/project/tigrbl-ops-oltp/"><img src="https://img.shields.io/pypi/v/tigrbl-ops-oltp?label=PyPI" alt="PyPI version for tigrbl-ops-oltp"/></a>
243
243
  <a href="https://pypi.org/project/tigrbl-ops-oltp/"><img src="https://static.pepy.tech/badge/tigrbl-ops-oltp" alt="Downloads for tigrbl-ops-oltp"/></a>
244
244
  <a href="https://discord.gg/K4YTAPapjR"><img src="https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord community for tigrbl-ops-oltp"/></a>
245
- <a href="https://github.com/tigrbl/tigrbl/blob/master/pkgs/core/tigrbl_ops_oltp/README.md"><img src="https://hits.sh/github.com/tigrbl/tigrbl/blob/master/pkgs/core/tigrbl_ops_oltp/README.md.svg?label=hits" alt="Repository hits for tigrbl-ops-oltp README"/></a>
245
+ <a href="https://github.com/tigrbl/tigrbl/blob/master/pkgs/60_ops/tigrbl_ops_oltp/README.md"><img src="https://hits.sh/github.com/tigrbl/tigrbl/blob/master/pkgs/60_ops/tigrbl_ops_oltp/README.md.svg?label=hits" alt="Repository hits for tigrbl-ops-oltp README"/></a>
246
246
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-525252" alt="Apache 2.0 license"/></a>
247
247
  <a href="pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab" alt="Python versions 3.10 | 3.11 | 3.12 | 3.13 | 3.14 for tigrbl-ops-oltp"/></a>
248
248
  <a href="https://github.com/tigrbl/tigrbl/blob/master/docs/README.md"><img src="https://img.shields.io/badge/workspace-core-1f6feb" alt="Workspace group for tigrbl-ops-oltp"/></a>
@@ -266,7 +266,7 @@ Framework maintainers and extension authors working near Tigrbl operation dispat
266
266
 
267
267
  ## Where does tigrbl-ops-oltp fit?
268
268
 
269
- `tigrbl-ops-oltp` lives at `pkgs/core/tigrbl_ops_oltp` and serves focused operation dispatch behavior inside the Tigrbl runtime model.
269
+ `tigrbl-ops-oltp` lives at `pkgs/60_ops/tigrbl_ops_oltp` and serves focused operation dispatch behavior inside the Tigrbl runtime model.
270
270
 
271
271
  ## How does tigrbl-ops-oltp work?
272
272
 
@@ -288,7 +288,7 @@ pip install tigrbl-ops-oltp
288
288
  | Surface | Value |
289
289
  |---|---|
290
290
  | PyPI package | [`tigrbl-ops-oltp`](https://pypi.org/project/tigrbl-ops-oltp/) |
291
- | Repository path | [`pkgs/core/tigrbl_ops_oltp`](https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_ops_oltp) |
291
+ | Repository path | [`pkgs/60_ops/tigrbl_ops_oltp`](https://github.com/tigrbl/tigrbl/tree/master/pkgs/60_ops/tigrbl_ops_oltp) |
292
292
  | Python import root | `tigrbl_ops_oltp` |
293
293
  | Console scripts | none declared |
294
294
  | Entry points | none declared |
@@ -375,7 +375,7 @@ Choose `tigrbl-ops-oltp` when the quick-answer table matches your use case. Choo
375
375
 
376
376
  - Community: [Discord](https://discord.gg/K4YTAPapjR).
377
377
  - Issues: [GitHub Issues](https://github.com/tigrbl/tigrbl/issues).
378
- - Repository: [pkgs/core/tigrbl_ops_oltp](https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_ops_oltp).
378
+ - Repository: [pkgs/60_ops/tigrbl_ops_oltp](https://github.com/tigrbl/tigrbl/tree/master/pkgs/60_ops/tigrbl_ops_oltp).
379
379
 
380
380
  ## Package-local Boundary
381
381
 
@@ -386,7 +386,7 @@ This README is the package-local distribution entry point for `tigrbl-ops-oltp`.
386
386
  - Package status: governed package in the `tigrbl/tigrbl` workspace.
387
387
  - Governance source: [SSOT registry](https://github.com/tigrbl/tigrbl/blob/master/.ssot/registry.json).
388
388
  - Release evidence: [publish workflow](https://github.com/tigrbl/tigrbl/actions/workflows/publish.yml) validates package builds, tests, GitHub release assets, and PyPI publication for managed packages.
389
- - Local certification guard: `pkgs/core/tigrbl_tests/tests/unit/test_package_badges_and_notices.py` verifies every package README keeps the Discord badge, Apache 2.0 badge, explicit Python-version badge, `LICENSE`, and `NOTICE`.
389
+ - Local certification guard: `pkgs/97_tests/tigrbl_tests/tests/unit/test_package_badges_and_notices.py` verifies every package README keeps the Discord badge, Apache 2.0 badge, explicit Python-version badge, `LICENSE`, and `NOTICE`.
390
390
  - Scope note: this README documents the package boundary. Runtime feature support remains governed by `.ssot/` entities and the conformance docs linked below.
391
391
 
392
392
  ## License
@@ -5,7 +5,7 @@
5
5
  <a href="https://pypi.org/project/tigrbl-ops-oltp/"><img src="https://img.shields.io/pypi/v/tigrbl-ops-oltp?label=PyPI" alt="PyPI version for tigrbl-ops-oltp"/></a>
6
6
  <a href="https://pypi.org/project/tigrbl-ops-oltp/"><img src="https://static.pepy.tech/badge/tigrbl-ops-oltp" alt="Downloads for tigrbl-ops-oltp"/></a>
7
7
  <a href="https://discord.gg/K4YTAPapjR"><img src="https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord community for tigrbl-ops-oltp"/></a>
8
- <a href="https://github.com/tigrbl/tigrbl/blob/master/pkgs/core/tigrbl_ops_oltp/README.md"><img src="https://hits.sh/github.com/tigrbl/tigrbl/blob/master/pkgs/core/tigrbl_ops_oltp/README.md.svg?label=hits" alt="Repository hits for tigrbl-ops-oltp README"/></a>
8
+ <a href="https://github.com/tigrbl/tigrbl/blob/master/pkgs/60_ops/tigrbl_ops_oltp/README.md"><img src="https://hits.sh/github.com/tigrbl/tigrbl/blob/master/pkgs/60_ops/tigrbl_ops_oltp/README.md.svg?label=hits" alt="Repository hits for tigrbl-ops-oltp README"/></a>
9
9
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-525252" alt="Apache 2.0 license"/></a>
10
10
  <a href="pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab" alt="Python versions 3.10 | 3.11 | 3.12 | 3.13 | 3.14 for tigrbl-ops-oltp"/></a>
11
11
  <a href="https://github.com/tigrbl/tigrbl/blob/master/docs/README.md"><img src="https://img.shields.io/badge/workspace-core-1f6feb" alt="Workspace group for tigrbl-ops-oltp"/></a>
@@ -29,7 +29,7 @@ Framework maintainers and extension authors working near Tigrbl operation dispat
29
29
 
30
30
  ## Where does tigrbl-ops-oltp fit?
31
31
 
32
- `tigrbl-ops-oltp` lives at `pkgs/core/tigrbl_ops_oltp` and serves focused operation dispatch behavior inside the Tigrbl runtime model.
32
+ `tigrbl-ops-oltp` lives at `pkgs/60_ops/tigrbl_ops_oltp` and serves focused operation dispatch behavior inside the Tigrbl runtime model.
33
33
 
34
34
  ## How does tigrbl-ops-oltp work?
35
35
 
@@ -51,7 +51,7 @@ pip install tigrbl-ops-oltp
51
51
  | Surface | Value |
52
52
  |---|---|
53
53
  | PyPI package | [`tigrbl-ops-oltp`](https://pypi.org/project/tigrbl-ops-oltp/) |
54
- | Repository path | [`pkgs/core/tigrbl_ops_oltp`](https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_ops_oltp) |
54
+ | Repository path | [`pkgs/60_ops/tigrbl_ops_oltp`](https://github.com/tigrbl/tigrbl/tree/master/pkgs/60_ops/tigrbl_ops_oltp) |
55
55
  | Python import root | `tigrbl_ops_oltp` |
56
56
  | Console scripts | none declared |
57
57
  | Entry points | none declared |
@@ -138,7 +138,7 @@ Choose `tigrbl-ops-oltp` when the quick-answer table matches your use case. Choo
138
138
 
139
139
  - Community: [Discord](https://discord.gg/K4YTAPapjR).
140
140
  - Issues: [GitHub Issues](https://github.com/tigrbl/tigrbl/issues).
141
- - Repository: [pkgs/core/tigrbl_ops_oltp](https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_ops_oltp).
141
+ - Repository: [pkgs/60_ops/tigrbl_ops_oltp](https://github.com/tigrbl/tigrbl/tree/master/pkgs/60_ops/tigrbl_ops_oltp).
142
142
 
143
143
  ## Package-local Boundary
144
144
 
@@ -149,7 +149,7 @@ This README is the package-local distribution entry point for `tigrbl-ops-oltp`.
149
149
  - Package status: governed package in the `tigrbl/tigrbl` workspace.
150
150
  - Governance source: [SSOT registry](https://github.com/tigrbl/tigrbl/blob/master/.ssot/registry.json).
151
151
  - Release evidence: [publish workflow](https://github.com/tigrbl/tigrbl/actions/workflows/publish.yml) validates package builds, tests, GitHub release assets, and PyPI publication for managed packages.
152
- - Local certification guard: `pkgs/core/tigrbl_tests/tests/unit/test_package_badges_and_notices.py` verifies every package README keeps the Discord badge, Apache 2.0 badge, explicit Python-version badge, `LICENSE`, and `NOTICE`.
152
+ - Local certification guard: `pkgs/97_tests/tigrbl_tests/tests/unit/test_package_badges_and_notices.py` verifies every package README keeps the Discord badge, Apache 2.0 badge, explicit Python-version badge, `LICENSE`, and `NOTICE`.
153
153
  - Scope note: this README documents the package boundary. Runtime feature support remains governed by `.ssot/` entities and the conformance docs linked below.
154
154
 
155
155
  ## License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tigrbl-ops-oltp"
3
- version = "0.4.5.dev4"
3
+ version = "0.4.5.dev6"
4
4
  description = "Transactional OLTP operation handlers for Tigrbl CRUD, bulk, REST, JSON-RPC, and database-backed workloads."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -52,7 +52,7 @@ dependencies = [
52
52
  Organization = "https://github.com/tigrbl"
53
53
  Discord = "https://discord.gg/K4YTAPapjR"
54
54
  Homepage = "https://github.com/tigrbl/tigrbl"
55
- Repository = "https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_ops_oltp"
55
+ Repository = "https://github.com/tigrbl/tigrbl/tree/master/pkgs/60_ops/tigrbl_ops_oltp"
56
56
  Issues = "https://github.com/tigrbl/tigrbl/issues"
57
57
 
58
58
  [tool.uv.sources]
@@ -9,7 +9,6 @@ from .helpers import (
9
9
  AsyncSession,
10
10
  Session,
11
11
  sa_delete,
12
- _coerce_pk_value,
13
12
  _immutable_columns,
14
13
  _maybe_delete,
15
14
  _maybe_execute,
@@ -113,7 +112,7 @@ async def bulk_merge(
113
112
  to_create: List[Mapping[str, Any]] = []
114
113
  for r in rows or ():
115
114
  r = dict(r)
116
- ident = _coerce_pk_value(model, r.get(pk))
115
+ ident = r.get(pk)
117
116
  if ident is not None:
118
117
  existing = await _maybe_get(db, model, ident)
119
118
  if existing is not None:
@@ -18,13 +18,12 @@ except Exception: # pragma: no cover
18
18
  from .model import (
19
19
  _pk_columns,
20
20
  _single_pk_name,
21
- _coerce_pk_value,
22
21
  _model_columns,
23
22
  _colspecs,
24
23
  _filter_in_values,
25
24
  _immutable_columns,
26
25
  )
27
- from .filters import _CANON_OPS, _coerce_filters, _apply_filters, _apply_sort
26
+ from .filters import _CANON_OPS, _authorize_filters, _apply_filters, _apply_sort
28
27
  from .db import (
29
28
  _is_async_db,
30
29
  _maybe_get,
@@ -51,8 +50,7 @@ __all__ = [
51
50
  "_apply_filters",
52
51
  "_apply_sort",
53
52
  "_CANON_OPS",
54
- "_coerce_filters",
55
- "_coerce_pk_value",
53
+ "_authorize_filters",
56
54
  "_colspecs",
57
55
  "_filter_in_values",
58
56
  "_immutable_columns",
@@ -4,6 +4,8 @@ from typing import Any, Dict, Iterable, Mapping, Optional, Sequence
4
4
 
5
5
  import logging
6
6
 
7
+ from tigrbl_typing.status.exceptions import HTTPException
8
+
7
9
  from . import select, and_, asc, desc
8
10
  from .model import _model_columns, _colspecs
9
11
 
@@ -36,29 +38,33 @@ _CANON_OPS = {
36
38
  }
37
39
 
38
40
 
39
- def _coerce_filters(
40
- model: type, filters: Optional[Mapping[str, Any]]
41
+ def _authorize_filters(
42
+ model: type, filters: Optional[Mapping[str, Any]], *, strict: bool = False
41
43
  ) -> Dict[str, Any]:
42
- logger.debug("_coerce_filters called with filters=%s", filters)
44
+ logger.debug("_authorize_filters called with filters=%s", filters)
43
45
  cols = set(_model_columns(model))
44
46
  specs = _colspecs(model)
45
47
  raw = dict(filters or {})
46
48
  out: Dict[str, Any] = {}
47
49
  for k, v in raw.items():
48
50
  name, op = k.split("__", 1) if "__" in k else (k, "eq")
49
- if name not in cols:
50
- continue
51
51
  canon = _CANON_OPS.get(op, op)
52
52
  sp = specs.get(name)
53
- if sp is not None:
54
- io = getattr(sp, "io", None)
55
- ops = set(getattr(io, "filter_ops", ()) or [])
56
- ops = {_CANON_OPS.get(o, o) for o in ops}
57
- if not ops or canon not in ops:
58
- continue
53
+ io = getattr(sp, "io", None)
54
+ ops = {_CANON_OPS.get(o, o) for o in getattr(io, "filter_ops", ()) or ()}
55
+ if (
56
+ name not in cols
57
+ or (sp is not None and canon not in ops)
58
+ or (strict and op not in _CANON_OPS)
59
+ ):
60
+ if strict:
61
+ raise HTTPException(
62
+ status_code=422, detail=f"Unsupported clear filter: {k}"
63
+ )
64
+ continue
59
65
  key = name if canon == "eq" else f"{name}__{canon}"
60
66
  out[key] = v
61
- logger.debug("_coerce_filters returning %s", out)
67
+ logger.debug("_authorize_filters returning %s", out)
62
68
  return out
63
69
 
64
70
 
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import datetime as dt
4
3
  from functools import lru_cache
5
4
  from typing import Any, Dict, Mapping, Tuple
6
5
 
@@ -61,11 +60,11 @@ def _filter_plan_cache_token(model: type, verb: str) -> tuple[int, int, int, int
61
60
  @lru_cache(maxsize=512)
62
61
  def _filter_plan_cached(
63
62
  model: type, verb: str, cache_token: tuple[int, int, int, int, int]
64
- ) -> tuple[Mapping[str, bool], frozenset[str], Mapping[str, Any]]:
63
+ ) -> tuple[Mapping[str, bool], frozenset[str]]:
65
64
  del cache_token
66
65
  specs = _colspecs(model)
67
66
  if not specs:
68
- return {}, frozenset(), _table_python_types(model)
67
+ return {}, frozenset()
69
68
 
70
69
  allowed_by_field: dict[str, bool] = {}
71
70
  for name, sp in specs.items():
@@ -80,28 +79,7 @@ def _filter_plan_cached(
80
79
  )
81
80
  allowed_by_field[name] = allowed
82
81
 
83
- return (
84
- allowed_by_field,
85
- _excluded_schema_fields(model, verb),
86
- _table_python_types(model),
87
- )
88
-
89
-
90
- @lru_cache(maxsize=256)
91
- def _table_python_types(model: type) -> Mapping[str, Any]:
92
- table = getattr(model, "__table__", None)
93
- columns = getattr(table, "columns", None)
94
- if columns is None:
95
- return {}
96
- resolved: dict[str, Any] = {}
97
- for col in columns:
98
- try:
99
- py_t = getattr(getattr(col, "type", None), "python_type", None)
100
- except Exception:
101
- py_t = None
102
- if py_t is not None:
103
- resolved[getattr(col, "name", "")] = py_t
104
- return resolved
82
+ return allowed_by_field, _excluded_schema_fields(model, verb)
105
83
 
106
84
 
107
85
  def _pk_columns(model: type) -> Tuple[Any, ...]:
@@ -124,23 +102,6 @@ def _single_pk_name(model: type) -> str:
124
102
  return name
125
103
 
126
104
 
127
- def _coerce_pk_value(model: type, value: Any) -> Any:
128
- if value is None:
129
- return None
130
- try:
131
- col = _pk_columns(model)[0]
132
- py_type = col.type.python_type # type: ignore[attr-defined]
133
- except Exception: # pragma: no cover - best effort
134
- return value
135
- if isinstance(value, py_type):
136
- return value
137
- try:
138
- coerced = py_type(value)
139
- return coerced
140
- except Exception: # pragma: no cover - fallback to original
141
- return value
142
-
143
-
144
105
  def _model_columns(model: type) -> Tuple[str, ...]:
145
106
  table = getattr(model, "__table__", None)
146
107
  if table is None:
@@ -156,7 +117,7 @@ def _colspecs(model: type) -> Mapping[str, Any]:
156
117
  def _filter_in_values(
157
118
  model: type, data: Mapping[str, Any], verb: str
158
119
  ) -> Dict[str, Any]:
159
- allowed_by_field, excluded_fields, column_types = _filter_plan_cached(
120
+ allowed_by_field, excluded_fields = _filter_plan_cached(
160
121
  model, verb, _filter_plan_cache_token(model, verb)
161
122
  )
162
123
  if not allowed_by_field and not excluded_fields:
@@ -171,19 +132,7 @@ def _filter_in_values(
171
132
  out[k] = v
172
133
  continue
173
134
  if allowed:
174
- try:
175
- py_t = column_types.get(k)
176
- if py_t is not None and v is not None and not isinstance(v, py_t):
177
- if py_t in (dt.datetime, dt.date) and isinstance(v, str):
178
- parsed = py_t.fromisoformat(v)
179
- out[k] = parsed
180
- else:
181
- out[k] = py_t(v)
182
- else:
183
- out[k] = v
184
- except Exception:
185
- # Best effort coercion only; preserve original value on failure.
186
- out[k] = v
135
+ out[k] = v
187
136
  return out
188
137
 
189
138
 
@@ -21,8 +21,7 @@ from .helpers import (
21
21
  sa_delete,
22
22
  _apply_filters,
23
23
  _apply_sort,
24
- _coerce_filters,
25
- _coerce_pk_value,
24
+ _authorize_filters,
26
25
  _filter_in_values,
27
26
  _immutable_columns,
28
27
  _maybe_delete,
@@ -38,6 +37,20 @@ from .helpers import (
38
37
  logger = logging.getLogger("uvicorn")
39
38
 
40
39
  _MAPPED_MODELS: "WeakSet[type]" = WeakSet()
40
+ _NO_NATIVE_OPERATION = object()
41
+
42
+
43
+ async def _call_native_operation(
44
+ db: Any,
45
+ name: str,
46
+ *args: Any,
47
+ **kwargs: Any,
48
+ ) -> Any:
49
+ operation = getattr(db, name, None)
50
+ if not callable(operation):
51
+ return _NO_NATIVE_OPERATION
52
+ result = operation(*args, **kwargs)
53
+ return await result if inspect.isawaitable(result) else result
41
54
 
42
55
 
43
56
  @lru_cache(maxsize=512)
@@ -127,9 +140,12 @@ async def create(
127
140
  Flush-only (commit happens later in TX_COMMIT).
128
141
  """
129
142
  logger.debug("create called with model=%s data=%s", model, data)
130
- _ensure_model_mapped(model)
131
143
  data = _filter_in_values(model, data or {}, "create")
132
144
  _validate_enum_values(model, data)
145
+ native = await _call_native_operation(db, "create_record", model, data)
146
+ if native is not _NO_NATIVE_OPERATION:
147
+ return native
148
+ _ensure_model_mapped(model)
133
149
  obj = model(**data)
134
150
  if hasattr(db, "add"):
135
151
  try:
@@ -170,6 +186,9 @@ async def read(model: type, ident: Any, db: Union[Session, AsyncSession]) -> Any
170
186
  Load a single row by primary key. Raises NoResultFound if not found.
171
187
  """
172
188
  logger.debug("read called with model=%s ident=%s", model, ident)
189
+ native = await _call_native_operation(db, "read_record", model, ident)
190
+ if native is not _NO_NATIVE_OPERATION:
191
+ return native
173
192
  _ensure_model_mapped(model)
174
193
  obj = await _maybe_get(db, model, ident)
175
194
  if obj is None:
@@ -233,7 +252,6 @@ async def merge(
233
252
  logger.debug("merge called with model=%s ident=%s data=%s", model, ident, data)
234
253
  _ensure_model_mapped(model)
235
254
  pk = _single_pk_name(model)
236
- ident = _coerce_pk_value(model, ident)
237
255
  obj = await _maybe_get(db, model, ident)
238
256
 
239
257
  verb = "update" if obj is not None else "create"
@@ -260,6 +278,9 @@ async def delete(
260
278
  Flush-only.
261
279
  """
262
280
  logger.debug("delete called with model=%s ident=%s", model, ident)
281
+ native = await _call_native_operation(db, "delete_record", model, ident)
282
+ if native is not _NO_NATIVE_OPERATION:
283
+ return native
263
284
  _ensure_model_mapped(model)
264
285
  obj = await read(model, ident, db)
265
286
  await _maybe_delete(db, obj)
@@ -280,13 +301,23 @@ async def list(*_args: Any, **_kwargs: Any) -> List[Any]: # noqa: A001 (shadow
280
301
  """
281
302
  logger.debug("list called with args=%s kwargs=%s", _args, _kwargs)
282
303
  model, params = _normalize_list_call(_args, _kwargs)
283
- _ensure_model_mapped(model)
284
-
285
- filters: Mapping[str, Any] = _coerce_filters(model, params["filters"])
304
+ filters: Mapping[str, Any] = _authorize_filters(model, params["filters"])
286
305
  skip: Optional[int] = params["skip"]
287
306
  limit: Optional[int] = params["limit"]
288
307
  db: Union[Session, AsyncSession] = params["db"]
289
308
  sort = params["sort"]
309
+ native = await _call_native_operation(
310
+ db,
311
+ "list_records",
312
+ model,
313
+ filters=filters,
314
+ skip=skip,
315
+ limit=limit,
316
+ sort=sort,
317
+ )
318
+ if native is not _NO_NATIVE_OPERATION:
319
+ return native
320
+ _ensure_model_mapped(model)
290
321
 
291
322
  if select is None: # pragma: no cover
292
323
  # Fallback: legacy query API
@@ -331,13 +362,23 @@ async def clear(
331
362
  # Reuse normalizer to accept the same shapes
332
363
  logger.debug("clear called with args=%s kwargs=%s", args, kwargs)
333
364
  model, params = _normalize_list_call(args, kwargs)
334
- _ensure_model_mapped(model)
335
- raw_filters: Mapping[str, Any] = params["filters"]
365
+ filters: Mapping[str, Any] = _authorize_filters(
366
+ model, params["filters"], strict=True
367
+ )
336
368
  db: Union[Session, AsyncSession] = params["db"]
369
+ native = await _call_native_operation(
370
+ db,
371
+ "clear_records",
372
+ model,
373
+ filters=filters,
374
+ )
375
+ if native is not _NO_NATIVE_OPERATION:
376
+ return native
377
+ _ensure_model_mapped(model)
337
378
 
338
379
  if sa_delete is None: # pragma: no cover
339
380
  # Fallback path: manual iteration
340
- items = await list(model, raw_filters, db=db)
381
+ items = await list(model, filters, db=db)
341
382
  n = 0
342
383
  for obj in items:
343
384
  await _maybe_delete(db, obj)
@@ -345,8 +386,7 @@ async def clear(
345
386
  await _maybe_flush(db)
346
387
  return {"deleted": n}
347
388
 
348
- filt = _coerce_filters(model, raw_filters)
349
- where = _apply_filters(model, filt)
389
+ where = _apply_filters(model, filters)
350
390
  stmt = sa_delete(model)
351
391
  if where is not None:
352
392
  stmt = stmt.where(where)
@@ -361,10 +401,17 @@ async def clear(
361
401
  async def count(*args: Any, **kwargs: Any) -> Dict[str, int]:
362
402
  """Count rows matching equality filters."""
363
403
  model, params = _normalize_list_call(args, kwargs)
364
- _ensure_model_mapped(model)
365
-
366
- filters: Mapping[str, Any] = _coerce_filters(model, params["filters"])
404
+ filters: Mapping[str, Any] = _authorize_filters(model, params["filters"])
367
405
  db: Union[Session, AsyncSession] = params["db"]
406
+ native = await _call_native_operation(
407
+ db,
408
+ "count_records",
409
+ model,
410
+ filters=filters,
411
+ )
412
+ if native is not _NO_NATIVE_OPERATION:
413
+ return native
414
+ _ensure_model_mapped(model)
368
415
  stmt = select(func.count()).select_from(model)
369
416
  where = _apply_filters(model, filters)
370
417
  if where is not None:
@@ -374,8 +421,13 @@ async def count(*args: Any, **kwargs: Any) -> Dict[str, int]:
374
421
  return {"count": int(value or 0)}
375
422
 
376
423
 
377
- async def exists(model: type, ident: Any, db: Union[Session, AsyncSession]) -> Dict[str, bool]:
424
+ async def exists(
425
+ model: type, ident: Any, db: Union[Session, AsyncSession]
426
+ ) -> Dict[str, bool]:
378
427
  """Check whether a row exists by primary key."""
428
+ native = await _call_native_operation(db, "exists_record", model, ident)
429
+ if native is not _NO_NATIVE_OPERATION:
430
+ return native
379
431
  _ensure_model_mapped(model)
380
432
  obj = await _maybe_get(db, model, ident)
381
433
  return {"exists": obj is not None}