tigrbl-ops-oltp 0.1.0.dev1__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.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: tigrbl-ops-oltp
3
+ Version: 0.1.0.dev1
4
+ Summary: OLTP operation implementations for Tigrbl, including CRUD and bulk handlers.
5
+ License-Expression: Apache-2.0
6
+ Keywords: tigrbl,sdk,standards,framework,oltp,crud
7
+ Author: Jacob Stewart
8
+ Author-email: jacob@swarmauri.com
9
+ Requires-Python: >=3.10,<3.13
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Development Status :: 1 - Planning
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Requires-Dist: tigrbl-base
19
+ Requires-Dist: tigrbl-core
20
+ Requires-Dist: tigrbl-orm
21
+ Description-Content-Type: text/markdown
22
+
23
+ ![Tigrbl branding](https://github.com/swarmauri/swarmauri-sdk/blob/a170683ecda8ca1c4f912c966d4499649ffb8224/assets/tigrbl.brand.theme.svg)
24
+
25
+ # tigrbl-ops-oltp
26
+
27
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/tigrbl-ops-oltp.svg) ![Hits](https://hits.sh/github.com/swarmauri/swarmauri-sdk.svg) ![Python Versions](https://img.shields.io/pypi/pyversions/tigrbl-ops-oltp.svg) ![License](https://img.shields.io/pypi/l/tigrbl-ops-oltp.svg) ![Version](https://img.shields.io/pypi/v/tigrbl-ops-oltp.svg)
28
+
29
+ ## Features
30
+
31
+ - Transactional OLTP operation implementations for Tigrbl.
32
+ - Includes canonical CRUD and bulk operation executors.
33
+ - Supports Python 3.10 through 3.12.
34
+
35
+ ## Installation
36
+
37
+ ### uv
38
+
39
+ ```bash
40
+ uv add tigrbl-ops-oltp
41
+ ```
42
+
43
+ ### pip
44
+
45
+ ```bash
46
+ pip install tigrbl-ops-oltp
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Import operation callables or parameter markers from `tigrbl_ops_oltp.crud`.
52
+
@@ -0,0 +1,29 @@
1
+ ![Tigrbl branding](https://github.com/swarmauri/swarmauri-sdk/blob/a170683ecda8ca1c4f912c966d4499649ffb8224/assets/tigrbl.brand.theme.svg)
2
+
3
+ # tigrbl-ops-oltp
4
+
5
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/tigrbl-ops-oltp.svg) ![Hits](https://hits.sh/github.com/swarmauri/swarmauri-sdk.svg) ![Python Versions](https://img.shields.io/pypi/pyversions/tigrbl-ops-oltp.svg) ![License](https://img.shields.io/pypi/l/tigrbl-ops-oltp.svg) ![Version](https://img.shields.io/pypi/v/tigrbl-ops-oltp.svg)
6
+
7
+ ## Features
8
+
9
+ - Transactional OLTP operation implementations for Tigrbl.
10
+ - Includes canonical CRUD and bulk operation executors.
11
+ - Supports Python 3.10 through 3.12.
12
+
13
+ ## Installation
14
+
15
+ ### uv
16
+
17
+ ```bash
18
+ uv add tigrbl-ops-oltp
19
+ ```
20
+
21
+ ### pip
22
+
23
+ ```bash
24
+ pip install tigrbl-ops-oltp
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Import operation callables or parameter markers from `tigrbl_ops_oltp.crud`.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "tigrbl-ops-oltp"
3
+ version = "0.1.0.dev1"
4
+ description = "OLTP operation implementations for Tigrbl, including CRUD and bulk handlers."
5
+ license = "Apache-2.0"
6
+ readme = "README.md"
7
+ repository = "http://github.com/swarmauri/swarmauri-sdk"
8
+ requires-python = ">=3.10,<3.13"
9
+ classifiers = [
10
+ "License :: OSI Approved :: Apache Software License",
11
+ "Development Status :: 1 - Planning",
12
+ "Programming Language :: Python :: 3.10",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ ]
19
+ authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
20
+ dependencies = [
21
+ "tigrbl-orm",
22
+ "tigrbl-base",
23
+ "tigrbl-core",
24
+ ]
25
+ keywords = ["tigrbl", "sdk", "standards", "framework", "oltp", "crud"]
26
+
27
+ [tool.uv.sources]
28
+ "tigrbl-orm" = { workspace = true }
29
+ "tigrbl-base" = { workspace = true }
30
+ "tigrbl-core" = { workspace = true }
31
+
32
+ [build-system]
33
+ requires = ["poetry-core>=1.0.0"]
34
+ build-backend = "poetry.core.masonry.api"
35
+
36
+
37
+ [tool.poetry]
38
+ packages = [
39
+ { include = "tigrbl_ops_oltp" },
40
+ ]
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pytest>=8.0",
45
+ "ruff>=0.9",
46
+ ]
@@ -0,0 +1,22 @@
1
+ """OLTP operation implementations for Tigrbl."""
2
+
3
+ from .crud import ( # noqa: F401
4
+ Body,
5
+ Header,
6
+ Param,
7
+ Path,
8
+ Query,
9
+ bulk_create,
10
+ bulk_delete,
11
+ bulk_merge,
12
+ bulk_replace,
13
+ bulk_update,
14
+ clear,
15
+ create,
16
+ delete,
17
+ list,
18
+ merge,
19
+ read,
20
+ replace,
21
+ update,
22
+ )
@@ -0,0 +1,43 @@
1
+ from .ops import (
2
+ create,
3
+ read,
4
+ update,
5
+ replace,
6
+ merge,
7
+ delete,
8
+ list as _list,
9
+ clear,
10
+ )
11
+ from .bulk import (
12
+ bulk_create,
13
+ bulk_update,
14
+ bulk_replace,
15
+ bulk_merge,
16
+ bulk_delete,
17
+ )
18
+
19
+ from .params import Body, Header, Param, Path, Query
20
+
21
+ # Public alias named exactly `list` to preserve API surface
22
+ list = _list # noqa: A001 - intentional shadow of built-in for public API
23
+
24
+ __all__ = [
25
+ "Header",
26
+ "Path",
27
+ "Query",
28
+ "Body",
29
+ "Param",
30
+ "create",
31
+ "read",
32
+ "update",
33
+ "replace",
34
+ "merge",
35
+ "delete",
36
+ "list",
37
+ "clear",
38
+ "bulk_create",
39
+ "bulk_update",
40
+ "bulk_replace",
41
+ "bulk_merge",
42
+ "bulk_delete",
43
+ ]
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable, List, Mapping, Union
4
+
5
+ import builtins as _builtins
6
+ import logging
7
+
8
+ from .helpers import (
9
+ AsyncSession,
10
+ Session,
11
+ sa_delete,
12
+ _coerce_pk_value,
13
+ _immutable_columns,
14
+ _maybe_delete,
15
+ _maybe_execute,
16
+ _maybe_flush,
17
+ _maybe_get,
18
+ _set_attrs,
19
+ _single_pk_name,
20
+ _validate_enum_values,
21
+ )
22
+ from .ops import merge, read
23
+
24
+ logger = logging.getLogger("uvicorn")
25
+
26
+
27
+ async def bulk_create(
28
+ model: type, rows: Iterable[Mapping[str, Any]], db: Union[Session, AsyncSession]
29
+ ) -> List[Any]:
30
+ """
31
+ Insert many rows. Returns the list of persisted instances.
32
+ Flush-only.
33
+ """
34
+ logger.debug("bulk_create called with model=%s rows=%s", model, rows)
35
+ items_data = [dict(r) for r in (rows or ())]
36
+ for r in items_data:
37
+ _validate_enum_values(model, r)
38
+ items = [model(**r) for r in items_data]
39
+ if not items:
40
+ logger.debug("bulk_create no items to create")
41
+ return []
42
+ if hasattr(db, "add_all"):
43
+ db.add_all(items) # type: ignore[attr-defined]
44
+ else:
45
+ for it in items:
46
+ db.add(it) # type: ignore[attr-defined]
47
+ await _maybe_flush(db)
48
+ logger.debug("bulk_create persisted %d items", len(items))
49
+ return items
50
+
51
+
52
+ async def bulk_update(
53
+ model: type, rows: Iterable[Mapping[str, Any]], db: Union[Session, AsyncSession]
54
+ ) -> List[Any]:
55
+ """
56
+ Update many rows by PK. Each row must include the PK field.
57
+ Returns the list of updated instances. Flush-only.
58
+ """
59
+ logger.debug("bulk_update called with model=%s rows=%s", model, rows)
60
+ pk = _single_pk_name(model)
61
+ skip = _immutable_columns(model, "update")
62
+ updated: List[Any] = []
63
+ for r in rows or ():
64
+ r = dict(r)
65
+ _validate_enum_values(model, r)
66
+ ident = r.get(pk)
67
+ if ident is None:
68
+ raise ValueError(f"bulk_update requires '{pk}' in each row")
69
+ obj = await read(model, ident, db)
70
+ data = {k: v for k, v in r.items() if k != pk}
71
+ _set_attrs(obj, data, allow_missing=True, skip=skip)
72
+ updated.append(obj)
73
+ if updated:
74
+ await _maybe_flush(db)
75
+ logger.debug("bulk_update updated %d items", len(updated))
76
+ return updated
77
+
78
+
79
+ async def bulk_replace(
80
+ model: type, rows: Iterable[Mapping[str, Any]], db: Union[Session, AsyncSession]
81
+ ) -> List[Any]:
82
+ """
83
+ Replace many rows by PK. Each row must include the PK field.
84
+ Missing attributes are nulled (except PK). Flush-only.
85
+ """
86
+ logger.debug("bulk_replace called with model=%s rows=%s", model, rows)
87
+ pk = _single_pk_name(model)
88
+ skip = _immutable_columns(model, "replace")
89
+ replaced: List[Any] = []
90
+ for r in rows or ():
91
+ r = dict(r)
92
+ _validate_enum_values(model, r)
93
+ ident = r.get(pk)
94
+ if ident is None:
95
+ raise ValueError(f"bulk_replace requires '{pk}' in each row")
96
+ obj = await read(model, ident, db)
97
+ data = {k: v for k, v in r.items() if k != pk}
98
+ _set_attrs(obj, data, allow_missing=False, skip=skip)
99
+ replaced.append(obj)
100
+ if replaced:
101
+ await _maybe_flush(db)
102
+ logger.debug("bulk_replace replaced %d items", len(replaced))
103
+ return replaced
104
+
105
+
106
+ async def bulk_merge(
107
+ model: type, rows: Iterable[Mapping[str, Any]], db: Union[Session, AsyncSession]
108
+ ) -> List[Any]:
109
+ """Merge many rows by primary key with upsert semantics."""
110
+ logger.debug("bulk_merge called with model=%s rows=%s", model, rows)
111
+ pk = _single_pk_name(model)
112
+ results: List[Any] = []
113
+ to_create: List[Mapping[str, Any]] = []
114
+ for r in rows or ():
115
+ r = dict(r)
116
+ ident = _coerce_pk_value(model, r.get(pk))
117
+ if ident is not None:
118
+ existing = await _maybe_get(db, model, ident)
119
+ if existing is not None:
120
+ data = {k: v for k, v in r.items() if k != pk}
121
+ merged = await merge(model, ident, data, db=db)
122
+ results.append(merged)
123
+ continue
124
+ r[pk] = ident
125
+ to_create.append(r)
126
+ if to_create:
127
+ created = await bulk_create(model, to_create, db)
128
+ results.extend(created)
129
+ logger.debug(
130
+ "bulk_merge returning %d results (%d created)",
131
+ len(results),
132
+ len(to_create),
133
+ )
134
+ return results
135
+
136
+
137
+ async def bulk_delete(
138
+ model: type, idents: Iterable[Any], db: Union[Session, AsyncSession]
139
+ ) -> Dict[str, int]:
140
+ """
141
+ Delete many rows by a sequence of PK values. Returns {"deleted": N}.
142
+ Flush-only.
143
+ """
144
+ logger.debug("bulk_delete called with model=%s idents=%s", model, idents)
145
+ pk_name = _single_pk_name(model)
146
+ id_seq = _builtins.list(idents or ())
147
+ if not id_seq:
148
+ logger.debug("bulk_delete no ids supplied")
149
+ return {"deleted": 0}
150
+
151
+ if sa_delete is not None:
152
+ col = getattr(model, pk_name)
153
+ stmt = sa_delete(model).where(col.in_(id_seq)) # type: ignore[attr-defined]
154
+ res = await _maybe_execute(db, stmt)
155
+ await _maybe_flush(db)
156
+ n = int(getattr(res, "rowcount", 0) or 0)
157
+ logger.debug("bulk_delete removed %d rows via stmt", n)
158
+ return {"deleted": n}
159
+
160
+ n = 0
161
+ for ident in id_seq:
162
+ obj = await read(model, ident, db)
163
+ await _maybe_delete(db, obj)
164
+ n += 1
165
+ await _maybe_flush(db)
166
+ logger.debug("bulk_delete removed %d rows individually", n)
167
+ return {"deleted": n}
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ from sqlalchemy import select, delete as sa_delete, and_, asc, desc, Enum as SAEnum
5
+ from sqlalchemy.orm import Session
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy.orm.exc import NoResultFound # type: ignore
8
+ except Exception: # pragma: no cover
9
+ select = sa_delete = and_ = asc = desc = None # type: ignore
10
+ SAEnum = None # type: ignore
11
+ Session = object # type: ignore
12
+ AsyncSession = object # type: ignore
13
+
14
+ class NoResultFound(LookupError): # type: ignore
15
+ pass
16
+
17
+
18
+ from .model import (
19
+ _pk_columns,
20
+ _single_pk_name,
21
+ _coerce_pk_value,
22
+ _model_columns,
23
+ _colspecs,
24
+ _filter_in_values,
25
+ _immutable_columns,
26
+ )
27
+ from .filters import _CANON_OPS, _coerce_filters, _apply_filters, _apply_sort
28
+ from .db import (
29
+ _is_async_db,
30
+ _maybe_get,
31
+ _maybe_execute,
32
+ _maybe_flush,
33
+ _maybe_delete,
34
+ _set_attrs,
35
+ )
36
+ from .enum import _validate_enum_values
37
+ from .normalize import (
38
+ _normalize_list_call,
39
+ _pop_bound_self,
40
+ _extract_db,
41
+ _as_pos_int,
42
+ )
43
+
44
+ __all__ = [
45
+ "AsyncSession",
46
+ "Session",
47
+ "NoResultFound",
48
+ "select",
49
+ "sa_delete",
50
+ "_apply_filters",
51
+ "_apply_sort",
52
+ "_CANON_OPS",
53
+ "_coerce_filters",
54
+ "_coerce_pk_value",
55
+ "_colspecs",
56
+ "_filter_in_values",
57
+ "_immutable_columns",
58
+ "_is_async_db",
59
+ "_maybe_delete",
60
+ "_maybe_execute",
61
+ "_maybe_flush",
62
+ "_maybe_get",
63
+ "_model_columns",
64
+ "_normalize_list_call",
65
+ "_pop_bound_self",
66
+ "_extract_db",
67
+ "_as_pos_int",
68
+ "_pk_columns",
69
+ "_set_attrs",
70
+ "_single_pk_name",
71
+ "_validate_enum_values",
72
+ "SAEnum",
73
+ "asc",
74
+ "desc",
75
+ "and_",
76
+ ]
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Sequence, Union
4
+
5
+ import logging
6
+
7
+ from . import AsyncSession, Session
8
+ from .model import _model_columns, _single_pk_name
9
+
10
+ logger = logging.getLogger("uvicorn")
11
+
12
+
13
+ def _is_async_db(db: Any) -> bool:
14
+ logger.debug("_is_async_db called with db=%s", db)
15
+ result = isinstance(db, AsyncSession) or hasattr(db, "run_sync")
16
+ logger.debug("_is_async_db returning %s", result)
17
+ return result
18
+
19
+
20
+ async def _maybe_get(db: Union[Session, AsyncSession], model: type, pk_value: Any):
21
+ logger.debug("_maybe_get model=%s pk_value=%s", model, pk_value)
22
+ if _is_async_db(db):
23
+ result = await db.get(model, pk_value) # type: ignore[attr-defined]
24
+ else:
25
+ result = db.get(model, pk_value) # type: ignore[attr-defined]
26
+ logger.debug("_maybe_get returning %s", result)
27
+ return result
28
+
29
+
30
+ async def _maybe_execute(db: Union[Session, AsyncSession], stmt: Any):
31
+ logger.debug("_maybe_execute stmt=%s", stmt)
32
+ if _is_async_db(db):
33
+ result = await db.execute(stmt) # type: ignore[attr-defined]
34
+ else:
35
+ result = db.execute(stmt) # type: ignore[attr-defined]
36
+ logger.debug("_maybe_execute returning %s", result)
37
+ return result
38
+
39
+
40
+ async def _maybe_flush(db: Union[Session, AsyncSession]) -> None:
41
+ logger.debug("_maybe_flush called")
42
+ if _is_async_db(db):
43
+ await db.flush() # type: ignore[attr-defined]
44
+ else:
45
+ db.flush() # type: ignore[attr-defined]
46
+ logger.debug("_maybe_flush completed")
47
+
48
+
49
+ async def _maybe_delete(db: Union[Session, AsyncSession], obj: Any) -> None:
50
+ logger.debug("_maybe_delete called with obj=%s", obj)
51
+ if not hasattr(db, "delete"):
52
+ logger.debug("_maybe_delete skipping delete; no attribute")
53
+ return
54
+ if _is_async_db(db):
55
+ await db.delete(obj) # type: ignore[attr-defined]
56
+ else:
57
+ db.delete(obj) # type: ignore[attr-defined]
58
+ logger.debug("_maybe_delete completed for obj=%s", obj)
59
+
60
+
61
+ def _set_attrs(
62
+ obj: Any,
63
+ values: Mapping[str, Any],
64
+ *,
65
+ allow_missing: bool = True,
66
+ skip: Sequence[str] = (),
67
+ ) -> None:
68
+ logger.debug(
69
+ "_set_attrs called on obj=%s values=%s allow_missing=%s skip=%s",
70
+ obj,
71
+ values,
72
+ allow_missing,
73
+ skip,
74
+ )
75
+ cols = set(_model_columns(type(obj)))
76
+ pk = _single_pk_name(type(obj))
77
+ skip_set = set(skip) | {pk}
78
+
79
+ if allow_missing:
80
+ for k, v in values.items():
81
+ if k in cols and k not in skip_set:
82
+ setattr(obj, k, v)
83
+ else:
84
+ for c in cols:
85
+ if c in skip_set:
86
+ continue
87
+ if c in values:
88
+ setattr(obj, c, values[c])
89
+ else:
90
+ setattr(obj, c, None)
91
+ logger.debug("_set_attrs completed for obj=%s", obj)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+ import builtins as _builtins
5
+ import logging
6
+
7
+ from . import SAEnum
8
+
9
+ logger = logging.getLogger("uvicorn")
10
+
11
+
12
+ def _validate_enum_values(model: type, values: Mapping[str, Any]) -> None:
13
+ logger.debug("_validate_enum_values called with model=%s values=%s", model, values)
14
+ if not values or SAEnum is None:
15
+ logger.debug("_validate_enum_values no validation needed")
16
+ return
17
+
18
+ table = getattr(model, "__table__", None)
19
+ if table is None:
20
+ return
21
+
22
+ get = getattr(table.c, "get", None)
23
+
24
+ for key, v in values.items():
25
+ col = get(key) if get else None
26
+ if col is None:
27
+ try:
28
+ col = table.c[key] # type: ignore[index]
29
+ except Exception:
30
+ col = None
31
+ if col is None:
32
+ continue
33
+
34
+ col_type = getattr(col, "type", None)
35
+ if col_type is None or not isinstance(col_type, SAEnum):
36
+ continue
37
+
38
+ if v is None:
39
+ continue
40
+
41
+ enum_cls = getattr(col_type, "enum_class", None)
42
+ if enum_cls is not None:
43
+ try:
44
+ import enum as _enum
45
+ except Exception: # pragma: no cover
46
+ _enum = None
47
+
48
+ if _enum is not None and isinstance(v, _enum.Enum):
49
+ if isinstance(v, enum_cls):
50
+ continue
51
+ logger.debug(
52
+ "_validate_enum_values invalid value %s for enum %s", v, enum_cls
53
+ )
54
+ raise LookupError(
55
+ f"{v!r} is not among the defined enum values. "
56
+ f"Enum name: {enum_cls.__name__}. "
57
+ f"Possible values: {', '.join([e.value for e in enum_cls])}"
58
+ )
59
+
60
+ allowed_values = [e.value for e in enum_cls]
61
+ allowed_names = [e.name for e in enum_cls]
62
+ if isinstance(v, str) and (v in allowed_values or v in allowed_names):
63
+ continue
64
+
65
+ logger.debug(
66
+ "_validate_enum_values invalid value %s for enum %s", v, enum_cls
67
+ )
68
+ raise LookupError(
69
+ f"{v!r} is not among the defined enum values. "
70
+ f"Enum name: {enum_cls.__name__}. "
71
+ f"Possible values: {', '.join(allowed_values)}"
72
+ )
73
+ else:
74
+ allowed = _builtins.list(getattr(col_type, "enums", []) or [])
75
+ if isinstance(v, str) and v in allowed:
76
+ continue
77
+ logger.debug(
78
+ "_validate_enum_values invalid value %s for enum %s", v, col_type
79
+ )
80
+ raise LookupError(
81
+ f"{v!r} is not among the defined enum values. "
82
+ f"Enum name: {getattr(col_type, 'name', 'Enum')}. "
83
+ f"Possible values: {', '.join(allowed) if allowed else '(none)'}"
84
+ )
85
+ logger.debug("_validate_enum_values completed")
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable, Mapping, Optional, Sequence
4
+
5
+ import logging
6
+
7
+ from . import select, and_, asc, desc
8
+ from .model import _model_columns, _colspecs
9
+
10
+ logger = logging.getLogger("uvicorn")
11
+
12
+ _CANON_OPS = {
13
+ "eq": "eq",
14
+ "=": "eq",
15
+ "==": "eq",
16
+ "ne": "ne",
17
+ "!=": "ne",
18
+ "<>": "ne",
19
+ "lt": "lt",
20
+ "<": "lt",
21
+ "gt": "gt",
22
+ ">": "gt",
23
+ "lte": "lte",
24
+ "le": "lte",
25
+ "<=": "lte",
26
+ "gte": "gte",
27
+ "ge": "gte",
28
+ ">=": "gte",
29
+ "like": "like",
30
+ "not_like": "not_like",
31
+ "ilike": "ilike",
32
+ "not_ilike": "not_ilike",
33
+ "in": "in",
34
+ "not_in": "not_in",
35
+ "nin": "not_in",
36
+ }
37
+
38
+
39
+ def _coerce_filters(
40
+ model: type, filters: Optional[Mapping[str, Any]]
41
+ ) -> Dict[str, Any]:
42
+ logger.debug("_coerce_filters called with filters=%s", filters)
43
+ cols = set(_model_columns(model))
44
+ specs = _colspecs(model)
45
+ raw = dict(filters or {})
46
+ out: Dict[str, Any] = {}
47
+ for k, v in raw.items():
48
+ name, op = k.split("__", 1) if "__" in k else (k, "eq")
49
+ if name not in cols:
50
+ continue
51
+ canon = _CANON_OPS.get(op, op)
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
59
+ key = name if canon == "eq" else f"{name}__{canon}"
60
+ out[key] = v
61
+ logger.debug("_coerce_filters returning %s", out)
62
+ return out
63
+
64
+
65
+ def _apply_filters(model: type, filters: Mapping[str, Any]) -> Any:
66
+ logger.debug("_apply_filters called with filters=%s", filters)
67
+ if select is None: # pragma: no cover
68
+ return None
69
+ clauses = []
70
+ for k, v in filters.items():
71
+ name, op = k.split("__", 1) if "__" in k else (k, "eq")
72
+ canon = _CANON_OPS.get(op, op)
73
+ col = getattr(model, name, None)
74
+ if col is None:
75
+ continue
76
+ if canon == "eq":
77
+ clauses.append(col == v)
78
+ elif canon == "ne":
79
+ clauses.append(col != v)
80
+ elif canon == "lt":
81
+ clauses.append(col < v)
82
+ elif canon == "gt":
83
+ clauses.append(col > v)
84
+ elif canon == "lte":
85
+ clauses.append(col <= v)
86
+ elif canon == "gte":
87
+ clauses.append(col >= v)
88
+ elif canon == "like":
89
+ clauses.append(col.like(v))
90
+ elif canon == "not_like":
91
+ clauses.append(~col.like(v))
92
+ elif canon == "ilike":
93
+ clauses.append(col.ilike(v))
94
+ elif canon == "not_ilike":
95
+ clauses.append(~col.ilike(v))
96
+ elif canon == "in":
97
+ seq = list(v) if isinstance(v, (list, tuple, set)) else [v]
98
+ clauses.append(col.in_(seq))
99
+ elif canon == "not_in":
100
+ seq = list(v) if isinstance(v, (list, tuple, set)) else [v]
101
+ clauses.append(~col.in_(seq))
102
+ if not clauses:
103
+ logger.debug("_apply_filters produced no clauses")
104
+ return None
105
+ result = clauses[0] if len(clauses) == 1 else and_(*clauses)
106
+ logger.debug("_apply_filters returning %s", result)
107
+ return result
108
+
109
+
110
+ def _apply_sort(model: type, sort: Any) -> Sequence[Any] | None:
111
+ logger.debug("_apply_sort called with sort=%s", sort)
112
+ if select is None or sort is None: # pragma: no cover
113
+ return None
114
+
115
+ def _tokenize(s: str) -> list[str]:
116
+ return [t.strip() for t in s.split(",") if t.strip()]
117
+
118
+ tokens: list[str] = []
119
+ if isinstance(sort, str):
120
+ tokens = _tokenize(sort)
121
+ elif isinstance(sort, Iterable):
122
+ for t in sort:
123
+ if isinstance(t, str):
124
+ tokens.extend(_tokenize(t))
125
+
126
+ if not tokens:
127
+ logger.debug("_apply_sort no tokens derived")
128
+ return None
129
+
130
+ specs = _colspecs(model)
131
+ order_by_exprs: list[Any] = []
132
+ for tok in tokens:
133
+ direction = "asc"
134
+ name = tok
135
+
136
+ if ":" in tok:
137
+ name, dirpart = tok.split(":", 1)
138
+ name = name.strip()
139
+ dirpart = dirpart.strip().lower()
140
+ if dirpart in ("desc", "descending"):
141
+ direction = "desc"
142
+ elif tok.startswith("-"):
143
+ name = tok[1:]
144
+ direction = "desc"
145
+
146
+ col = getattr(model, name, None)
147
+ if col is None:
148
+ continue
149
+ sp = specs.get(name)
150
+ if sp is not None:
151
+ io = getattr(sp, "io", None)
152
+ if io is not None and not getattr(io, "sortable", False):
153
+ continue
154
+ if direction == "desc":
155
+ order_by_exprs.append(desc(col))
156
+ else:
157
+ order_by_exprs.append(asc(col))
158
+
159
+ result = order_by_exprs or None
160
+ logger.debug("_apply_sort returning %s", result)
161
+ return result
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from typing import Any, Dict, Mapping, Tuple
5
+
6
+ import logging
7
+
8
+ try:
9
+ from tigrbl_canon.mapping.column_mro_collect import mro_collect_columns
10
+ except Exception: # pragma: no cover
11
+
12
+ def mro_collect_columns(_model: type, _cache_bust: int | None = None):
13
+ return {}
14
+
15
+
16
+ logger = logging.getLogger("uvicorn")
17
+
18
+
19
+ def _pk_columns(model: type) -> Tuple[Any, ...]:
20
+ logger.debug("_pk_columns called with model=%s", model)
21
+ table = getattr(model, "__table__", None)
22
+ if table is None:
23
+ raise ValueError(f"{model.__name__} has no __table__")
24
+ pks = tuple(table.primary_key.columns) # type: ignore[attr-defined]
25
+ if not pks:
26
+ raise ValueError(f"{model.__name__} has no primary key")
27
+ logger.debug("_pk_columns returning %s", pks)
28
+ return pks
29
+
30
+
31
+ def _single_pk_name(model: type) -> str:
32
+ logger.debug("_single_pk_name called with model=%s", model)
33
+ pks = _pk_columns(model)
34
+ if len(pks) != 1:
35
+ raise NotImplementedError(
36
+ f"{model.__name__} has composite PK; not supported by default core"
37
+ )
38
+ name = pks[0].name
39
+ logger.debug("_single_pk_name returning %s", name)
40
+ return name
41
+
42
+
43
+ def _coerce_pk_value(model: type, value: Any) -> Any:
44
+ logger.debug("_coerce_pk_value called with model=%s value=%s", model, value)
45
+ if value is None:
46
+ return None
47
+ try:
48
+ col = _pk_columns(model)[0]
49
+ py_type = col.type.python_type # type: ignore[attr-defined]
50
+ except Exception: # pragma: no cover - best effort
51
+ logger.debug("_coerce_pk_value returning original value %s", value)
52
+ return value
53
+ if isinstance(value, py_type):
54
+ return value
55
+ try:
56
+ coerced = py_type(value)
57
+ logger.debug("_coerce_pk_value coerced %s to %s", value, coerced)
58
+ return coerced
59
+ except Exception: # pragma: no cover - fallback to original
60
+ logger.debug("_coerce_pk_value failed to coerce %s", value)
61
+ return value
62
+
63
+
64
+ def _model_columns(model: type) -> Tuple[str, ...]:
65
+ logger.debug("_model_columns called with model=%s", model)
66
+ table = getattr(model, "__table__", None)
67
+ if table is None:
68
+ return ()
69
+ cols = tuple(c.name for c in table.columns)
70
+ logger.debug("_model_columns returning %s", cols)
71
+ return cols
72
+
73
+
74
+ def _colspecs(model: type) -> Mapping[str, Any]:
75
+ logger.info("_colspecs called with model=%s", model)
76
+ cache_bust = hash(
77
+ (
78
+ id(getattr(model, "__tigrbl_colspecs__", None)),
79
+ id(getattr(model, "__tigrbl_cols__", None)),
80
+ )
81
+ )
82
+ specs = mro_collect_columns(model, _cache_bust=cache_bust)
83
+ logger.info("_colspecs returning %s", specs)
84
+ return specs
85
+
86
+
87
+ def _filter_in_values(
88
+ model: type, data: Mapping[str, Any], verb: str
89
+ ) -> Dict[str, Any]:
90
+ logger.info("_filter_in_values called with data=%s verb=%s", data, verb)
91
+ specs = _colspecs(model)
92
+ if not specs:
93
+ result = dict(data)
94
+ logger.debug("_filter_in_values returning %s", result)
95
+ return result
96
+ out: Dict[str, Any] = {}
97
+ for k, v in data.items():
98
+ sp = specs.get(k)
99
+ if sp is None:
100
+ out[k] = v
101
+ continue
102
+ io = getattr(sp, "io", None)
103
+ allowed = True
104
+ if io is not None:
105
+ in_verbs = getattr(io, "in_verbs", ())
106
+ mutable = getattr(io, "mutable_verbs", ())
107
+ if in_verbs and verb not in in_verbs:
108
+ allowed = False
109
+ if mutable and verb not in mutable:
110
+ allowed = False
111
+ if allowed:
112
+ try:
113
+ col = getattr(getattr(model, "__table__", None), "columns", {}).get(k)
114
+ py_t = getattr(getattr(col, "type", None), "python_type", None)
115
+ if py_t is not None and v is not None and not isinstance(v, py_t):
116
+ if py_t in (dt.datetime, dt.date) and isinstance(v, str):
117
+ parsed = py_t.fromisoformat(v)
118
+ out[k] = parsed
119
+ else:
120
+ out[k] = py_t(v)
121
+ else:
122
+ out[k] = v
123
+ except Exception:
124
+ # Best effort coercion only; preserve original value on failure.
125
+ out[k] = v
126
+ logger.info("_filter_in_values returning %s", out)
127
+ return out
128
+
129
+
130
+ def _immutable_columns(model: type, verb: str) -> set[str]:
131
+ logger.info("_immutable_columns called with model=%s verb=%s", model, verb)
132
+ specs = _colspecs(model)
133
+ if not specs:
134
+ return set()
135
+ imm: set[str] = set()
136
+ for name, sp in specs.items():
137
+ io = getattr(sp, "io", None)
138
+ mutable = getattr(io, "mutable_verbs", ()) if io else ()
139
+ if mutable and verb not in mutable:
140
+ imm.add(name)
141
+ logger.info("_immutable_columns returning %s", imm)
142
+ return imm
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Mapping, Optional, Union
4
+ import builtins as _builtins
5
+ import logging
6
+
7
+ from . import AsyncSession, Session
8
+
9
+ logger = logging.getLogger("uvicorn")
10
+
11
+
12
+ def _pop_bound_self(args: list[Any]) -> None:
13
+ logger.debug("_pop_bound_self called with args=%s", args)
14
+ if args and not isinstance(args[0], type):
15
+ args.pop(0)
16
+ logger.debug("_pop_bound_self result args=%s", args)
17
+
18
+
19
+ def _extract_db(
20
+ args: list[Any], kwargs: dict[str, Any]
21
+ ) -> Union[Session, AsyncSession]:
22
+ logger.debug("_extract_db called with args=%s kwargs=%s", args, kwargs)
23
+ db = kwargs.pop("db", None)
24
+ if db is not None:
25
+ logger.debug("_extract_db found db in kwargs=%s", db)
26
+ return db
27
+ for i, a in enumerate(args):
28
+ if isinstance(a, (Session, AsyncSession)) or hasattr(a, "execute"):
29
+ args.pop(i)
30
+ logger.debug("_extract_db using positional db=%s", a)
31
+ return a # type: ignore[return-value]
32
+ logger.debug("_extract_db failed to find db")
33
+ raise TypeError("db session is required")
34
+
35
+
36
+ def _as_pos_int(x: Any) -> Optional[int]:
37
+ logger.debug("_as_pos_int called with x=%s", x)
38
+ if x is None:
39
+ return None
40
+ try:
41
+ v = int(x)
42
+ result = v if v >= 0 else 0
43
+ logger.debug("_as_pos_int returning %s", result)
44
+ return result
45
+ except Exception:
46
+ logger.debug("_as_pos_int returning None for x=%s", x)
47
+ return None
48
+
49
+
50
+ def _normalize_list_call(
51
+ _args: tuple[Any, ...], _kwargs: dict[str, Any]
52
+ ) -> tuple[type, Dict[str, Any]]:
53
+ logger.debug("_normalize_list_call called with _args=%s _kwargs=%s", _args, _kwargs)
54
+ args = _builtins.list(_args)
55
+ kwargs = dict(_kwargs)
56
+
57
+ _pop_bound_self(args)
58
+
59
+ if args and isinstance(args[0], type):
60
+ model = args.pop(0)
61
+ else:
62
+ model = kwargs.pop("model", None)
63
+ if not isinstance(model, type):
64
+ raise TypeError("list(model, ...) requires a model class")
65
+
66
+ filters = kwargs.pop("filters", None)
67
+ if filters is None and args:
68
+ maybe = args[0]
69
+ if isinstance(maybe, Mapping):
70
+ filters = args.pop(0)
71
+
72
+ skip = _as_pos_int(kwargs.pop("skip", None))
73
+ limit = _as_pos_int(kwargs.pop("limit", None))
74
+ sort = kwargs.pop("sort", None)
75
+
76
+ if skip is None and args:
77
+ skip = _as_pos_int(args[0])
78
+ if skip is not None:
79
+ args.pop(0)
80
+ if limit is None and args:
81
+ limit = _as_pos_int(args[0])
82
+ if limit is not None:
83
+ args.pop(0)
84
+
85
+ db = _extract_db(args, kwargs)
86
+
87
+ if filters is None:
88
+ filters = {}
89
+
90
+ result = {
91
+ "filters": filters,
92
+ "skip": skip,
93
+ "limit": limit,
94
+ "db": db,
95
+ "sort": sort,
96
+ }
97
+ logger.debug("_normalize_list_call returning model=%s params=%s", model, result)
98
+ return model, result
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Mapping, Optional, Union
4
+
5
+ import builtins as _builtins
6
+ import logging
7
+
8
+ from sqlalchemy.orm.exc import UnmappedInstanceError
9
+ from sqlalchemy import inspect as _sa_inspect
10
+ from sqlalchemy.exc import NoInspectionAvailable, OperationalError
11
+
12
+ from .helpers import (
13
+ AsyncSession,
14
+ Session,
15
+ NoResultFound,
16
+ select,
17
+ sa_delete,
18
+ _apply_filters,
19
+ _apply_sort,
20
+ _coerce_filters,
21
+ _coerce_pk_value,
22
+ _filter_in_values,
23
+ _immutable_columns,
24
+ _maybe_delete,
25
+ _maybe_execute,
26
+ _maybe_flush,
27
+ _maybe_get,
28
+ _normalize_list_call,
29
+ _set_attrs,
30
+ _single_pk_name,
31
+ _validate_enum_values,
32
+ )
33
+
34
+ logger = logging.getLogger("uvicorn")
35
+
36
+
37
+ def _ensure_model_mapped(model: type) -> None:
38
+ try:
39
+ _sa_inspect(model)
40
+ return
41
+ except NoInspectionAvailable:
42
+ pass
43
+
44
+ from tigrbl_orm.orm.tables import TableBase
45
+ from tigrbl_base._base._table_base import _materialize_colspecs_to_sqla
46
+
47
+ _materialize_colspecs_to_sqla(model)
48
+ TableBase.registry.map_declaratively(model)
49
+
50
+
51
+ def _ensure_model_table(model: type, db: Union[Session, AsyncSession]) -> None:
52
+ """Best-effort table creation for in-memory sqlite race conditions."""
53
+ table = getattr(model, "__table__", None)
54
+ if table is None:
55
+ return
56
+ bind = getattr(db, "bind", None)
57
+ if bind is None and hasattr(db, "get_bind"):
58
+ try:
59
+ bind = db.get_bind()
60
+ except Exception:
61
+ bind = None
62
+ if bind is None:
63
+ return
64
+ try:
65
+ table.create(bind=bind, checkfirst=True)
66
+ except Exception:
67
+ return
68
+
69
+
70
+ async def create(
71
+ model: type, data: Mapping[str, Any], db: Union[Session, AsyncSession]
72
+ ) -> Any:
73
+ """
74
+ Insert a single row. Returns the persisted model instance.
75
+ Flush-only (commit happens later in END_TX).
76
+ """
77
+ logger.debug("create called with model=%s data=%s", model, data)
78
+ _ensure_model_mapped(model)
79
+ data = _filter_in_values(model, data or {}, "create")
80
+ _validate_enum_values(model, data)
81
+ obj = model(**data)
82
+ if hasattr(db, "add"):
83
+ try:
84
+ db.add(obj)
85
+ except UnmappedInstanceError:
86
+ # Test suites may dispose the declarative registry between app
87
+ # instances. Re-materialize and re-map before retrying create.
88
+ from tigrbl_orm.orm.tables import TableBase
89
+ from tigrbl_base._base._table_base import _materialize_colspecs_to_sqla
90
+
91
+ _materialize_colspecs_to_sqla(model)
92
+ TableBase.registry.map_declaratively(model)
93
+ obj = model(**data)
94
+ db.add(obj)
95
+ try:
96
+ await _maybe_flush(db)
97
+ except OperationalError as exc:
98
+ if "no such table" not in str(exc).lower():
99
+ raise
100
+ _ensure_model_table(model, db)
101
+ obj = model(**data)
102
+ db.add(obj)
103
+ await _maybe_flush(db)
104
+ logger.debug("create persisted obj=%s", obj)
105
+ return obj
106
+
107
+
108
+ async def read(model: type, ident: Any, db: Union[Session, AsyncSession]) -> Any:
109
+ """
110
+ Load a single row by primary key. Raises NoResultFound if not found.
111
+ """
112
+ logger.debug("read called with model=%s ident=%s", model, ident)
113
+ _ensure_model_mapped(model)
114
+ obj = await _maybe_get(db, model, ident)
115
+ if obj is None:
116
+ logger.debug("read did not find model=%s ident=%s", model, ident)
117
+ raise NoResultFound(f"{model.__name__}({ident!r}) not found")
118
+ logger.debug("read returning obj=%s", obj)
119
+ return obj
120
+
121
+
122
+ async def update(
123
+ model: type, ident: Any, data: Mapping[str, Any], db: Union[Session, AsyncSession]
124
+ ) -> Any:
125
+ """
126
+ Partial update by primary key. Missing keys are left unchanged.
127
+ Returns the updated model instance. Flush-only.
128
+ """
129
+ logger.debug("update called with model=%s ident=%s data=%s", model, ident, data)
130
+ _ensure_model_mapped(model)
131
+ data = _filter_in_values(model, data or {}, "update")
132
+ _validate_enum_values(model, data)
133
+ obj = await read(model, ident, db)
134
+ skip = _immutable_columns(model, "update")
135
+ _set_attrs(obj, data, allow_missing=True, skip=skip)
136
+ await _maybe_flush(db)
137
+ logger.debug("update returning obj=%s", obj)
138
+ return obj
139
+
140
+
141
+ async def replace(
142
+ model: type, ident: Any, data: Mapping[str, Any], db: Union[Session, AsyncSession]
143
+ ) -> Any:
144
+ """
145
+ PUT semantics with upsert behaviour.
146
+
147
+ If the row exists it is replaced entirely (missing attributes are nulled).
148
+ If the row does not exist it is created with the provided identifier.
149
+ Flush-only.
150
+ """
151
+ logger.debug("replace called with model=%s ident=%s data=%s", model, ident, data)
152
+ _ensure_model_mapped(model)
153
+ data = _filter_in_values(model, data or {}, "replace")
154
+ _validate_enum_values(model, data)
155
+ pk = _single_pk_name(model)
156
+ obj = await _maybe_get(db, model, ident)
157
+ if obj is None:
158
+ payload = {pk: ident, **data}
159
+ result = await create(model, payload, db=db)
160
+ logger.debug("replace created obj=%s", result)
161
+ return result
162
+ skip = _immutable_columns(model, "replace")
163
+ _set_attrs(obj, data, allow_missing=False, skip=skip)
164
+ await _maybe_flush(db)
165
+ logger.debug("replace updated obj=%s", obj)
166
+ return obj
167
+
168
+
169
+ async def merge(
170
+ model: type, ident: Any, data: Mapping[str, Any], db: Union[Session, AsyncSession]
171
+ ) -> Any:
172
+ """PATCH semantics with upsert behaviour."""
173
+ logger.debug("merge called with model=%s ident=%s data=%s", model, ident, data)
174
+ _ensure_model_mapped(model)
175
+ pk = _single_pk_name(model)
176
+ ident = _coerce_pk_value(model, ident)
177
+ obj = await _maybe_get(db, model, ident)
178
+
179
+ verb = "update" if obj is not None else "create"
180
+ data = _filter_in_values(model, data or {}, verb)
181
+ _validate_enum_values(model, data)
182
+ data_no_pk = {k: v for k, v in data.items() if k != pk}
183
+ if obj is None:
184
+ payload = {pk: ident, **data_no_pk}
185
+ result = await create(model, payload, db=db)
186
+ logger.debug("merge created obj=%s", result)
187
+ return result
188
+ skip = _immutable_columns(model, "update")
189
+ _set_attrs(obj, data_no_pk, allow_missing=True, skip=skip)
190
+ await _maybe_flush(db)
191
+ logger.debug("merge updated obj=%s", obj)
192
+ return obj
193
+
194
+
195
+ async def delete(
196
+ model: type, ident: Any, db: Union[Session, AsyncSession]
197
+ ) -> Dict[str, int]:
198
+ """
199
+ Delete by primary key. Returns {"deleted": 1} if removed, else raises NoResultFound.
200
+ Flush-only.
201
+ """
202
+ logger.debug("delete called with model=%s ident=%s", model, ident)
203
+ _ensure_model_mapped(model)
204
+ obj = await read(model, ident, db)
205
+ await _maybe_delete(db, obj)
206
+ await _maybe_flush(db)
207
+ logger.debug("delete removed obj=%s", obj)
208
+ return {"deleted": 1}
209
+
210
+
211
+ # NOTE: tolerant signature: accepts positional/keyword and ignores stray args
212
+ async def list(*_args: Any, **_kwargs: Any) -> List[Any]: # noqa: A001 (shadow built-in)
213
+ """
214
+ Simple list with equality filters + skip/limit (+ optional sort).
215
+ Tolerant to:
216
+ - missing filters (defaults to {})
217
+ - accidental bound-method 'self' (first positional arg)
218
+ - positional or keyword args
219
+ - stray extras (e.g., request) which are ignored
220
+ """
221
+ logger.debug("list called with args=%s kwargs=%s", _args, _kwargs)
222
+ model, params = _normalize_list_call(_args, _kwargs)
223
+ _ensure_model_mapped(model)
224
+
225
+ filters: Mapping[str, Any] = _coerce_filters(model, params["filters"])
226
+ skip: Optional[int] = params["skip"]
227
+ limit: Optional[int] = params["limit"]
228
+ db: Union[Session, AsyncSession] = params["db"]
229
+ sort = params["sort"]
230
+
231
+ if select is None: # pragma: no cover
232
+ # Fallback: legacy query API
233
+ q = db.query(model) # type: ignore[attr-defined]
234
+ if filters:
235
+ q = q.filter_by(**filters) # type: ignore[attr-defined]
236
+ if isinstance(skip, int):
237
+ q = q.offset(max(skip, 0)) # type: ignore[attr-defined]
238
+ if isinstance(limit, int) and limit is not None:
239
+ q = q.limit(max(limit, 0)) # type: ignore[attr-defined]
240
+ return _builtins.list(q.all()) # type: ignore[attr-defined]
241
+
242
+ where = _apply_filters(model, filters)
243
+ stmt = select(model)
244
+ if where is not None:
245
+ stmt = stmt.where(where)
246
+
247
+ order_exprs = _apply_sort(model, sort)
248
+ if order_exprs:
249
+ for ob in order_exprs:
250
+ stmt = stmt.order_by(ob)
251
+
252
+ if isinstance(skip, int):
253
+ stmt = stmt.offset(max(skip, 0))
254
+ if isinstance(limit, int) and limit is not None:
255
+ stmt = stmt.limit(max(limit, 0))
256
+
257
+ result = await _maybe_execute(db, stmt)
258
+ items = _builtins.list(result.scalars().all()) # type: ignore[attr-defined]
259
+ logger.debug("list returning %d items", len(items))
260
+ return items
261
+
262
+
263
+ async def clear(
264
+ *args: Any,
265
+ **kwargs: Any,
266
+ ) -> Dict[str, int]:
267
+ """
268
+ Delete many rows matching equality filters. Returns {"deleted": N}.
269
+ Flush-only. Tolerant to the same calling variations as `list`.
270
+ """
271
+ # Reuse normalizer to accept the same shapes
272
+ logger.debug("clear called with args=%s kwargs=%s", args, kwargs)
273
+ model, params = _normalize_list_call(args, kwargs)
274
+ _ensure_model_mapped(model)
275
+ raw_filters: Mapping[str, Any] = params["filters"]
276
+ db: Union[Session, AsyncSession] = params["db"]
277
+
278
+ if sa_delete is None: # pragma: no cover
279
+ # Fallback path: manual iteration
280
+ items = await list(model, raw_filters, db=db)
281
+ n = 0
282
+ for obj in items:
283
+ await _maybe_delete(db, obj)
284
+ n += 1
285
+ await _maybe_flush(db)
286
+ return {"deleted": n}
287
+
288
+ filt = _coerce_filters(model, raw_filters)
289
+ where = _apply_filters(model, filt)
290
+ stmt = sa_delete(model)
291
+ if where is not None:
292
+ stmt = stmt.where(where)
293
+
294
+ res = await _maybe_execute(db, stmt)
295
+ await _maybe_flush(db)
296
+ n = int(getattr(res, "rowcount", 0) or 0)
297
+ logger.debug("clear removed %d rows", n)
298
+ return {"deleted": n}
@@ -0,0 +1,50 @@
1
+ """Parameter marker helpers for std dependency resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Param:
11
+ default: Any = None
12
+ alias: str | None = None
13
+ required: bool = False
14
+ location: str = "query"
15
+
16
+
17
+ def Body(default: Any = None, **kwargs: Any) -> Param:
18
+ return Param(
19
+ default=default,
20
+ alias=kwargs.get("alias"),
21
+ required=default is ...,
22
+ location="body",
23
+ )
24
+
25
+
26
+ def Query(default: Any = None, **kwargs: Any) -> Param:
27
+ return Param(
28
+ default=default,
29
+ alias=kwargs.get("alias"),
30
+ required=default is ...,
31
+ location="query",
32
+ )
33
+
34
+
35
+ def Path(default: Any = None, **kwargs: Any) -> Param:
36
+ return Param(
37
+ default=default,
38
+ alias=kwargs.get("alias"),
39
+ required=default is ...,
40
+ location="path",
41
+ )
42
+
43
+
44
+ def Header(default: Any = None, **kwargs: Any) -> Param:
45
+ return Param(
46
+ default=default,
47
+ alias=kwargs.get("alias"),
48
+ required=default is ...,
49
+ location="header",
50
+ )