simple-module-db 0.0.33__tar.gz → 0.0.35__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 (44) hide show
  1. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/PKG-INFO +8 -6
  2. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/README.md +6 -4
  3. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/pyproject.toml +2 -2
  4. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/__init__.py +25 -2
  5. simple_module_db-0.0.35/simple_module_db/callbacks.py +47 -0
  6. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/deps.py +3 -3
  7. simple_module_db-0.0.35/simple_module_db/flush_guard.py +73 -0
  8. simple_module_db-0.0.35/simple_module_db/insert_guard.py +131 -0
  9. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/listeners.py +50 -93
  10. simple_module_db-0.0.35/simple_module_db/model_registry.py +56 -0
  11. simple_module_db-0.0.35/simple_module_db/query_filter.py +211 -0
  12. simple_module_db-0.0.35/simple_module_db/session.py +161 -0
  13. simple_module_db-0.0.35/simple_module_db/subquery_guard.py +49 -0
  14. simple_module_db-0.0.35/simple_module_db/tenancy.py +169 -0
  15. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/transaction.py +8 -0
  16. simple_module_db-0.0.35/simple_module_db/writes.py +107 -0
  17. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/conftest.py +5 -1
  18. simple_module_db-0.0.35/tests/test_query_filters.py +150 -0
  19. simple_module_db-0.0.35/tests/test_session.py +138 -0
  20. simple_module_db-0.0.35/tests/test_soft_delete_shapes.py +89 -0
  21. simple_module_db-0.0.35/tests/test_tenant_dml.py +165 -0
  22. simple_module_db-0.0.35/tests/test_tenant_shapes.py +121 -0
  23. simple_module_db-0.0.35/tests/test_tenant_strict.py +147 -0
  24. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_transaction.py +70 -8
  25. simple_module_db-0.0.35/tests/test_writes.py +203 -0
  26. simple_module_db-0.0.33/simple_module_db/session.py +0 -77
  27. simple_module_db-0.0.33/tests/test_session.py +0 -65
  28. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/.gitignore +0 -0
  29. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/LICENSE +0 -0
  30. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/audit.py +0 -0
  31. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/base.py +0 -0
  32. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/migrations.py +0 -0
  33. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/mixins.py +0 -0
  34. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/provider.py +0 -0
  35. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/py.typed +0 -0
  36. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/simple_module_db/search.py +0 -0
  37. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/_audit_models.py +0 -0
  38. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/_models.py +0 -0
  39. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_audit.py +0 -0
  40. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_base.py +0 -0
  41. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_db_logging.py +0 -0
  42. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_migrations.py +0 -0
  43. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_mixins.py +0 -0
  44. {simple_module_db-0.0.33 → simple_module_db-0.0.35}/tests/test_multi_tenancy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_db
3
- Version: 0.0.33
3
+ Version: 0.0.35
4
4
  Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -25,7 +25,7 @@ Requires-Dist: aiosqlite>=0.20
25
25
  Requires-Dist: alembic>=1.14
26
26
  Requires-Dist: asyncpg>=0.30
27
27
  Requires-Dist: psycopg2-binary>=2.9
28
- Requires-Dist: simple-module-core==0.0.33
28
+ Requires-Dist: simple-module-core==0.0.35
29
29
  Requires-Dist: sqlalchemy[asyncio]>=2.0
30
30
  Requires-Dist: sqlmodel>=0.0.22
31
31
  Description-Content-Type: text/markdown
@@ -43,7 +43,8 @@ pip install simple_module_db
43
43
  ## What it provides
44
44
 
45
45
  - `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
46
- - Per-request async session (`get_db`) with an auto-commit-on-flush hook — `after_flush` commits if there are pending writes, rolls back otherwise.
46
+ - Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
47
+ - `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
47
48
  - Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
48
49
  - `DatabaseState` container used by the framework to avoid global mutable state.
49
50
 
@@ -66,12 +67,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
66
67
  In a service:
67
68
 
68
69
  ```python
69
- from simple_module_db import get_db
70
+ from simple_module_db import RequestSession, get_db
70
71
 
71
- async def create_order(session = Depends(get_db), ...):
72
+ async def create_order(session: RequestSession = Depends(get_db), ...):
72
73
  order = Order(customer_id=..., total_cents=...)
73
74
  session.add(order)
74
- await session.flush() # assigns order.id; auto-commit happens after the request
75
+ await session.flush() # assigns order.id; commit happens before the response
76
+ session.on_commit(order_cache.refresh)
75
77
  return order
76
78
  ```
77
79
 
@@ -11,7 +11,8 @@ pip install simple_module_db
11
11
  ## What it provides
12
12
 
13
13
  - `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
14
- - Per-request async session (`get_db`) with an auto-commit-on-flush hook — `after_flush` commits if there are pending writes, rolls back otherwise.
14
+ - Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
15
+ - `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
15
16
  - Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
16
17
  - `DatabaseState` container used by the framework to avoid global mutable state.
17
18
 
@@ -34,12 +35,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
34
35
  In a service:
35
36
 
36
37
  ```python
37
- from simple_module_db import get_db
38
+ from simple_module_db import RequestSession, get_db
38
39
 
39
- async def create_order(session = Depends(get_db), ...):
40
+ async def create_order(session: RequestSession = Depends(get_db), ...):
40
41
  order = Order(customer_id=..., total_cents=...)
41
42
  session.add(order)
42
- await session.flush() # assigns order.id; auto-commit happens after the request
43
+ await session.flush() # assigns order.id; commit happens before the response
44
+ session.on_commit(order_cache.refresh)
43
45
  return order
44
46
  ```
45
47
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_db"
3
- version = "0.0.33"
3
+ version = "0.0.35"
4
4
  description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -28,7 +28,7 @@ dependencies = [
28
28
  # The async app code goes through asyncpg; migration env.py rewrites the
29
29
  # URL to +psycopg2 because Alembic's online migrations are sync-only.
30
30
  "psycopg2-binary>=2.9",
31
- "simple_module_core==0.0.33",
31
+ "simple_module_core==0.0.35",
32
32
  "sqlalchemy[asyncio]>=2.0",
33
33
  "sqlmodel>=0.0.22",
34
34
  ]
@@ -2,8 +2,8 @@
2
2
 
3
3
  from simple_module_db.audit import AuditRecord
4
4
  from simple_module_db.base import create_module_base
5
+ from simple_module_db.callbacks import OnCommitCallback
5
6
  from simple_module_db.deps import get_db
6
- from simple_module_db.listeners import TenantIsolationError, current_tenant_id
7
7
  from simple_module_db.migrations import (
8
8
  build_module_metadata,
9
9
  make_include_object,
@@ -13,30 +13,53 @@ from simple_module_db.migrations import (
13
13
  from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
14
14
  from simple_module_db.provider import DatabaseProvider, detect_provider
15
15
  from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
16
- from simple_module_db.session import DatabaseState, init_db
16
+ from simple_module_db.session import DatabaseState, RequestSession, init_db
17
+ from simple_module_db.tenancy import (
18
+ ALL_TENANTS_OPTION,
19
+ TENANT_ID_PATTERN,
20
+ MissingTenantError,
21
+ TenantIsolationError,
22
+ all_tenants,
23
+ bind_current_tenant,
24
+ current_tenant_id,
25
+ is_valid_tenant_id,
26
+ tenant_context,
27
+ )
17
28
  from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
29
+ from simple_module_db.writes import hard_delete, mark_written
18
30
 
19
31
  __all__ = [
32
+ "ALL_TENANTS_OPTION",
20
33
  "LIKE_ESCAPE_CHAR",
34
+ "TENANT_ID_PATTERN",
21
35
  "AuditMixin",
22
36
  "AuditRecord",
23
37
  "CommitBeforeResponseMiddleware",
24
38
  "DatabaseProvider",
25
39
  "DatabaseState",
40
+ "MissingTenantError",
26
41
  "MultiTenantMixin",
42
+ "OnCommitCallback",
43
+ "RequestSession",
27
44
  "SoftDeleteMixin",
28
45
  "TenantIsolationError",
29
46
  "VersionedMixin",
47
+ "all_tenants",
48
+ "bind_current_tenant",
30
49
  "build_module_metadata",
31
50
  "create_module_base",
32
51
  "current_tenant_id",
33
52
  "detect_provider",
34
53
  "finalize_session",
35
54
  "get_db",
55
+ "hard_delete",
36
56
  "init_db",
57
+ "is_valid_tenant_id",
37
58
  "like_contains_pattern",
38
59
  "like_prefix_pattern",
39
60
  "make_include_object",
40
61
  "make_process_revision_directives",
62
+ "mark_written",
41
63
  "render_item",
64
+ "tenant_context",
42
65
  ]
@@ -0,0 +1,47 @@
1
+ """Post-commit callbacks for framework-managed database sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ type OnCommitCallback = Callable[[], Awaitable[None] | None]
12
+
13
+ _CALLBACKS_KEY = "sm_db_on_commit_callbacks"
14
+ logger = logging.getLogger("simple_module.db")
15
+
16
+
17
+ def register_on_commit(session: AsyncSession, callback: OnCommitCallback) -> None:
18
+ """Queue ``callback`` for this session's next successful finalization."""
19
+ if not callable(callback):
20
+ raise TypeError("on_commit callback must be callable")
21
+ callbacks = session.info.setdefault(_CALLBACKS_KEY, [])
22
+ callbacks.append(callback)
23
+
24
+
25
+ def discard_on_commit_callbacks(session: AsyncSession) -> None:
26
+ """Discard callbacks belonging to a transaction that did not commit."""
27
+ session.info.pop(_CALLBACKS_KEY, None)
28
+
29
+
30
+ async def run_on_commit_callbacks(session: AsyncSession) -> None:
31
+ """Run and remove queued callbacks after a successful commit.
32
+
33
+ The transaction is already durable, so callback failures are logged and do
34
+ not turn a successful database mutation into a misleading failed response.
35
+ Remaining callbacks still run.
36
+ """
37
+ callbacks: list[OnCommitCallback] = session.info.pop(_CALLBACKS_KEY, [])
38
+ for callback in callbacks:
39
+ try:
40
+ result = callback()
41
+ if inspect.isawaitable(result):
42
+ await result
43
+ except Exception:
44
+ logger.exception(
45
+ "db.session.on_commit_failed",
46
+ extra={"operation": "on_commit_failed"},
47
+ )
@@ -6,8 +6,8 @@ import time
6
6
  from collections.abc import AsyncGenerator
7
7
 
8
8
  from fastapi import Request
9
- from sqlalchemy.ext.asyncio import AsyncSession
10
9
 
10
+ from simple_module_db.session import RequestSession
11
11
  from simple_module_db.transaction import (
12
12
  SESSION_START_KEY,
13
13
  finalize_session,
@@ -16,7 +16,7 @@ from simple_module_db.transaction import (
16
16
  )
17
17
 
18
18
 
19
- async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
19
+ async def get_db(request: Request) -> AsyncGenerator[RequestSession, None]:
20
20
  """Yield an async database session, auto-closing on exit.
21
21
 
22
22
  Commits only when the session has pending writes (``new``, ``dirty``,
@@ -34,7 +34,7 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
34
34
  Usage in FastAPI endpoints::
35
35
 
36
36
  @router.get("/items")
37
- async def list_items(db: AsyncSession = Depends(get_db)):
37
+ async def list_items(db: RequestSession = Depends(get_db)):
38
38
  ...
39
39
  """
40
40
  factory = request.app.state.sm.db.session_factory
@@ -0,0 +1,73 @@
1
+ """Unit-of-work tenant rules, applied in ``before_flush``.
2
+
3
+ The query filter scopes what a statement *reads*; this guards what a flush
4
+ *writes*. It covers the paths that never reach ``do_orm_execute``: objects
5
+ added with ``session.add``, and objects that came out of the identity map —
6
+ ``session.get`` answers from it without SQL, so a session reused across a
7
+ ``tenant_context`` switch can hand back, and then write, another tenant's row.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from sqlalchemy import inspect as sa_inspect
13
+ from sqlalchemy.orm import Session
14
+
15
+ from simple_module_db.mixins import MultiTenantMixin
16
+ from simple_module_db.query_filter import is_strict
17
+ from simple_module_db.tenancy import (
18
+ TenantIsolationError,
19
+ current_tenant_id,
20
+ is_all_tenants,
21
+ missing_tenant_error,
22
+ )
23
+
24
+
25
+ def _owner(obj: MultiTenantMixin) -> str | None:
26
+ """The tenant the row belongs to in the database (before this flush)."""
27
+ hist = sa_inspect(obj).attrs.tenant_id.history
28
+ if hist.deleted:
29
+ return hist.deleted[0]
30
+ return obj.tenant_id
31
+
32
+
33
+ def guard_flush(session: Session) -> None:
34
+ if is_all_tenants():
35
+ return
36
+ tenant_id = current_tenant_id.get()
37
+ strict = is_strict(session)
38
+
39
+ for obj in session.new:
40
+ if not isinstance(obj, MultiTenantMixin):
41
+ continue
42
+ if obj.tenant_id is None:
43
+ if tenant_id is not None:
44
+ obj.tenant_id = tenant_id
45
+ elif strict:
46
+ raise missing_tenant_error(type(obj).__name__, "INSERT")
47
+ elif tenant_id is not None and obj.tenant_id != tenant_id:
48
+ raise TenantIsolationError(
49
+ f"Cannot create object for tenant '{obj.tenant_id}' "
50
+ f"in context of tenant '{tenant_id}'"
51
+ )
52
+
53
+ for obj in list(session.dirty) + list(session.deleted):
54
+ if not isinstance(obj, MultiTenantMixin):
55
+ continue
56
+ is_deleted = obj in session.deleted
57
+ if not is_deleted and not session.is_modified(obj):
58
+ continue
59
+ # Moving a row between tenants is never a routine edit, bound or not
60
+ # (#356); only a deliberate all_tenants() block may do it.
61
+ if not is_deleted and sa_inspect(obj).attrs.tenant_id.history.has_changes():
62
+ raise TenantIsolationError("Cannot change tenant_id of an existing object")
63
+ owner = _owner(obj)
64
+ op = "DELETE" if is_deleted else "UPDATE"
65
+ if tenant_id is not None and owner != tenant_id:
66
+ raise TenantIsolationError(
67
+ f"Cannot {op} an object of tenant '{owner}' in context of tenant '{tenant_id}'"
68
+ )
69
+ if tenant_id is None and strict:
70
+ raise missing_tenant_error(type(obj).__name__, op)
71
+
72
+
73
+ __all__ = ["guard_flush"]
@@ -0,0 +1,131 @@
1
+ """INSERT guard and statement-value helpers for the tenant query filter.
2
+
3
+ Explicit ``tenant_id`` values in an insert must match the bound tenant, and
4
+ missing ones are stamped with it (#357); the same value readers let the
5
+ query filter refuse an UPDATE that assigns ``tenant_id`` (#356).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from sqlalchemy.orm import ORMExecuteState
13
+
14
+ from simple_module_db import model_registry as registry
15
+ from simple_module_db.tenancy import (
16
+ ALL_TENANTS_OPTION,
17
+ TenantIsolationError,
18
+ current_tenant_id,
19
+ is_all_tenants,
20
+ missing_tenant_error,
21
+ )
22
+
23
+ TENANT_COLUMN = "tenant_id"
24
+
25
+
26
+ def _bound_value(value: Any) -> Any:
27
+ return getattr(value, "value", value)
28
+
29
+
30
+ def _column_name(key: Any) -> str:
31
+ return getattr(key, "key", None) or getattr(key, "name", None) or str(key)
32
+
33
+
34
+ def _statement_values(execute_state: ORMExecuteState) -> dict[str, Any]:
35
+ raw = getattr(execute_state.statement, "_values", None) or {}
36
+ return {_column_name(k): _bound_value(v) for k, v in raw.items()}
37
+
38
+
39
+ def _param_rows(execute_state: ORMExecuteState) -> list[dict[str, Any]]:
40
+ params = execute_state.parameters
41
+ if isinstance(params, dict):
42
+ return [params] if params else []
43
+ return [p for p in (params or []) if isinstance(p, dict)]
44
+
45
+
46
+ def assigned_tenant_ids(execute_state: ORMExecuteState) -> list[Any]:
47
+ found = []
48
+ stmt_values = _statement_values(execute_state)
49
+ if TENANT_COLUMN in stmt_values:
50
+ found.append(stmt_values[TENANT_COLUMN])
51
+ found.extend(row[TENANT_COLUMN] for row in _param_rows(execute_state) if TENANT_COLUMN in row)
52
+ return found
53
+
54
+
55
+ def _foreign_insert(value: Any, tenant_id: str) -> TenantIsolationError:
56
+ return TenantIsolationError(
57
+ f"Cannot insert rows for tenant '{value}' in context of tenant '{tenant_id}'"
58
+ )
59
+
60
+
61
+ def guard_insert(execute_state: ORMExecuteState, *, strict: bool) -> Any:
62
+ mappers = [m.class_.__name__ for m in execute_state.all_mappers if registry.flags(m.class_)[1]]
63
+ target = getattr(execute_state.statement, "table", None)
64
+ if not mappers and getattr(target, "name", None) in registry.tenant_table_names:
65
+ mappers = [target.name] # Core insert(Model.__table__)
66
+ if not mappers or execute_state.execution_options.get(ALL_TENANTS_OPTION, False):
67
+ return None
68
+ if is_all_tenants():
69
+ return None
70
+ tenant_id = current_tenant_id.get()
71
+ if getattr(execute_state.statement, "_multi_values", None):
72
+ return _guard_multi_values(execute_state, tenant_id, mappers[0], strict)
73
+ for value in assigned_tenant_ids(execute_state):
74
+ if tenant_id is not None and value != tenant_id:
75
+ raise _foreign_insert(value, tenant_id)
76
+
77
+ rows = _param_rows(execute_state)
78
+ stmt_has_tenant = TENANT_COLUMN in _statement_values(execute_state)
79
+ missing = (
80
+ [r for r in rows if TENANT_COLUMN not in r] if rows else ([] if stmt_has_tenant else [None])
81
+ )
82
+ if not missing:
83
+ return None
84
+ if tenant_id is None:
85
+ if strict:
86
+ raise missing_tenant_error(mappers[0], "INSERT")
87
+ return None
88
+ if rows:
89
+ stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN, tenant_id)} for r in rows]
90
+ payload = stamped if isinstance(execute_state.parameters, list) else stamped[0]
91
+ return execute_state.invoke_statement(params=payload)
92
+ execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN: tenant_id})
93
+ return None
94
+
95
+
96
+ def _guard_multi_values(
97
+ execute_state: ORMExecuteState, tenant_id: str | None, entity: str, strict: bool
98
+ ) -> Any:
99
+ """``insert(M).values([{...}, {...}])``: check and stamp each row.
100
+
101
+ ``.values(tenant_id=...)`` cannot be appended to a multi-VALUES insert
102
+ (SQLAlchemy refuses to mix the two forms), so the rows are rewritten.
103
+ """
104
+ stmt = execute_state.statement
105
+ columns = list(stmt.table.c)
106
+ tenant_col = stmt.table.c[TENANT_COLUMN]
107
+ groups, missing = [], False
108
+ for group in stmt._multi_values:
109
+ rows = []
110
+ for row in group:
111
+ mapping = dict(row) if isinstance(row, dict) else dict(zip(columns, row, strict=False))
112
+ keys = {_column_name(k): k for k in mapping}
113
+ if TENANT_COLUMN in keys:
114
+ value = _bound_value(mapping[keys[TENANT_COLUMN]])
115
+ if tenant_id is not None and value != tenant_id:
116
+ raise _foreign_insert(value, tenant_id)
117
+ else:
118
+ missing = True
119
+ mapping[tenant_col] = tenant_id
120
+ rows.append(mapping)
121
+ groups.append(rows)
122
+ if not missing:
123
+ return None
124
+ if tenant_id is None:
125
+ if strict:
126
+ raise missing_tenant_error(entity, "INSERT")
127
+ return None
128
+ stamped = stmt._generate()
129
+ stamped._multi_values = tuple(groups)
130
+ execute_state.statement = stamped
131
+ return None
@@ -8,10 +8,35 @@ from datetime import UTC, datetime
8
8
 
9
9
  from sqlalchemy import event
10
10
  from sqlalchemy import inspect as sa_inspect
11
- from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria
11
+ from sqlalchemy.orm import Session
12
12
 
13
- from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
13
+ from simple_module_db.flush_guard import guard_flush
14
+ from simple_module_db.mixins import AuditMixin, SoftDeleteMixin, VersionedMixin
15
+ from simple_module_db.query_filter import bind_engine_policy, filter_statements
14
16
  from simple_module_db.session import DatabaseState
17
+ from simple_module_db.writes import (
18
+ _HARD_DELETE_KEY,
19
+ SESSION_HAS_WRITES_KEY,
20
+ _is_purge,
21
+ _mark_dml_written,
22
+ _mark_session_written,
23
+ hard_delete,
24
+ mark_written,
25
+ )
26
+
27
+ # Re-exported: ``transaction`` and published modules import these from here.
28
+ __all__ = [
29
+ "SESSION_HAS_WRITES_KEY",
30
+ "TenantIsolationError",
31
+ "current_tenant_id",
32
+ "current_user_id",
33
+ "hard_delete",
34
+ "mark_written",
35
+ "register_listeners",
36
+ ]
37
+
38
+ # Re-exported: callers import these from here since before tenancy.py existed.
39
+ from simple_module_db.tenancy import TenantIsolationError, current_tenant_id
15
40
 
16
41
  logger = logging.getLogger(__name__)
17
42
  _db_logger = logging.getLogger("simple_module.db")
@@ -19,19 +44,6 @@ _db_logger = logging.getLogger("simple_module.db")
19
44
  # Set by auth middleware on each request
20
45
  current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)
21
46
 
22
- # Set by tenant middleware on each request
23
- current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
24
-
25
-
26
- class TenantIsolationError(Exception):
27
- """Raised when a multi-tenancy isolation constraint is violated."""
28
-
29
-
30
- # Key on ``Session.info`` stamped by the after_flush listener so
31
- # ``get_db`` can distinguish read-only requests from write requests after
32
- # flush has cleared ``session.new/.dirty/.deleted``.
33
- SESSION_HAS_WRITES_KEY = "has_writes"
34
-
35
47
  # Key on ``Session.info`` for pending audit snapshots produced in before_flush
36
48
  # and consumed in after_flush_postexec (when DB-assigned PKs are populated).
37
49
  _AUDIT_PENDING_KEY = "_audit_pending"
@@ -70,17 +82,6 @@ def _entity_pk(obj: object) -> object:
70
82
  return None
71
83
 
72
84
 
73
- def _mark_session_written(session: Session, flush_context: object) -> None:
74
- """Flag the session as having performed write work.
75
-
76
- ``get_db`` reads this flag to decide between commit and rollback at
77
- request end. Checking ``session.new/.dirty/.deleted`` directly after
78
- a flush is useless — flush empties those sets — so we stash a tag on
79
- ``session.info`` that survives the rest of the request.
80
- """
81
- session.info[SESSION_HAS_WRITES_KEY] = True
82
-
83
-
84
85
  def register_listeners(db_state: DatabaseState) -> None:
85
86
  """Register SQLAlchemy event listeners for audit, soft delete, versioning, and tenancy.
86
87
 
@@ -93,22 +94,37 @@ def register_listeners(db_state: DatabaseState) -> None:
93
94
 
94
95
  global _db_state
95
96
  _db_state = db_state
96
-
97
- event.listen(db_state.sync_session_class, "before_flush", _before_flush_listener)
98
- event.listen(db_state.sync_session_class, "after_flush", _mark_session_written)
99
- event.listen(db_state.sync_session_class, "after_flush_postexec", _after_flush_audit)
100
- event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements)
97
+ # Strict mode is read from db_state at query time, per engine.
98
+ bind_engine_policy(db_state.engine.sync_engine, db_state)
99
+ attach_session_listeners(db_state.sync_session_class)
101
100
  db_state._listeners_registered = True
102
101
  logger.info("Registered SQLAlchemy entity listeners")
103
102
 
104
103
 
104
+ def attach_session_listeners(session_class: type[Session]) -> None:
105
+ """Attach the entity listeners to a session class; idempotent.
106
+
107
+ For a process with no ``DatabaseState`` (the Celery worker's sync engine),
108
+ call this and ``bind_engine_policy(engine, EngineTenancy(...))``.
109
+ """
110
+ for name, fn in (
111
+ ("before_flush", _before_flush_listener),
112
+ ("after_flush", _mark_session_written),
113
+ ("after_flush_postexec", _after_flush_audit),
114
+ ("do_orm_execute", _mark_dml_written),
115
+ ("do_orm_execute", filter_statements),
116
+ ):
117
+ if not event.contains(session_class, name, fn):
118
+ event.listen(session_class, name, fn)
119
+
120
+
105
121
  def _before_flush_listener(
106
122
  session: Session,
107
123
  flush_context: object,
108
124
  instances: object,
109
125
  ) -> None:
126
+ guard_flush(session)
110
127
  user_id = current_user_id.get()
111
- tenant_id = current_tenant_id.get()
112
128
  now = datetime.now(UTC)
113
129
 
114
130
  for obj in session.new:
@@ -118,16 +134,6 @@ def _before_flush_listener(
118
134
  if obj.updated_by is None:
119
135
  obj.updated_by = user_id
120
136
 
121
- # Auto-populate tenant_id; reject cross-tenant creation
122
- if isinstance(obj, MultiTenantMixin):
123
- if obj.tenant_id is None and tenant_id is not None:
124
- obj.tenant_id = tenant_id
125
- elif tenant_id is not None and obj.tenant_id != tenant_id:
126
- raise TenantIsolationError(
127
- f"Cannot create object for tenant '{obj.tenant_id}' "
128
- f"in context of tenant '{tenant_id}'"
129
- )
130
-
131
137
  _db_logger.info(
132
138
  _EVENT_ENTITY_CREATED,
133
139
  extra={
@@ -148,12 +154,6 @@ def _before_flush_listener(
148
154
  if isinstance(obj, VersionedMixin):
149
155
  obj.version += 1
150
156
 
151
- # Prevent tenant_id from being changed on existing objects
152
- if isinstance(obj, MultiTenantMixin) and tenant_id is not None:
153
- hist = sa_inspect(obj).attrs.tenant_id.history
154
- if hist.has_changes():
155
- raise TenantIsolationError("Cannot change tenant_id of an existing object")
156
-
157
157
  _db_logger.info(
158
158
  _EVENT_ENTITY_UPDATED,
159
159
  extra={
@@ -165,8 +165,9 @@ def _before_flush_listener(
165
165
  )
166
166
 
167
167
  # Deleted objects — convert to soft delete if applicable
168
+ hard_delete_ids = session.info.pop(_HARD_DELETE_KEY, frozenset())
168
169
  for obj in list(session.deleted):
169
- if isinstance(obj, SoftDeleteMixin):
170
+ if isinstance(obj, SoftDeleteMixin) and not _is_purge(obj, hard_delete_ids):
170
171
  # Cancel the hard delete
171
172
  session.expunge(obj)
172
173
  # Merge back as modified with soft-delete fields set
@@ -233,47 +234,3 @@ def _after_flush_audit(session: Session, flush_context: object) -> None:
233
234
  records = finalize_records(pending)
234
235
  if records:
235
236
  _db_state.audit_callback(session, records)
236
-
237
-
238
- # Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the
239
- # ``do_orm_execute`` hot path skips redundant ``issubclass`` work on every query.
240
- _mixin_flags_cache: dict[type, tuple[bool, bool]] = {}
241
-
242
-
243
- def _filter_select_statements(execute_state: ORMExecuteState) -> None:
244
- """Attach per-mapper ``with_loader_criteria`` for soft-delete and tenant isolation.
245
-
246
- The criteria are attached per concrete mapper because SQLModel mixins
247
- expose Pydantic ``FieldInfo`` (not SQLAlchemy ``InstrumentedAttribute``)
248
- at the mixin-class level, which breaks the lambda form of
249
- ``with_loader_criteria`` that was used before the SQLModel migration.
250
-
251
- Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``.
252
- """
253
- if not execute_state.is_select:
254
- return
255
-
256
- skip_soft_delete = execute_state.execution_options.get("include_deleted", False)
257
- tenant_id = current_tenant_id.get()
258
- if skip_soft_delete and tenant_id is None:
259
- return
260
-
261
- options = []
262
- for mapper in execute_state.all_mappers:
263
- cls = mapper.class_
264
- flags = _mixin_flags_cache.get(cls)
265
- if flags is None:
266
- flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin))
267
- _mixin_flags_cache[cls] = flags
268
- is_soft_delete, is_multi_tenant = flags
269
- if is_soft_delete and not skip_soft_delete:
270
- options.append(
271
- with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True)
272
- )
273
- if is_multi_tenant and tenant_id is not None:
274
- options.append(
275
- with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True)
276
- )
277
-
278
- if options:
279
- execute_state.statement = execute_state.statement.options(*options)