Per-module Base
Every module that declares SQLModel tables calls create_module_base("<name>") once, and inherits from the returned class:
from simple_module_db.base import create_module_base
Base = create_module_base("orders")
class Order(Base, table=True):
__tablename__ = "orders_order"
id: int | None = Field(default=None, primary_key=True)
...create_module_base returns a SQLModel base that's bound to a private MetaData object. This isolation is what lets modules live side-by-side without their tables trampling each other's Alembic autogenerate output.
Provider auto-detection
The function inspects SM_DATABASE_URL at import time and picks the right strategy:
- PostgreSQL → the Base sets
__table_args__ = {"schema": "<name>"}. Tables live atorders.<table>. Each module effectively owns a namespace; oneDROP SCHEMA orders CASCADEcan cleanly uninstall a module. - SQLite → no schema (SQLite has one). The
__tablename__must still be prefixed with the module name to avoid collisions.create_module_basedoes not enforce the prefix — it's a convention.
You can override detection explicitly for tests or special cases:
from simple_module_db.base import create_module_base
from simple_module_db.types import DatabaseProvider
Base = create_module_base("orders", provider=DatabaseProvider.SQLITE)Production code should let auto-detection do its thing.
The build_module_metadata() function
Alembic's autogenerate needs a single MetaData object describing every table it should manage. Each module has its own — so host/alembic/env.py calls:
from simple_module_db.base import build_module_metadata
target_metadata = build_module_metadata()This function iterates every discovered module, imports its models submodule (if one exists), and unions all the per-module MetaDatas into one. Autogenerate then diffs the DB against that union.
If a module has no models.py, it contributes nothing — fine. If a module has a models.py that doesn't import, the union fails — fix the import.
make_include_object()
Alembic's include_object callback filters which tables autogenerate considers. host/alembic/env.py uses make_include_object() from simple_module_db to:
- Include tables from any discovered module's MetaData.
- Exclude tables owned by the Alembic runtime itself (
alembic_version). - Exclude host-owned tables that shouldn't be in a module migration (there are currently none, but the hook is there).
If you write a one-off host-level table that autogenerate shouldn't track, extend make_include_object() — don't reach into a module's models.py.
Naming rules
- Module name must match
ModuleMeta.name.lower(). The framework caches the Base by module name; a mismatch causes silent schema drift. - Module names should be identifiers:
[a-z][a-z0-9_]*. Hyphens break SQL identifier parsing on some providers. - Don't rename a module after it ships without migrating data. Both the schema name (Postgres) and the table prefix (SQLite) are durable.
Tables across modules
If your module needs to reference another module's table by foreign key, import its model:
# modules/invoices/invoices/models.py
from orders.models import Order
class Invoice(Base, table=True):
__tablename__ = "invoices_invoice"
id: int | None = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="orders.order.id")Caveats:
- Add
depends_on=["Orders"]inInvoicesModule.meta— modules are loaded in topological order; withoutdepends_on,orders.modelsmight not be imported wheninvoices.modelsruns. - Autogenerate handles cross-schema FKs on Postgres natively.
- Uninstalling
OrderswhileInvoicesstill references it produces a DB error — cross-module FKs are a commitment.
If you can avoid a hard FK (store order_id: int without the constraint), module lifecycles stay more independent. Prefer application-level validation for loose coupling.
Inspecting at runtime
For debugging, you can dump the registered tables:
from simple_module_db.base import build_module_metadata
meta = build_module_metadata()
for t in meta.sorted_tables:
print(t.schema or "(no schema)", t.name)This is also what the make doctor SM011 check uses — it compares this set against the Alembic history to detect tables that exist in code but not in any migration.