Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# simple_module_python

A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLAlchemy models, schema, FastAPI endpoints, React pages — but everything ships as one FastAPI + Inertia.js + React app. No microservice tax, no API-client glue; just plugin modules that compose at boot.
A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLModel tables, schema, FastAPI endpoints, React pages — but everything ships as one FastAPI + Inertia.js + React app. No microservice tax, no API-client glue; just plugin modules that compose at boot.

## Stack

- **Backend:** Python 3.12, FastAPI, SQLAlchemy async, Alembic
- **Backend:** Python 3.12, FastAPI, SQLModel (SQLAlchemy async + Pydantic), Alembic
- **Frontend:** Inertia.js + React + Tailwind CSS 4, Vite HMR
- **Auth:** Local user management (email+password, cookie-based sessions) via fastapi-users
- **Tooling:** uv workspaces, Ruff, ty, Biome, pytest
Expand Down Expand Up @@ -36,7 +36,7 @@ Hit `http://localhost:8000` — you land on the public page. `/users/login` is t
make new-module name=orders
```

That scaffolds `modules/orders/` with a working CRUD module end-to-end — `ModuleMeta`, SQLAlchemy model with `AuditMixin`, Pydantic contracts, service layer, REST + Inertia view endpoints, `Browse/Create/Edit.tsx` pages, and tests. Next:
That scaffolds `modules/orders/` with a working CRUD module end-to-end — `ModuleMeta`, SQLModel table with `AuditMixin`, SQLModel contracts, service layer, REST + Inertia view endpoints, `Browse/Create/Edit.tsx` pages, and tests. Next:

```bash
# 1. Edit modules/orders/orders/models.py to your actual schema
Expand Down
34 changes: 32 additions & 2 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ modules/<name>/
└── <name>/
├── __init__.py
├── module.py # ModuleBase subclass with meta = ModuleMeta(...)
├── models.py # SQLAlchemy models (optional)
├── models.py # SQLModel tables (optional)
├── service.py # business logic
├── deps.py # FastAPI dependencies
├── contracts/ # Pydantic DTOs + Protocol interfaces (public)
├── contracts/ # SQLModel DTOs + Protocol interfaces (public)
├── endpoints/
│ ├── api.py # REST endpoints (JSON)
│ └── views.py # Inertia view endpoints
Expand Down Expand Up @@ -105,6 +105,36 @@ class AuthModule(ModuleBase):

## Database

### Models

**SQLModel is the project-wide standard for every model — both DB tables and DTOs.** Table classes declare `table=True` and inherit the per-module `Base`; DTOs are plain `SQLModel` subclasses. No Pydantic `BaseModel` and no SQLAlchemy `DeclarativeBase` + `Mapped[...]`/`mapped_column` in module code.

```python
# modules/orders/orders/models.py
from simple_module_db.base import create_module_base
from simple_module_db.mixins import AuditMixin
from sqlmodel import Field

Base = create_module_base("orders")

class Order(Base, AuditMixin, table=True):
__tablename__ = "orders_order"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(max_length=200)

# modules/orders/orders/contracts/schemas.py
from pydantic import ConfigDict
from sqlmodel import Field, SQLModel

class OrderOut(SQLModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str

class OrderCreate(SQLModel):
name: str = Field(min_length=1, max_length=200)
```

### Per-module Base

```python
Expand Down
4 changes: 2 additions & 2 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ my-module/
├── my_module/
│ ├── __init__.py
│ ├── module.py # ModuleBase subclass
│ ├── models.py # SQLAlchemy models (optional)
│ ├── models.py # SQLModel tables (optional)
│ ├── endpoints/ # FastAPI routes
│ ├── pages/ # Inertia TSX pages (optional)
│ ├── templates/ # Jinja2 templates (optional)
Expand Down Expand Up @@ -92,7 +92,7 @@ installed modules.
## Migrations workflow

Migrations live **in the host scaffold** (`<host>/migrations/versions/`),
not inside the module package. The module ships its SQLAlchemy models only;
not inside the module package. The module ships its SQLModel tables only;
the host developer generates a migration each time a new module is
installed or a module's models change:

Expand Down
2 changes: 1 addition & 1 deletion framework/core/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async def handler(event: OrderCreated):
await bus.publish(OrderCreated(order_id=42))

assert len(received) == 1
assert received[0].order_id == 42 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
assert received[0].order_id == 42 # type: ignore[attr-defined]

async def test_multiple_handlers(self):
bus = EventBus()
Expand Down
8 changes: 4 additions & 4 deletions framework/core/tests/test_module_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ async def test_default_noop_methods(self):
class TestModuleLifecycle:
async def test_on_startup_default_noop(self):
mod = DummyModule()
await mod.on_startup(None) # type: ignore
await mod.on_startup(None)

async def test_on_shutdown_default_noop(self):
mod = DummyModule()
await mod.on_shutdown(None) # type: ignore
await mod.on_shutdown(None)

async def test_register_event_handlers_default_noop(self):
mod = DummyModule()
Expand All @@ -89,7 +89,7 @@ async def test_register_feature_flags_default_noop(self):
class TestModuleNewHooks:
async def test_register_exception_handlers_default_noop(self):
mod = DummyModule()
mod.register_exception_handlers(None) # type: ignore
mod.register_exception_handlers(None)

async def test_register_health_checks_default_noop(self):
mod = DummyModule()
Expand All @@ -99,7 +99,7 @@ async def test_register_health_checks_default_noop(self):

async def test_register_settings_default_noop(self):
mod = DummyModule()
mod.register_settings(None) # type: ignore
mod.register_settings(None)


class TestModuleAssetHooks:
Expand Down
1 change: 1 addition & 0 deletions framework/db/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"asyncpg>=0.30",
"simple-module-core",
"sqlalchemy[asyncio]>=2.0",
"sqlmodel>=0.0.22",
]

[build-system]
Expand Down
3 changes: 2 additions & 1 deletion framework/db/simple_module_db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from simple_module_db.base import create_module_base
from simple_module_db.deps import get_db
from simple_module_db.listeners import TenantIsolationError, current_tenant_id
from simple_module_db.migrations import build_module_metadata, make_include_object
from simple_module_db.migrations import build_module_metadata, make_include_object, render_item
from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
from simple_module_db.provider import DatabaseProvider, detect_provider
from simple_module_db.session import DatabaseState, init_db
Expand All @@ -23,4 +23,5 @@
"get_db",
"init_db",
"make_include_object",
"render_item",
]
22 changes: 12 additions & 10 deletions framework/db/simple_module_db/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Per-module declarative base with schema isolation."""
"""Per-module SQLModel base with schema isolation."""

from __future__ import annotations

import os

from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
from sqlmodel import SQLModel

from simple_module_db.provider import DatabaseProvider, detect_provider

Expand All @@ -19,16 +19,16 @@
}

# Cache created bases to avoid recreating for the same module
_base_cache: dict[str, type[DeclarativeBase]] = {}
_base_cache: dict[str, type[SQLModel]] = {}

# Track all module bases for Alembic discovery. Module-level *mutable* list
# so callers that imported it before every module registered (e.g. conftest,
# migrations/env.py) still observe new entries. Deduped at append time —
# see ``_register_base`` below.
all_module_bases: list[type[DeclarativeBase]] = []
all_module_bases: list[type[SQLModel]] = []


def _register_base(base: type[DeclarativeBase]) -> None:
def _register_base(base: type[SQLModel]) -> None:
"""Append ``base`` to ``all_module_bases`` iff not already present.

Guards against the list growing under repeated imports (test suites,
Expand All @@ -51,8 +51,8 @@ def _default_provider() -> DatabaseProvider:
def create_module_base(
module_name: str,
provider: DatabaseProvider | None = None,
) -> type[DeclarativeBase]:
"""Create a SQLAlchemy DeclarativeBase with schema isolation for a module.
) -> type[SQLModel]:
"""Create a SQLModel abstract base with schema isolation for a module.

- PostgreSQL: uses a dedicated schema (e.g., ``products``)
- SQLite: single schema; modules are expected to prefix ``__tablename__``
Expand All @@ -62,7 +62,9 @@ def create_module_base(
module models work in both dev (SQLite) and prod (PostgreSQL) without
code changes. Pass ``provider=`` explicitly in tests that need to pin it.

Returns a cached base if already created for this module+provider.
Returns a cached base if already created for this module+provider. The
returned class is a ``SQLModel`` subclass with a per-module ``MetaData``;
concrete table classes declare ``table=True`` and inherit from it.
"""
if provider is None:
provider = _default_provider()
Expand All @@ -81,15 +83,15 @@ def create_module_base(
# Use type() to create the class, avoiding class body scoping issues
ModuleBase = type( # noqa: N806
f"{module_name.title()}Base",
(DeclarativeBase,),
(SQLModel,),
{
"__abstract__": True,
"metadata": mod_metadata,
},
)

# Store module name for reference
ModuleBase.__module_name__ = schema_name # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
ModuleBase.__module_name__ = schema_name # type: ignore[attr-defined]

_base_cache[cache_key] = ModuleBase
_register_base(ModuleBase)
Expand Down
65 changes: 32 additions & 33 deletions framework/db/simple_module_db/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ def register_listeners(db_state: DatabaseState) -> None:

event.listen(db_state.sync_session_class, "before_flush", _before_flush_listener)
event.listen(db_state.sync_session_class, "after_flush", _mark_session_written)
event.listen(db_state.sync_session_class, "do_orm_execute", _soft_delete_filter)
event.listen(db_state.sync_session_class, "do_orm_execute", _add_tenant_filter)
event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements)
db_state._listeners_registered = True
logger.info("Registered SQLAlchemy entity listeners")

Expand Down Expand Up @@ -175,45 +174,45 @@ def _before_flush_listener(
)


def _soft_delete_filter(execute_state: ORMExecuteState) -> None:
"""Automatically exclude soft-deleted rows from SELECT queries.
# Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the
# ``do_orm_execute`` hot path skips redundant ``issubclass`` work on every query.
_mixin_flags_cache: dict[type, tuple[bool, bool]] = {}

Adds ``WHERE is_deleted = FALSE`` for every entity in the query that
inherits from :class:`SoftDeleteMixin`.

To bypass the filter (e.g. admin views), use::
def _filter_select_statements(execute_state: ORMExecuteState) -> None:
"""Attach per-mapper ``with_loader_criteria`` for soft-delete and tenant isolation.

session.execute(stmt.execution_options(include_deleted=True))
"""
if execute_state.is_select and not execute_state.execution_options.get(
"include_deleted", False
):
execute_state.statement = execute_state.statement.options(
with_loader_criteria(
SoftDeleteMixin,
lambda cls: cls.is_deleted.is_(False),
include_aliases=True,
)
)


def _add_tenant_filter(execute_state: ORMExecuteState) -> None:
"""Automatically filter SELECT queries on multi-tenant models by current tenant.
The criteria are attached per concrete mapper because SQLModel mixins
expose Pydantic ``FieldInfo`` (not SQLAlchemy ``InstrumentedAttribute``)
at the mixin-class level, which breaks the lambda form of
``with_loader_criteria`` that was used before the SQLModel migration.

Uses ``with_loader_criteria`` so the filter applies to ``session.execute()``,
``session.get()``, and relationship lazy-loading.
Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``.
"""
if not execute_state.is_select:
return

skip_soft_delete = execute_state.execution_options.get("include_deleted", False)
tenant_id = current_tenant_id.get()
if tenant_id is None:
if skip_soft_delete and tenant_id is None:
return

execute_state.statement = execute_state.statement.options(
with_loader_criteria(
MultiTenantMixin,
lambda cls: cls.tenant_id == tenant_id,
include_aliases=True,
)
)
options = []
for mapper in execute_state.all_mappers:
cls = mapper.class_
flags = _mixin_flags_cache.get(cls)
if flags is None:
flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin))
_mixin_flags_cache[cls] = flags
is_soft_delete, is_multi_tenant = flags
if is_soft_delete and not skip_soft_delete:
options.append(
with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True)
)
if is_multi_tenant and tenant_id is not None:
options.append(
with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True)
)

if options:
execute_state.statement = execute_state.statement.options(*options)
19 changes: 19 additions & 0 deletions framework/db/simple_module_db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,22 @@ def include_object(
return True

return include_object


def render_item(type_, obj, autogen_context):
"""Alembic ``render_item`` callback that collapses SQLModel's ``AutoString``.

Without this, autogenerate emits ``sqlmodel.sql.sqltypes.AutoString(...)``
in generated migrations but does not add the corresponding
``import sqlmodel``, so the migration fails with ``NameError`` on apply.
``AutoString`` is a thin wrapper over ``String``, so collapsing it here
keeps migrations self-contained without losing semantics.

Pass to :func:`alembic.context.configure` as ``render_item=render_item``.
"""
if type_ == "type" and type(obj).__name__ == "AutoString":
length = getattr(obj, "length", None)
if length is not None:
return f"sa.String(length={length})"
return "sa.String()"
return False # let alembic use its default rendering
Loading
Loading