simple-module-db 0.0.1__py3-none-any.whl
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/__init__.py +27 -0
- simple_module_db/base.py +98 -0
- simple_module_db/deps.py +62 -0
- simple_module_db/listeners.py +230 -0
- simple_module_db/migrations.py +112 -0
- simple_module_db/mixins.py +62 -0
- simple_module_db/provider.py +16 -0
- simple_module_db/py.typed +0 -0
- simple_module_db/session.py +70 -0
- simple_module_db-0.0.1.dist-info/METADATA +85 -0
- simple_module_db-0.0.1.dist-info/RECORD +13 -0
- simple_module_db-0.0.1.dist-info/WHEEL +4 -0
- simple_module_db-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
simple_module_db/base.py
ADDED
|
@@ -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
|
simple_module_db/deps.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Helpers for Alembic integration with module-based schemas.
|
|
2
|
+
|
|
3
|
+
A host's ``migrations/env.py`` should call :func:`build_module_metadata` to
|
|
4
|
+
obtain the combined ``target_metadata`` for autogenerate, and
|
|
5
|
+
:func:`make_include_object` to obtain an ``include_object`` filter that
|
|
6
|
+
protects non-module tables from being touched by autogenerate.
|
|
7
|
+
|
|
8
|
+
This abstraction decouples the host from the import mechanics and is the
|
|
9
|
+
single place where a pip-installed module's ``<pkg>.models`` submodule gets
|
|
10
|
+
loaded — so every host scaffolded by ``sm create-host`` behaves identically
|
|
11
|
+
whether the module was installed via workspace path, PyPI wheel, or
|
|
12
|
+
``pip install -e``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from simple_module_core import ModuleBase
|
|
23
|
+
from simple_module_core.discovery import discover_modules, get_module_package_name
|
|
24
|
+
from sqlalchemy import MetaData
|
|
25
|
+
from sqlalchemy.schema import SchemaItem
|
|
26
|
+
|
|
27
|
+
from simple_module_db.base import all_module_bases
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Matches alembic.context.configure's include_object signature exactly so
|
|
32
|
+
# type-checkers accept the returned filter without casts or ignores.
|
|
33
|
+
_SchemaItemType = Literal[
|
|
34
|
+
"schema", "table", "column", "index", "unique_constraint", "foreign_key_constraint"
|
|
35
|
+
]
|
|
36
|
+
IncludeObjectFn = Callable[[SchemaItem, str | None, _SchemaItemType, bool, SchemaItem | None], bool]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_module_metadata(modules: Sequence[ModuleBase] | None = None) -> MetaData:
|
|
40
|
+
"""Import every installed module's ``models`` submodule and return combined MetaData.
|
|
41
|
+
|
|
42
|
+
Returns a single :class:`MetaData` containing every ``Table`` declared by
|
|
43
|
+
every installed module's SQLAlchemy models. Modules without a ``models``
|
|
44
|
+
submodule are skipped without error.
|
|
45
|
+
|
|
46
|
+
:param modules: Optional list of already-discovered modules. When ``None``,
|
|
47
|
+
:func:`discover_modules` is called to find them. Callers that already
|
|
48
|
+
have the list (e.g. an env.py that shares state with the app) should
|
|
49
|
+
pass it in to avoid re-parsing entry_points and re-running the
|
|
50
|
+
framework version check.
|
|
51
|
+
"""
|
|
52
|
+
if modules is None:
|
|
53
|
+
modules = discover_modules()
|
|
54
|
+
for mod in modules:
|
|
55
|
+
pkg = get_module_package_name(mod)
|
|
56
|
+
try:
|
|
57
|
+
importlib.import_module(f"{pkg}.models")
|
|
58
|
+
except ModuleNotFoundError:
|
|
59
|
+
logger.debug("No models submodule for module '%s' (pkg=%s)", mod.meta.name, pkg)
|
|
60
|
+
|
|
61
|
+
combined = MetaData()
|
|
62
|
+
for base in all_module_bases:
|
|
63
|
+
for table in base.metadata.tables.values():
|
|
64
|
+
table.to_metadata(combined)
|
|
65
|
+
return combined
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_include_object(metadata: MetaData) -> IncludeObjectFn:
|
|
69
|
+
"""Return an Alembic ``include_object`` filter scoped to the module tables.
|
|
70
|
+
|
|
71
|
+
Call as ``context.configure(..., include_object=make_include_object(meta))``.
|
|
72
|
+
The filter accepts only table names present in ``metadata``, preventing
|
|
73
|
+
autogenerate from diffing — and potentially dropping — tables that exist
|
|
74
|
+
in the database but aren't owned by any installed module (e.g. a user
|
|
75
|
+
table added by the host developer outside the module system).
|
|
76
|
+
"""
|
|
77
|
+
allowlist = {t.name for t in metadata.tables.values()}
|
|
78
|
+
|
|
79
|
+
def include_object(
|
|
80
|
+
object: SchemaItem,
|
|
81
|
+
name: str | None,
|
|
82
|
+
type_: _SchemaItemType,
|
|
83
|
+
reflected: bool,
|
|
84
|
+
compare_to: SchemaItem | None,
|
|
85
|
+
) -> bool:
|
|
86
|
+
if type_ == "table":
|
|
87
|
+
return name in allowlist
|
|
88
|
+
parent_table = getattr(object, "table", None)
|
|
89
|
+
if parent_table is not None:
|
|
90
|
+
return parent_table.name in allowlist
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
return include_object
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_item(type_, obj, autogen_context):
|
|
97
|
+
"""Alembic ``render_item`` callback that collapses SQLModel's ``AutoString``.
|
|
98
|
+
|
|
99
|
+
Without this, autogenerate emits ``sqlmodel.sql.sqltypes.AutoString(...)``
|
|
100
|
+
in generated migrations but does not add the corresponding
|
|
101
|
+
``import sqlmodel``, so the migration fails with ``NameError`` on apply.
|
|
102
|
+
``AutoString`` is a thin wrapper over ``String``, so collapsing it here
|
|
103
|
+
keeps migrations self-contained without losing semantics.
|
|
104
|
+
|
|
105
|
+
Pass to :func:`alembic.context.configure` as ``render_item=render_item``.
|
|
106
|
+
"""
|
|
107
|
+
if type_ == "type" and type(obj).__name__ == "AutoString":
|
|
108
|
+
length = getattr(obj, "length", None)
|
|
109
|
+
if length is not None:
|
|
110
|
+
return f"sa.String(length={length})"
|
|
111
|
+
return "sa.String()"
|
|
112
|
+
return False # let alembic use its default rendering
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Entity mixins for cross-cutting concerns (audit, soft delete, tenancy, versioning).
|
|
2
|
+
|
|
3
|
+
We use ``sa_type`` + ``sa_column_kwargs`` (not ``sa_column=Column(...)``) so
|
|
4
|
+
SQLModel constructs a fresh ``Column`` per concrete subclass; sharing a single
|
|
5
|
+
``Column`` across mixin subclasses raises
|
|
6
|
+
``ArgumentError: Column already assigned to Table``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import DateTime, func
|
|
14
|
+
from sqlmodel import Field, SQLModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditMixin(SQLModel):
|
|
18
|
+
"""Adds created_at, updated_at, created_by, updated_by fields.
|
|
19
|
+
|
|
20
|
+
``created_at`` gets a server-side default; ``updated_at`` is populated by
|
|
21
|
+
the audit listener in :mod:`simple_module_db.listeners`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
created_at: datetime = Field(
|
|
25
|
+
sa_type=DateTime(timezone=True),
|
|
26
|
+
sa_column_kwargs={"server_default": func.now()},
|
|
27
|
+
)
|
|
28
|
+
updated_at: datetime | None = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
sa_type=DateTime(timezone=True),
|
|
31
|
+
sa_column_kwargs={"onupdate": func.now()},
|
|
32
|
+
)
|
|
33
|
+
created_by: str | None = Field(default=None, max_length=255)
|
|
34
|
+
updated_by: str | None = Field(default=None, max_length=255)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SoftDeleteMixin(SQLModel):
|
|
38
|
+
"""Marks records as deleted instead of removing them.
|
|
39
|
+
|
|
40
|
+
Query filters installed by :func:`register_listeners` exclude
|
|
41
|
+
``is_deleted=True`` rows by default. Use
|
|
42
|
+
``stmt.execution_options(include_deleted=True)`` to bypass.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
is_deleted: bool = Field(default=False)
|
|
46
|
+
deleted_at: datetime | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
sa_type=DateTime(timezone=True),
|
|
49
|
+
)
|
|
50
|
+
deleted_by: str | None = Field(default=None, max_length=255)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MultiTenantMixin(SQLModel):
|
|
54
|
+
"""Adds a tenant_id column for data isolation in multi-tenant apps."""
|
|
55
|
+
|
|
56
|
+
tenant_id: str = Field(max_length=50, index=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class VersionedMixin(SQLModel):
|
|
60
|
+
"""Optimistic concurrency via an auto-incrementing version field."""
|
|
61
|
+
|
|
62
|
+
version: int = Field(default=1)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Database provider detection."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DatabaseProvider(StrEnum):
|
|
7
|
+
SQLITE = "sqlite"
|
|
8
|
+
POSTGRESQL = "postgresql"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_provider(database_url: str) -> DatabaseProvider:
|
|
12
|
+
"""Detect the database provider from a connection URL."""
|
|
13
|
+
url_lower = database_url.lower()
|
|
14
|
+
if url_lower.startswith(("postgresql", "postgres")):
|
|
15
|
+
return DatabaseProvider.POSTGRESQL
|
|
16
|
+
return DatabaseProvider.SQLITE
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Async engine and session factory management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.ext.asyncio import (
|
|
8
|
+
AsyncEngine,
|
|
9
|
+
AsyncSession,
|
|
10
|
+
async_sessionmaker,
|
|
11
|
+
create_async_engine,
|
|
12
|
+
)
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DatabaseState:
|
|
20
|
+
"""Holds all database state for a single application instance."""
|
|
21
|
+
|
|
22
|
+
engine: AsyncEngine
|
|
23
|
+
session_factory: async_sessionmaker[AsyncSession]
|
|
24
|
+
sync_session_class: type[Session] = field(repr=False, default=Session)
|
|
25
|
+
_listeners_registered: bool = field(default=False, repr=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_db(
|
|
29
|
+
database_url: str,
|
|
30
|
+
*,
|
|
31
|
+
echo: bool = False,
|
|
32
|
+
pool_size: int = 10,
|
|
33
|
+
max_overflow: int = 20,
|
|
34
|
+
pool_pre_ping: bool = True,
|
|
35
|
+
pool_recycle: int = 1800,
|
|
36
|
+
) -> DatabaseState:
|
|
37
|
+
"""Create an async engine and session factory.
|
|
38
|
+
|
|
39
|
+
The pool options only take effect for server-side providers (Postgres).
|
|
40
|
+
SQLite uses SQLAlchemy's default pool (single-file, no network), so
|
|
41
|
+
passing ``pool_size``/etc. would raise ``TypeError`` — skipped below.
|
|
42
|
+
|
|
43
|
+
Returns a ``DatabaseState`` that should be stored on ``app.state.db``.
|
|
44
|
+
"""
|
|
45
|
+
provider = detect_provider(database_url)
|
|
46
|
+
|
|
47
|
+
connect_args: dict = {}
|
|
48
|
+
engine_kwargs: dict = {"echo": echo, "connect_args": connect_args}
|
|
49
|
+
if provider == DatabaseProvider.SQLITE:
|
|
50
|
+
connect_args["check_same_thread"] = False
|
|
51
|
+
else:
|
|
52
|
+
engine_kwargs.update(
|
|
53
|
+
pool_size=pool_size,
|
|
54
|
+
max_overflow=max_overflow,
|
|
55
|
+
pool_pre_ping=pool_pre_ping,
|
|
56
|
+
pool_recycle=pool_recycle,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
engine = create_async_engine(database_url, **engine_kwargs)
|
|
60
|
+
# Scoped Session subclass so event listeners only fire for this engine's sessions
|
|
61
|
+
scoped_session_class = type("ScopedSession", (Session,), {})
|
|
62
|
+
session_factory = async_sessionmaker(
|
|
63
|
+
engine, expire_on_commit=False, sync_session_class=scoped_session_class
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return DatabaseState(
|
|
67
|
+
engine=engine,
|
|
68
|
+
session_factory=session_factory,
|
|
69
|
+
sync_session_class=scoped_session_class,
|
|
70
|
+
)
|
|
@@ -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,13 @@
|
|
|
1
|
+
simple_module_db/__init__.py,sha256=4Hhxiqh8apRK2S1afYRjftIw13R3ZQxcL0DScsgKP84,936
|
|
2
|
+
simple_module_db/base.py,sha256=oLCG4x_v83Kw_uv6_qsfwBzprM7__dVMRd8fUEMmKTk,3497
|
|
3
|
+
simple_module_db/deps.py,sha256=C-nVm2mLOjAuqkNL7MnjnjpDG4x-vPfhTdZbaFbKWts,2306
|
|
4
|
+
simple_module_db/listeners.py,sha256=ENuxdc59GO-x0S8pNMS77psNOkagjQD18xSlLjt7OuM,8176
|
|
5
|
+
simple_module_db/migrations.py,sha256=KZzz3Cr-486P2reGjUczhmIXO1cNEzJg1M4_YrJ326Y,4555
|
|
6
|
+
simple_module_db/mixins.py,sha256=I5Ih2GQOWNe7jERt4wQdxP4itR2SNadfbXEw_yCZcDM,1968
|
|
7
|
+
simple_module_db/provider.py,sha256=jvO42xGh15bj52xd9E4dZc1yur_soSSkBAHKMWhAGi4,444
|
|
8
|
+
simple_module_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
simple_module_db/session.py,sha256=Q3ked5HQIMUJO9jBaPJ37lQha8UXYJyLJ4hm69fdmcw,2181
|
|
10
|
+
simple_module_db-0.0.1.dist-info/METADATA,sha256=uzGqb3gRecAjEPDS_oerg397caPWXdMG17zMUXx2Q88,3371
|
|
11
|
+
simple_module_db-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
simple_module_db-0.0.1.dist-info/licenses/LICENSE,sha256=Yn66lhLklsF5p7pa85_ksQrJ79Q-FgOaUAHevLBjer4,1068
|
|
13
|
+
simple_module_db-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|