simple-module-db 0.0.1__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,59 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # Auto-generated frontend module manifest (regenerated by the host at boot
40
+ # or via `make gen-pages`).
41
+ host/client_app/modules.manifest.json
42
+ host/client_app/modules.generated.ts
43
+ host/client_app/modules.generated.css
44
+
45
+ # Worktrees
46
+ .worktrees/
47
+
48
+ # Performance profiles
49
+ .memray/
50
+ .benchmarks/
51
+
52
+ # OS
53
+ .DS_Store
54
+ Thumbs.db
55
+
56
+ .playwright-cli/*
57
+ .playwright-mcp/*
58
+ host/client_app/.playwright-cli/*
59
+ .superpowers/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anto Subash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_db
3
+ Version: 0.0.1
4
+ Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
9
+ Author-email: Anto Subash <antosubash@live.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: alembic,async,multi-tenant,simple-module,sqlalchemy,sqlmodel
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Database
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: aiosqlite>=0.20
25
+ Requires-Dist: alembic>=1.14
26
+ Requires-Dist: asyncpg>=0.30
27
+ Requires-Dist: simple-module-core==0.0.1
28
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
29
+ Requires-Dist: sqlmodel>=0.0.22
30
+ Description-Content-Type: text/markdown
31
+
32
+ # simple_module_db
33
+
34
+ Database layer for the [simple_module](https://github.com/antosubash/simple_module_python) framework. Provides a per-module `Base`, an async SQLAlchemy/SQLModel session, standard mixins, and an auto-commit-on-flush listener that removes manual `session.commit()` calls from service code.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install simple_module_db
40
+ ```
41
+
42
+ ## What it provides
43
+
44
+ - `create_module_base("<module_name>")` — a module-scoped declarative `Base`. PostgreSQL maps it to its own schema; SQLite namespaces via table-name prefix.
45
+ - 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
+ - Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
47
+ - `DatabaseState` container used by the framework to avoid global mutable state.
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ # modules/orders/orders/models.py
53
+ from simple_module_db import AuditMixin, SoftDeleteMixin, create_module_base
54
+ from sqlmodel import Field
55
+
56
+ Base = create_module_base("orders")
57
+
58
+
59
+ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
60
+ id: int | None = Field(default=None, primary_key=True)
61
+ customer_id: int = Field(index=True, foreign_key="users_user.id")
62
+ total_cents: int
63
+ ```
64
+
65
+ In a service:
66
+
67
+ ```python
68
+ from simple_module_db import get_db
69
+
70
+ async def create_order(session = Depends(get_db), ...):
71
+ order = Order(customer_id=..., total_cents=...)
72
+ session.add(order)
73
+ await session.flush() # assigns order.id; auto-commit happens after the request
74
+ return order
75
+ ```
76
+
77
+ Never call `session.commit()` — the framework handles it.
78
+
79
+ ## Depends on
80
+
81
+ - `simple_module_core`, `sqlalchemy[asyncio]`, `sqlmodel`, `alembic`, `asyncpg`, `aiosqlite`
82
+
83
+ ## License
84
+
85
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,54 @@
1
+ # simple_module_db
2
+
3
+ Database layer for the [simple_module](https://github.com/antosubash/simple_module_python) framework. Provides a per-module `Base`, an async SQLAlchemy/SQLModel session, standard mixins, and an auto-commit-on-flush listener that removes manual `session.commit()` calls from service code.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install simple_module_db
9
+ ```
10
+
11
+ ## What it provides
12
+
13
+ - `create_module_base("<module_name>")` — a module-scoped declarative `Base`. PostgreSQL maps it to its own schema; SQLite namespaces via table-name prefix.
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.
15
+ - Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
16
+ - `DatabaseState` container used by the framework to avoid global mutable state.
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ # modules/orders/orders/models.py
22
+ from simple_module_db import AuditMixin, SoftDeleteMixin, create_module_base
23
+ from sqlmodel import Field
24
+
25
+ Base = create_module_base("orders")
26
+
27
+
28
+ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
29
+ id: int | None = Field(default=None, primary_key=True)
30
+ customer_id: int = Field(index=True, foreign_key="users_user.id")
31
+ total_cents: int
32
+ ```
33
+
34
+ In a service:
35
+
36
+ ```python
37
+ from simple_module_db import get_db
38
+
39
+ async def create_order(session = Depends(get_db), ...):
40
+ order = Order(customer_id=..., total_cents=...)
41
+ session.add(order)
42
+ await session.flush() # assigns order.id; auto-commit happens after the request
43
+ return order
44
+ ```
45
+
46
+ Never call `session.commit()` — the framework handles it.
47
+
48
+ ## Depends on
49
+
50
+ - `simple_module_core`, `sqlalchemy[asyncio]`, `sqlmodel`, `alembic`, `asyncpg`, `aiosqlite`
51
+
52
+ ## License
53
+
54
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "simple_module_db"
3
+ version = "0.0.1"
4
+ description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
10
+ keywords = ["simple-module", "sqlmodel", "sqlalchemy", "async", "alembic", "multi-tenant"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: FastAPI",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Database",
20
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "aiosqlite>=0.20",
25
+ "alembic>=1.14",
26
+ "asyncpg>=0.30",
27
+ "simple_module_core==0.0.1",
28
+ "sqlalchemy[asyncio]>=2.0",
29
+ "sqlmodel>=0.0.22",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/antosubash/simple_module_python"
34
+ Repository = "https://github.com/antosubash/simple_module_python"
35
+ Issues = "https://github.com/antosubash/simple_module_python/issues"
36
+ Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.uv.sources]
43
+ simple_module_core = { workspace = true }
@@ -0,0 +1,27 @@
1
+ """SimpleModule DB - SQLAlchemy async support with per-module schema isolation."""
2
+
3
+ from simple_module_db.base import create_module_base
4
+ from simple_module_db.deps import get_db
5
+ from simple_module_db.listeners import TenantIsolationError, current_tenant_id
6
+ from simple_module_db.migrations import build_module_metadata, make_include_object, render_item
7
+ from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
8
+ from simple_module_db.provider import DatabaseProvider, detect_provider
9
+ from simple_module_db.session import DatabaseState, init_db
10
+
11
+ __all__ = [
12
+ "AuditMixin",
13
+ "DatabaseProvider",
14
+ "DatabaseState",
15
+ "MultiTenantMixin",
16
+ "SoftDeleteMixin",
17
+ "TenantIsolationError",
18
+ "VersionedMixin",
19
+ "build_module_metadata",
20
+ "create_module_base",
21
+ "current_tenant_id",
22
+ "detect_provider",
23
+ "get_db",
24
+ "init_db",
25
+ "make_include_object",
26
+ "render_item",
27
+ ]
@@ -0,0 +1,98 @@
1
+ """Per-module SQLModel base with schema isolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from sqlalchemy import MetaData
8
+ from sqlmodel import SQLModel
9
+
10
+ from simple_module_db.provider import DatabaseProvider, detect_provider
11
+
12
+ # Convention-based naming for constraints (helps Alembic)
13
+ _naming_convention = {
14
+ "ix": "ix_%(column_0_label)s",
15
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
16
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
17
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
18
+ "pk": "pk_%(table_name)s",
19
+ }
20
+
21
+ # Cache created bases to avoid recreating for the same module
22
+ _base_cache: dict[str, type[SQLModel]] = {}
23
+
24
+ # Track all module bases for Alembic discovery. Module-level *mutable* list
25
+ # so callers that imported it before every module registered (e.g. conftest,
26
+ # migrations/env.py) still observe new entries. Deduped at append time —
27
+ # see ``_register_base`` below.
28
+ all_module_bases: list[type[SQLModel]] = []
29
+
30
+
31
+ def _register_base(base: type[SQLModel]) -> None:
32
+ """Append ``base`` to ``all_module_bases`` iff not already present.
33
+
34
+ Guards against the list growing under repeated imports (test suites,
35
+ reloaders, plugin discovery) without changing the public type.
36
+ """
37
+ if base not in all_module_bases:
38
+ all_module_bases.append(base)
39
+
40
+
41
+ def _default_provider() -> DatabaseProvider:
42
+ """Resolve the active provider from ``SM_DATABASE_URL`` at import time.
43
+
44
+ Falls back to SQLite when the variable is unset, keeping the common
45
+ dev-loop happy without requiring callers to plumb the provider through.
46
+ """
47
+ url = os.environ.get("SM_DATABASE_URL", "")
48
+ return detect_provider(url) if url else DatabaseProvider.SQLITE
49
+
50
+
51
+ def create_module_base(
52
+ module_name: str,
53
+ provider: DatabaseProvider | None = None,
54
+ ) -> type[SQLModel]:
55
+ """Create a SQLModel abstract base with schema isolation for a module.
56
+
57
+ - PostgreSQL: uses a dedicated schema (e.g., ``products``)
58
+ - SQLite: single schema; modules are expected to prefix ``__tablename__``
59
+ with the module name to avoid collisions (e.g., ``products_product``)
60
+
61
+ The provider defaults to whatever ``SM_DATABASE_URL`` indicates, so
62
+ module models work in both dev (SQLite) and prod (PostgreSQL) without
63
+ code changes. Pass ``provider=`` explicitly in tests that need to pin it.
64
+
65
+ Returns a cached base if already created for this module+provider. The
66
+ returned class is a ``SQLModel`` subclass with a per-module ``MetaData``;
67
+ concrete table classes declare ``table=True`` and inherit from it.
68
+ """
69
+ if provider is None:
70
+ provider = _default_provider()
71
+
72
+ cache_key = f"{module_name}:{provider}"
73
+ if cache_key in _base_cache:
74
+ return _base_cache[cache_key]
75
+
76
+ schema_name = module_name.lower()
77
+
78
+ if provider == DatabaseProvider.POSTGRESQL:
79
+ mod_metadata = MetaData(schema=schema_name, naming_convention=_naming_convention)
80
+ else:
81
+ mod_metadata = MetaData(naming_convention=_naming_convention)
82
+
83
+ # Use type() to create the class, avoiding class body scoping issues
84
+ ModuleBase = type( # noqa: N806
85
+ f"{module_name.title()}Base",
86
+ (SQLModel,),
87
+ {
88
+ "__abstract__": True,
89
+ "metadata": mod_metadata,
90
+ },
91
+ )
92
+
93
+ # Store module name for reference
94
+ ModuleBase.__module_name__ = schema_name # type: ignore[attr-defined]
95
+
96
+ _base_cache[cache_key] = ModuleBase
97
+ _register_base(ModuleBase)
98
+ return ModuleBase
@@ -0,0 +1,62 @@
1
+ """FastAPI dependencies for database access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import AsyncGenerator
8
+
9
+ from fastapi import Request
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
13
+
14
+ _db_logger = logging.getLogger("simple_module.db")
15
+
16
+
17
+ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
18
+ """Yield an async database session, auto-closing on exit.
19
+
20
+ Commits only when the session has pending writes (``new``, ``dirty``,
21
+ or ``deleted`` objects). Read-only handlers still open an implicit
22
+ transaction but exit via ``rollback`` — that's one round-trip
23
+ cheaper than ``commit`` and keeps read-only queries from showing up
24
+ as writes in query logs / ``pg_stat_statements``.
25
+
26
+ Usage in FastAPI endpoints::
27
+
28
+ @router.get("/items")
29
+ async def list_items(db: AsyncSession = Depends(get_db)):
30
+ ...
31
+ """
32
+ factory = request.app.state.sm.db.session_factory
33
+ start = time.perf_counter()
34
+ async with factory() as session:
35
+ try:
36
+ yield session
37
+ # ``has_writes`` is set by the after_flush listener and
38
+ # survives the flush emptying session.new/.dirty/.deleted.
39
+ has_pending = bool(
40
+ session.info.get(SESSION_HAS_WRITES_KEY)
41
+ or session.new
42
+ or session.dirty
43
+ or session.deleted
44
+ )
45
+ if has_pending:
46
+ await session.commit()
47
+ op, log_message = "commit", "db.session.commit"
48
+ log = _db_logger.info
49
+ else:
50
+ await session.rollback()
51
+ op, log_message = "read_only_rollback", "db.session.read_only"
52
+ log = _db_logger.debug
53
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
54
+ log(log_message, extra={"operation": op, "db_duration_ms": duration_ms})
55
+ except Exception:
56
+ await session.rollback()
57
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
58
+ _db_logger.warning(
59
+ "db.session.rollback",
60
+ extra={"operation": "rollback", "db_duration_ms": duration_ms},
61
+ )
62
+ raise
@@ -0,0 +1,230 @@
1
+ """SQLAlchemy event listeners for auto-populating entity fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextvars import ContextVar
7
+ from datetime import UTC, datetime
8
+
9
+ from sqlalchemy import event
10
+ from sqlalchemy import inspect as sa_inspect
11
+ from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria
12
+
13
+ from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
14
+ from simple_module_db.session import DatabaseState
15
+
16
+ logger = logging.getLogger(__name__)
17
+ _db_logger = logging.getLogger("simple_module.db")
18
+
19
+ # Set by auth middleware on each request
20
+ current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)
21
+
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
+ # DB audit event names
36
+ _EVENT_ENTITY_CREATED = "db.entity.created"
37
+ _EVENT_ENTITY_UPDATED = "db.entity.updated"
38
+ _EVENT_ENTITY_SOFT_DELETED = "db.entity.soft_deleted"
39
+ _EVENT_ENTITY_DELETED = "db.entity.deleted"
40
+
41
+ # DB operation strings used in log extra dicts
42
+ _OP_CREATE = "create"
43
+ _OP_UPDATE = "update"
44
+ _OP_SOFT_DELETE = "soft_delete"
45
+ _OP_DELETE = "delete"
46
+
47
+
48
+ def _entity_label(obj: object) -> str:
49
+ """Return 'ClassName' for a mapped entity instance."""
50
+ return type(obj).__name__
51
+
52
+
53
+ def _entity_pk(obj: object) -> object:
54
+ """Return the primary key value(s) if available, else None."""
55
+ try:
56
+ inspector = sa_inspect(obj)
57
+ if inspector is None:
58
+ return None
59
+ identity = inspector.identity
60
+ if identity and len(identity) == 1:
61
+ return identity[0]
62
+ return identity
63
+ except Exception:
64
+ return None
65
+
66
+
67
+ def _mark_session_written(session: Session, flush_context: object) -> None:
68
+ """Flag the session as having performed write work.
69
+
70
+ ``get_db`` reads this flag to decide between commit and rollback at
71
+ request end. Checking ``session.new/.dirty/.deleted`` directly after
72
+ a flush is useless — flush empties those sets — so we stash a tag on
73
+ ``session.info`` that survives the rest of the request.
74
+ """
75
+ session.info[SESSION_HAS_WRITES_KEY] = True
76
+
77
+
78
+ def register_listeners(db_state: DatabaseState) -> None:
79
+ """Register SQLAlchemy event listeners for audit, soft delete, versioning, and tenancy.
80
+
81
+ Registers on the engine-scoped session events. Safe to call multiple times
82
+ — subsequent calls are no-ops.
83
+ """
84
+ if db_state._listeners_registered:
85
+ logger.debug("Listeners already registered, skipping")
86
+ return
87
+
88
+ event.listen(db_state.sync_session_class, "before_flush", _before_flush_listener)
89
+ event.listen(db_state.sync_session_class, "after_flush", _mark_session_written)
90
+ event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements)
91
+ db_state._listeners_registered = True
92
+ logger.info("Registered SQLAlchemy entity listeners")
93
+
94
+
95
+ def _before_flush_listener(
96
+ session: Session,
97
+ flush_context: object,
98
+ instances: object,
99
+ ) -> None:
100
+ user_id = current_user_id.get()
101
+ tenant_id = current_tenant_id.get()
102
+ now = datetime.now(UTC)
103
+
104
+ for obj in session.new:
105
+ if isinstance(obj, AuditMixin):
106
+ if obj.created_by is None:
107
+ obj.created_by = user_id
108
+ if obj.updated_by is None:
109
+ obj.updated_by = user_id
110
+
111
+ # Auto-populate tenant_id; reject cross-tenant creation
112
+ if isinstance(obj, MultiTenantMixin):
113
+ if obj.tenant_id is None and tenant_id is not None:
114
+ obj.tenant_id = tenant_id
115
+ elif tenant_id is not None and obj.tenant_id != tenant_id:
116
+ raise TenantIsolationError(
117
+ f"Cannot create object for tenant '{obj.tenant_id}' "
118
+ f"in context of tenant '{tenant_id}'"
119
+ )
120
+
121
+ _db_logger.info(
122
+ _EVENT_ENTITY_CREATED,
123
+ extra={
124
+ "operation": _OP_CREATE,
125
+ "entity": _entity_label(obj),
126
+ "user_id": user_id,
127
+ },
128
+ )
129
+
130
+ for obj in session.dirty:
131
+ if not session.is_modified(obj):
132
+ continue
133
+
134
+ if isinstance(obj, AuditMixin):
135
+ obj.updated_at = now
136
+ obj.updated_by = user_id
137
+
138
+ if isinstance(obj, VersionedMixin):
139
+ obj.version += 1
140
+
141
+ # Prevent tenant_id from being changed on existing objects
142
+ if isinstance(obj, MultiTenantMixin) and tenant_id is not None:
143
+ hist = sa_inspect(obj).attrs.tenant_id.history
144
+ if hist.has_changes():
145
+ raise TenantIsolationError("Cannot change tenant_id of an existing object")
146
+
147
+ _db_logger.info(
148
+ _EVENT_ENTITY_UPDATED,
149
+ extra={
150
+ "operation": _OP_UPDATE,
151
+ "entity": _entity_label(obj),
152
+ "entity_id": _entity_pk(obj),
153
+ "user_id": user_id,
154
+ },
155
+ )
156
+
157
+ # Deleted objects — convert to soft delete if applicable
158
+ for obj in list(session.deleted):
159
+ if isinstance(obj, SoftDeleteMixin):
160
+ # Cancel the hard delete
161
+ session.expunge(obj)
162
+ # Merge back as modified with soft-delete fields set
163
+ obj.is_deleted = True
164
+ obj.deleted_at = now
165
+ obj.deleted_by = user_id
166
+ session.add(obj)
167
+
168
+ _db_logger.info(
169
+ _EVENT_ENTITY_SOFT_DELETED,
170
+ extra={
171
+ "operation": _OP_SOFT_DELETE,
172
+ "entity": _entity_label(obj),
173
+ "entity_id": _entity_pk(obj),
174
+ "user_id": user_id,
175
+ },
176
+ )
177
+ else:
178
+ _db_logger.info(
179
+ _EVENT_ENTITY_DELETED,
180
+ extra={
181
+ "operation": _OP_DELETE,
182
+ "entity": _entity_label(obj),
183
+ "entity_id": _entity_pk(obj),
184
+ "user_id": user_id,
185
+ },
186
+ )
187
+
188
+
189
+ # Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the
190
+ # ``do_orm_execute`` hot path skips redundant ``issubclass`` work on every query.
191
+ _mixin_flags_cache: dict[type, tuple[bool, bool]] = {}
192
+
193
+
194
+ def _filter_select_statements(execute_state: ORMExecuteState) -> None:
195
+ """Attach per-mapper ``with_loader_criteria`` for soft-delete and tenant isolation.
196
+
197
+ The criteria are attached per concrete mapper because SQLModel mixins
198
+ expose Pydantic ``FieldInfo`` (not SQLAlchemy ``InstrumentedAttribute``)
199
+ at the mixin-class level, which breaks the lambda form of
200
+ ``with_loader_criteria`` that was used before the SQLModel migration.
201
+
202
+ Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``.
203
+ """
204
+ if not execute_state.is_select:
205
+ return
206
+
207
+ skip_soft_delete = execute_state.execution_options.get("include_deleted", False)
208
+ tenant_id = current_tenant_id.get()
209
+ if skip_soft_delete and tenant_id is None:
210
+ return
211
+
212
+ options = []
213
+ for mapper in execute_state.all_mappers:
214
+ cls = mapper.class_
215
+ flags = _mixin_flags_cache.get(cls)
216
+ if flags is None:
217
+ flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin))
218
+ _mixin_flags_cache[cls] = flags
219
+ is_soft_delete, is_multi_tenant = flags
220
+ if is_soft_delete and not skip_soft_delete:
221
+ options.append(
222
+ with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True)
223
+ )
224
+ if is_multi_tenant and tenant_id is not None:
225
+ options.append(
226
+ with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True)
227
+ )
228
+
229
+ if options:
230
+ execute_state.statement = execute_state.statement.options(*options)