tigrbl-ops-oltp 0.1.0.dev1__tar.gz → 0.1.0.dev7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrbl-ops-oltp
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev7
4
4
  Summary: OLTP operation implementations for Tigrbl, including CRUD and bulk handlers.
5
5
  License-Expression: Apache-2.0
6
6
  Keywords: tigrbl,sdk,standards,framework,oltp,crud
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tigrbl-ops-oltp"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.0.dev7"
4
4
  description = "OLTP operation implementations for Tigrbl, including CRUD and bulk handlers."
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -30,6 +30,7 @@ from .db import (
30
30
  _maybe_get,
31
31
  _maybe_execute,
32
32
  _maybe_flush,
33
+ _maybe_rollback,
33
34
  _maybe_delete,
34
35
  _set_attrs,
35
36
  )
@@ -59,6 +60,7 @@ __all__ = [
59
60
  "_maybe_delete",
60
61
  "_maybe_execute",
61
62
  "_maybe_flush",
63
+ "_maybe_rollback",
62
64
  "_maybe_get",
63
65
  "_model_columns",
64
66
  "_normalize_list_call",
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
4
+ from functools import lru_cache
3
5
  from typing import Any, Mapping, Sequence, Union
4
6
 
5
7
  import logging
@@ -10,51 +12,63 @@ from .model import _model_columns, _single_pk_name
10
12
  logger = logging.getLogger("uvicorn")
11
13
 
12
14
 
15
+ @lru_cache(maxsize=512)
16
+ def _is_async_db_type(db_type: type[Any]) -> bool:
17
+ return db_type.__name__ == "AsyncSession" or hasattr(db_type, "run_sync")
18
+
19
+
13
20
  def _is_async_db(db: Any) -> bool:
14
21
  logger.debug("_is_async_db called with db=%s", db)
15
- result = isinstance(db, AsyncSession) or hasattr(db, "run_sync")
22
+ result = _is_async_db_type(type(db))
16
23
  logger.debug("_is_async_db returning %s", result)
17
24
  return result
18
25
 
19
26
 
20
27
  async def _maybe_get(db: Union[Session, AsyncSession], model: type, pk_value: Any):
21
28
  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]
29
+ result = db.get(model, pk_value) # type: ignore[attr-defined]
30
+ if inspect.isawaitable(result):
31
+ result = await result
26
32
  logger.debug("_maybe_get returning %s", result)
27
33
  return result
28
34
 
29
35
 
30
36
  async def _maybe_execute(db: Union[Session, AsyncSession], stmt: Any):
31
37
  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]
38
+ result = db.execute(stmt) # type: ignore[attr-defined]
39
+ if inspect.isawaitable(result):
40
+ result = await result
36
41
  logger.debug("_maybe_execute returning %s", result)
37
42
  return result
38
43
 
39
44
 
40
45
  async def _maybe_flush(db: Union[Session, AsyncSession]) -> None:
41
46
  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]
47
+ result = db.flush() # type: ignore[attr-defined]
48
+ if inspect.isawaitable(result):
49
+ await result
46
50
  logger.debug("_maybe_flush completed")
47
51
 
48
52
 
53
+ async def _maybe_rollback(db: Union[Session, AsyncSession]) -> None:
54
+ logger.debug("_maybe_rollback called")
55
+ if not hasattr(db, "rollback"):
56
+ logger.debug("_maybe_rollback skipping rollback; no attribute")
57
+ return
58
+ result = db.rollback() # type: ignore[attr-defined]
59
+ if inspect.isawaitable(result):
60
+ await result
61
+ logger.debug("_maybe_rollback completed")
62
+
63
+
49
64
  async def _maybe_delete(db: Union[Session, AsyncSession], obj: Any) -> None:
50
65
  logger.debug("_maybe_delete called with obj=%s", obj)
51
66
  if not hasattr(db, "delete"):
52
67
  logger.debug("_maybe_delete skipping delete; no attribute")
53
68
  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]
69
+ result = db.delete(obj) # type: ignore[attr-defined]
70
+ if inspect.isawaitable(result):
71
+ await result
58
72
  logger.debug("_maybe_delete completed for obj=%s", obj)
59
73
 
60
74
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import enum as _enum
3
4
  from typing import Any, Mapping
4
5
  import builtins as _builtins
5
6
  import logging
@@ -40,12 +41,7 @@ def _validate_enum_values(model: type, values: Mapping[str, Any]) -> None:
40
41
 
41
42
  enum_cls = getattr(col_type, "enum_class", None)
42
43
  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):
44
+ if isinstance(v, _enum.Enum):
49
45
  if isinstance(v, enum_cls):
50
46
  continue
51
47
  logger.debug(
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from functools import lru_cache
5
+ from typing import Any, Dict, Mapping, Tuple
6
+
7
+ try:
8
+ from tigrbl_core._spec.column_spec import mro_collect_columns
9
+ except Exception: # pragma: no cover
10
+
11
+ def mro_collect_columns(_model: type, _cache_bust: int | None = None):
12
+ return {}
13
+
14
+
15
+ def _colspecs_cache_token(model: type) -> tuple[int, int, int, int]:
16
+ table = getattr(model, "__table__", None)
17
+ cols = getattr(table, "columns", None) if table is not None else None
18
+ try:
19
+ col_count = len(tuple(cols)) if cols is not None else 0
20
+ except Exception:
21
+ col_count = 0
22
+ return (
23
+ id(getattr(model, "__tigrbl_colspecs__", None)),
24
+ id(getattr(model, "__tigrbl_cols__", None)),
25
+ id(table),
26
+ col_count,
27
+ )
28
+
29
+
30
+ @lru_cache(maxsize=256)
31
+ def _colspecs_cached(
32
+ model: type, cache_token: tuple[int, int, int, int]
33
+ ) -> Mapping[str, Any]:
34
+ del cache_token
35
+ return mro_collect_columns(model, _cache_bust=hash(_colspecs_cache_token(model)))
36
+
37
+
38
+ @lru_cache(maxsize=512)
39
+ def _excluded_schema_fields(model: type, verb: str) -> frozenset[str]:
40
+ schema_cls = getattr(getattr(model, "schemas", None), verb, None)
41
+ schema_in = getattr(schema_cls, "in_", None) if schema_cls else None
42
+ model_fields = (
43
+ getattr(schema_in, "model_fields", None) if schema_in is not None else None
44
+ )
45
+ if not isinstance(model_fields, Mapping):
46
+ return frozenset()
47
+ return frozenset(
48
+ name
49
+ for name, field_info in model_fields.items()
50
+ if getattr(field_info, "exclude", False)
51
+ )
52
+
53
+
54
+ def _filter_plan_cache_token(model: type, verb: str) -> tuple[int, int, int, int, int]:
55
+ return (
56
+ *_colspecs_cache_token(model),
57
+ id(getattr(getattr(model, "schemas", None), verb, None)),
58
+ )
59
+
60
+
61
+ @lru_cache(maxsize=512)
62
+ def _filter_plan_cached(
63
+ model: type, verb: str, cache_token: tuple[int, int, int, int, int]
64
+ ) -> tuple[Mapping[str, bool], frozenset[str], Mapping[str, Any]]:
65
+ del cache_token
66
+ specs = _colspecs(model)
67
+ if not specs:
68
+ return {}, frozenset(), _table_python_types(model)
69
+
70
+ allowed_by_field: dict[str, bool] = {}
71
+ for name, sp in specs.items():
72
+ io = getattr(sp, "io", None)
73
+ if io is None:
74
+ allowed_by_field[name] = True
75
+ continue
76
+ in_verbs = getattr(io, "in_verbs", ())
77
+ mutable = getattr(io, "mutable_verbs", ())
78
+ allowed = (not in_verbs or verb in in_verbs) and (
79
+ not mutable or verb in mutable
80
+ )
81
+ allowed_by_field[name] = allowed
82
+
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
105
+
106
+
107
+ def _pk_columns(model: type) -> Tuple[Any, ...]:
108
+ table = getattr(model, "__table__", None)
109
+ if table is None:
110
+ raise ValueError(f"{model.__name__} has no __table__")
111
+ pks = tuple(table.primary_key.columns) # type: ignore[attr-defined]
112
+ if not pks:
113
+ raise ValueError(f"{model.__name__} has no primary key")
114
+ return pks
115
+
116
+
117
+ def _single_pk_name(model: type) -> str:
118
+ pks = _pk_columns(model)
119
+ if len(pks) != 1:
120
+ raise NotImplementedError(
121
+ f"{model.__name__} has composite PK; not supported by default core"
122
+ )
123
+ name = pks[0].name
124
+ return name
125
+
126
+
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
+ def _model_columns(model: type) -> Tuple[str, ...]:
145
+ table = getattr(model, "__table__", None)
146
+ if table is None:
147
+ return ()
148
+ cols = tuple(c.name for c in table.columns)
149
+ return cols
150
+
151
+
152
+ def _colspecs(model: type) -> Mapping[str, Any]:
153
+ return _colspecs_cached(model, _colspecs_cache_token(model))
154
+
155
+
156
+ def _filter_in_values(
157
+ model: type, data: Mapping[str, Any], verb: str
158
+ ) -> Dict[str, Any]:
159
+ allowed_by_field, excluded_fields, column_types = _filter_plan_cached(
160
+ model, verb, _filter_plan_cache_token(model, verb)
161
+ )
162
+ if not allowed_by_field and not excluded_fields:
163
+ return dict(data)
164
+
165
+ out: Dict[str, Any] = {}
166
+ for k, v in data.items():
167
+ allowed = allowed_by_field.get(k)
168
+ if allowed is None:
169
+ if k in excluded_fields:
170
+ continue
171
+ out[k] = v
172
+ continue
173
+ 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
187
+ return out
188
+
189
+
190
+ def _immutable_columns(model: type, verb: str) -> set[str]:
191
+ specs = _colspecs(model)
192
+ if not specs:
193
+ return set()
194
+ imm: set[str] = set()
195
+ for name, sp in specs.items():
196
+ io = getattr(sp, "io", None)
197
+ mutable = getattr(io, "mutable_verbs", ()) if io else ()
198
+ if mutable and verb not in mutable:
199
+ imm.add(name)
200
+ return imm
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from functools import lru_cache
3
4
  from typing import Any, Dict, List, Mapping, Optional, Union
5
+ import inspect
6
+ from weakref import WeakSet
4
7
 
5
8
  import builtins as _builtins
6
9
  import logging
@@ -33,10 +36,57 @@ from .helpers import (
33
36
 
34
37
  logger = logging.getLogger("uvicorn")
35
38
 
39
+ _MAPPED_MODELS: "WeakSet[type]" = WeakSet()
40
+
41
+
42
+ @lru_cache(maxsize=512)
43
+ def _create_flush_requirements(model: type) -> tuple[str | None, tuple[str, ...]]:
44
+ """Precompute model attributes that may need a create-time flush."""
45
+ table = getattr(model, "__table__", None)
46
+ pk_name: str | None = None
47
+ server_default_names: list[str] = []
48
+
49
+ if table is None:
50
+ return pk_name, ()
51
+
52
+ try:
53
+ pk_cols = tuple(getattr(table, "primary_key", ()).columns)
54
+ except Exception:
55
+ pk_cols = ()
56
+ if len(pk_cols) == 1:
57
+ name = getattr(pk_cols[0], "name", None)
58
+ if isinstance(name, str) and name:
59
+ pk_name = name
60
+
61
+ for col in getattr(table, "columns", ()):
62
+ name = getattr(col, "name", None)
63
+ if not isinstance(name, str) or not name:
64
+ continue
65
+ if getattr(col, "server_default", None) is not None:
66
+ server_default_names.append(name)
67
+
68
+ return pk_name, tuple(server_default_names)
69
+
70
+
71
+ def _requires_create_flush(model: type, obj: Any) -> bool:
72
+ """Flush only when we need database-generated values immediately."""
73
+ pk_name, server_default_names = _create_flush_requirements(model)
74
+
75
+ if pk_name is not None and getattr(obj, pk_name, None) is None:
76
+ return True
77
+
78
+ for name in server_default_names:
79
+ if getattr(obj, name, None) is None:
80
+ return True
81
+ return False
82
+
36
83
 
37
84
  def _ensure_model_mapped(model: type) -> None:
85
+ if model in _MAPPED_MODELS:
86
+ return
38
87
  try:
39
88
  _sa_inspect(model)
89
+ _MAPPED_MODELS.add(model)
40
90
  return
41
91
  except NoInspectionAvailable:
42
92
  pass
@@ -46,6 +96,7 @@ def _ensure_model_mapped(model: type) -> None:
46
96
 
47
97
  _materialize_colspecs_to_sqla(model)
48
98
  TableBase.registry.map_declaratively(model)
99
+ _MAPPED_MODELS.add(model)
49
100
 
50
101
 
51
102
  def _ensure_model_table(model: type, db: Union[Session, AsyncSession]) -> None:
@@ -92,15 +143,23 @@ async def create(
92
143
  TableBase.registry.map_declaratively(model)
93
144
  obj = model(**data)
94
145
  db.add(obj)
146
+ needs_flush = _requires_create_flush(model, obj)
147
+
95
148
  try:
96
- await _maybe_flush(db)
149
+ if needs_flush:
150
+ await _maybe_flush(db)
97
151
  except OperationalError as exc:
98
152
  if "no such table" not in str(exc).lower():
99
153
  raise
154
+ if hasattr(db, "rollback"):
155
+ rollback_result = db.rollback()
156
+ if inspect.isawaitable(rollback_result):
157
+ await rollback_result
100
158
  _ensure_model_table(model, db)
101
159
  obj = model(**data)
102
160
  db.add(obj)
103
- await _maybe_flush(db)
161
+ if needs_flush:
162
+ await _maybe_flush(db)
104
163
  logger.debug("create persisted obj=%s", obj)
105
164
  return obj
106
165
 
@@ -1,142 +0,0 @@
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