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 migrations/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. migrations/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 boot-time 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.