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.
- simple_module_db-0.0.1/.gitignore +59 -0
- simple_module_db-0.0.1/LICENSE +21 -0
- simple_module_db-0.0.1/PKG-INFO +85 -0
- simple_module_db-0.0.1/README.md +54 -0
- simple_module_db-0.0.1/pyproject.toml +43 -0
- simple_module_db-0.0.1/simple_module_db/__init__.py +27 -0
- simple_module_db-0.0.1/simple_module_db/base.py +98 -0
- simple_module_db-0.0.1/simple_module_db/deps.py +62 -0
- simple_module_db-0.0.1/simple_module_db/listeners.py +230 -0
- simple_module_db-0.0.1/simple_module_db/migrations.py +112 -0
- simple_module_db-0.0.1/simple_module_db/mixins.py +62 -0
- simple_module_db-0.0.1/simple_module_db/provider.py +16 -0
- simple_module_db-0.0.1/simple_module_db/py.typed +0 -0
- simple_module_db-0.0.1/simple_module_db/session.py +70 -0
- simple_module_db-0.0.1/tests/_models.py +30 -0
- simple_module_db-0.0.1/tests/conftest.py +25 -0
- simple_module_db-0.0.1/tests/test_base.py +88 -0
- simple_module_db-0.0.1/tests/test_db_logging.py +128 -0
- simple_module_db-0.0.1/tests/test_migrations.py +56 -0
- simple_module_db-0.0.1/tests/test_multi_tenancy.py +273 -0
- simple_module_db-0.0.1/tests/test_session.py +65 -0
|
@@ -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)
|