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.
@@ -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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.