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
5 changes: 2 additions & 3 deletions docs/database/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ When you scaffold a module with `smpy create-module`, the *first* autogenerate r

revision = "..."
down_revision = "..."
branch_labels = ("orders",) # ← add this
branch_labels = ("orders",) # ← add this
depends_on = None
```

Expand Down Expand Up @@ -133,8 +133,7 @@ For long-running backfills on big tables, break into batches and run outside Ale

```python
# scripts/backfill_orders_total.py
async def backfill(batch_size=1000):
...
async def backfill(batch_size=1000): ...
```

## Migration drift in monorepos
Expand Down
23 changes: 15 additions & 8 deletions docs/database/mixins.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ Standard mixins in `simple_module_db.mixins`. Compose as many as you need alongs
```python
from simple_module_db.base import create_module_base
from simple_module_db.mixins import (
AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin,
AuditMixin,
SoftDeleteMixin,
MultiTenantMixin,
VersionedMixin,
)

Base = create_module_base("orders")


class Order(
Base, AuditMixin, SoftDeleteMixin, MultiTenantMixin, VersionedMixin,
Base,
AuditMixin,
SoftDeleteMixin,
MultiTenantMixin,
VersionedMixin,
table=True,
):
__tablename__ = "orders_order"
Expand Down Expand Up @@ -65,9 +73,8 @@ If you genuinely need to remove the row, call the underlying SQLAlchemy DELETE

```python
from sqlmodel import delete
await session.exec(
delete(Order).where(Order.id == order_id)
)

await session.exec(delete(Order).where(Order.id == order_id))
```

Use sparingly — audit trails and downstream systems may depend on historical rows.
Expand Down Expand Up @@ -118,9 +125,9 @@ Order matters only for MRO of columns that share names; the mixins here don't ov
```python
class Order(
Base,
AuditMixin, # created/updated timestamps + author
SoftDeleteMixin, # is_deleted flag + auto-filter
MultiTenantMixin, # tenant_id + auto-filter
AuditMixin, # created/updated timestamps + author
SoftDeleteMixin, # is_deleted flag + auto-filter
MultiTenantMixin, # tenant_id + auto-filter
table=True,
):
__tablename__ = "orders_order"
Expand Down
6 changes: 6 additions & 0 deletions docs/database/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ from sqlmodel import Field

Base = create_module_base("orders")


class Order(Base, AuditMixin, table=True):
__tablename__ = "orders_order"

Expand Down Expand Up @@ -43,6 +44,7 @@ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
```python
from sqlmodel import Field, Relationship


class OrderLine(Base, table=True):
__tablename__ = "orders_order_line"

Expand All @@ -66,13 +68,16 @@ from datetime import datetime
from pydantic import ConfigDict
from sqlmodel import Field, SQLModel


class OrderCreate(SQLModel):
customer_email: str = Field(min_length=1, max_length=200)
total: Decimal = Field(ge=0)


class OrderUpdate(SQLModel):
status: str | None = Field(default=None, max_length=20)


class OrderOut(SQLModel):
model_config = ConfigDict(from_attributes=True)

Expand Down Expand Up @@ -130,6 +135,7 @@ class Order(Base, table=True):
id: int | None = Field(default=None, primary_key=True)
lines: list["OrderLine"] = Relationship(back_populates="order")


class OrderLine(Base, table=True):
id: int | None = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="orders_order.id")
Expand Down
2 changes: 2 additions & 0 deletions docs/database/per-module-base.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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)
Expand Down Expand Up @@ -59,6 +60,7 @@ If your module needs to reference another module's table by foreign key, import
# 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)
Expand Down
6 changes: 5 additions & 1 deletion docs/database/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ from simple_module_db.deps import get_db

SessionDep = Annotated[AsyncSession, Depends(get_db)]


@router.post("")
async def create_order(data: OrderCreate, session: SessionDep): ...
```
Expand All @@ -40,9 +41,11 @@ Most modules wrap `get_db` in their own `deps.py` so the service is injected rat
# modules/orders/orders/deps.py
from orders.service import OrderService


def _order_service(session: SessionDep) -> OrderService:
return OrderService(session)


OrderServiceDep = Annotated[OrderService, Depends(_order_service)]
```

Expand Down Expand Up @@ -78,7 +81,7 @@ Instead, **flush** when you need a DB-assigned value (e.g. an auto-generated `id
async def create(self, data: OrderCreate) -> OrderOut:
order = Order(**data.model_dump())
self.session.add(order)
await self.session.flush() # populates order.id, stays inside the transaction
await self.session.flush() # populates order.id, stays inside the transaction
# further logic can see order.id but won't persist until the dependency commits
return OrderOut.model_validate(order)
```
Expand All @@ -92,6 +95,7 @@ If you need finer control — e.g. a background worker that processes many items
```python
from simple_module_db import DatabaseState


async def worker(db: DatabaseState):
async with db.session_factory() as session:
async with session.begin():
Expand Down
27 changes: 19 additions & 8 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ Every `ModuleBase` subclass **must** declare a `meta` class attribute:
```python
class OrdersModule(ModuleBase):
meta = ModuleMeta(
name="Orders", # PascalCase, unique
route_prefix="/api/orders", # mounted on the API router
view_prefix="/orders", # mounted on the view router
depends_on=["Products"], # strict load order
name="Orders", # PascalCase, unique
route_prefix="/api/orders", # mounted on the API router
view_prefix="/orders", # mounted on the view router
depends_on=["Products"], # strict load order
version="1.0.0",
)
```
Expand Down Expand Up @@ -144,20 +144,24 @@ 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)
```
Expand Down Expand Up @@ -228,9 +232,15 @@ Use `InertiaDep` from `simple_module_hosting.inertia_deps` — it attaches the s
Modules declare permissions in `register_permissions(registry)` grouped by a name prefix:

```python
registry.add_group("Orders", [
"orders.view", "orders.create", "orders.edit", "orders.delete",
])
registry.add_group(
"Orders",
[
"orders.view",
"orders.create",
"orders.edit",
"orders.delete",
],
)
```

Enforce with the `RequiresPermission` dependency:
Expand Down Expand Up @@ -349,6 +359,7 @@ Modules ship translations as JSON under `<package>/locales/<lang>.json` and decl
import importlib.resources
from pathlib import Path


class OrdersModule(ModuleBase):
def locale_dirs(self) -> dict[str, Path]:
return {"orders": Path(str(importlib.resources.files(__package__) / "locales"))}
Expand All @@ -373,7 +384,7 @@ t('orders.greeting', { name: user.name }) // frontend
```

```python
t.t("orders.greeting", name=user.name) # backend
t.t("orders.greeting", name=user.name) # backend
```

Missing placeholders are left verbatim (`"Hello, {name}"`) rather than raising.
Expand Down
10 changes: 5 additions & 5 deletions docs/framework/discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ Failures:
from simple_module_core.module import ModuleMeta

meta = ModuleMeta(
name="Orders", # PascalCase, globally unique
route_prefix="/api/orders", # where the API router mounts
view_prefix="/orders", # where the view router mounts
depends_on=["Products"], # hard ordering requirements
version="1.0.0", # semver for the module
name="Orders", # PascalCase, globally unique
route_prefix="/api/orders", # where the API router mounts
view_prefix="/orders", # where the view router mounts
depends_on=["Products"], # hard ordering requirements
version="1.0.0", # semver for the module
)
```

Expand Down
31 changes: 20 additions & 11 deletions docs/framework/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ from dataclasses import dataclass
from decimal import Decimal
from simple_module_core.events import Event


@dataclass
class OrderPlaced(Event):
order_id: int
customer_email: str
total: Decimal


@dataclass
class OrderCancelled(Event):
order_id: int
Expand Down Expand Up @@ -67,12 +69,14 @@ class OrderService:
async def place(self, data: OrderCreate) -> Order:
order = Order(**data.model_dump())
self.session.add(order)
await self.session.flush() # need the id
await self.bus.publish(OrderPlaced(
order_id=order.id,
customer_email=order.customer_email,
total=order.total,
))
await self.session.flush() # need the id
await self.bus.publish(
OrderPlaced(
order_id=order.id,
customer_email=order.customer_email,
total=order.total,
)
)
return order
```

Expand All @@ -83,9 +87,11 @@ Get the bus as a FastAPI dependency:
from fastapi import Depends, Request
from simple_module_core.events import EventBus


def _event_bus(request: Request) -> EventBus:
return request.app.state.sm.event_bus


EventBusDep = Annotated[EventBus, Depends(_event_bus)]
```

Expand All @@ -97,14 +103,17 @@ The bus (backed by `pyee`'s `AsyncIOEventEmitter`) keys handlers by the **exact*
@dataclass
class OrderEvent(Event): ...


@dataclass
class OrderPlaced(OrderEvent): ...


@dataclass
class OrderCancelled(OrderEvent): ...

bus.subscribe(OrderEvent, audit_handler) # receives only OrderEvent, NOT subclasses
bus.subscribe(OrderPlaced, specific_handler) # receives only OrderPlaced

bus.subscribe(OrderEvent, audit_handler) # receives only OrderEvent, NOT subclasses
bus.subscribe(OrderPlaced, specific_handler) # receives only OrderPlaced
```

If you want a handler to see several event types, subscribe it to each one explicitly.
Expand All @@ -122,8 +131,10 @@ If you need durable delivery across processes, handlers should enqueue a Celery
def register_event_handlers(self, bus: EventBus) -> None:
bus.subscribe(OrderPlaced, self._enqueue_invoice)


async def _enqueue_invoice(self, event: OrderPlaced) -> None:
from invoices.tasks import create_invoice_task

create_invoice_task.delay(event.order_id)
```

Expand All @@ -135,9 +146,7 @@ Subscribe a spy in a test fixture:
@pytest.mark.asyncio
async def test_place_order_publishes_event(db_session, app):
received: list[OrderPlaced] = []
app.state.sm.event_bus.subscribe(
OrderPlaced, lambda e: received.append(e)
)
app.state.sm.event_bus.subscribe(OrderPlaced, lambda e: received.append(e))

service = OrderService(db_session, app.state.sm.event_bus)
await service.place(OrderCreate(customer_email="a@b.c", total=Decimal("1")))
Expand Down
5 changes: 2 additions & 3 deletions docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,11 @@ Declare them from `ModuleBase.locale_dirs()`:
import importlib.resources
from pathlib import Path


class OrdersModule(ModuleBase):
def locale_dirs(self) -> dict[str, Path]:
return {
"orders": Path(
str(importlib.resources.files(__package__) / "locales")
),
"orders": Path(str(importlib.resources.files(__package__) / "locales")),
}
```

Expand Down
Loading
Loading