Skip to content

Per-module Base

Every module that declares SQLModel tables calls create_module_base("<name>") once, and inherits from the returned class:

python
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 at orders.<table>. Each module effectively owns a namespace; one DROP SCHEMA orders CASCADE can 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_base does not enforce the prefix — it's a convention.

You can override detection explicitly for tests or special cases:

python
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:

python
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:

python
# 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"] in InvoicesModule.meta — modules are loaded in topological order; without depends_on, orders.models might not be imported when invoices.models runs.
  • Autogenerate handles cross-schema FKs on Postgres natively.
  • Uninstalling Orders while Invoices still 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:

python
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.

Released under the MIT License.