Skip to content

Subclassing action classes

An action is the service layer for one table: queries, writes and the rules around them. Routers call actions, and your own code can too. This guide shows how to add queries and rules by subclassing, how the write hooks work, and how to replace the action a module router uses. The basic CRUD calls are on Actions and CRUD.

The base class

msflib.actions.ModelAction is generic over the table model, the create schema and the update schema. It resolves the three types from the generic arguments, so either form works:

from msflib.actions import ModelAction

notes = ModelAction[Note, NoteCreate, NoteUpdate]()


class NoteAction(ModelAction[Note, NoteCreate, NoteUpdate]):
    ...

Instantiating a bare ModelAction() raises TypeError: ModelAction must be instantiated with concrete generic types. when a method first needs the model. There is also msflib.actions.action(Note, NoteCreate, NoteUpdate), which returns a cached ModelAction per type triple. Prefer a named subclass for anything with rules.

Each method takes the Session as its first argument. The main ones:

Method Purpose
get(session, id) One row by primary key
get_by_all(session, **filters), get_by_any(...), get_by_expressions(session, *exprs) First row matching all, any, or SQLAlchemy expressions
get_multi(...), get_multi_by_all(...), get_multi_by_any(...), get_multi_by_expressions(...) Lists with offset and limit (default 100; limit=None for no limit); the expressions form also takes order_by
create(session, *, data, update=None, decorator=None, commit=True) Insert from a create schema
create_multi(session, *, data, update=None, decorator=None, commit=True) Insert several; update may be one dict or one per item
update(session, *, model, data=None, update=None, decorator=None, commit=True) Apply an update schema (only fields the client set), then update overrides
delete(session, id, commit=True), delete_by_all, delete_by_any, delete_by_expressions Delete one row; returns it, or None
delete_multi_by_all, delete_multi_by_any, delete_multi_by_expressions Delete matching rows; returns them

Three arguments are worth knowing before you override anything:

  • update is a dict applied after the schema. It is the supported way to set values the client does not control, such as an owner id (see Writing custom code).
  • decorator is a callable (model) -> None run on the row before it is added to the session. Use it to compute or normalise fields.
  • commit=False leaves the transaction to the caller. Rows are flushed (ids are assigned) but not committed, so several actions can take part in one transaction.

create drops payload keys that are not columns on the model, so a create schema may carry input that you handle yourself.

Add queries and rules

Subclass, add methods, and override the write method when a rule must apply to every write. Keep the full signature, including commit, and pass everything through to super().

from typing import Any

from fastapi import HTTPException
from msflib.actions import ModelAction
from sqlmodel import Session, func, select


class NoteAction(ModelAction[Note, NoteCreate, NoteUpdate]):
    def __init__(self, *, max_per_account: int) -> None:
        self.max_per_account = max_per_account

    def list_for_account(self, session: Session, *, account_id: int, limit: int = 50) -> list[Note]:
        return self.get_multi_by_all(session, account_id=account_id, limit=limit)

    def create(
        self,
        session: Session,
        *,
        data: NoteCreate,
        update: dict[str, Any] | None = None,
        decorator=None,
        commit: bool = True,
    ) -> Note:
        account_id = (update or {}).get("account_id")
        count = session.exec(select(func.count()).where(Note.account_id == account_id)).one()
        if count >= self.max_per_account:
            raise HTTPException(status_code=409, detail="Note limit reached")
        return super().create(
            session, data=data, update=update, decorator=decorator, commit=commit
        )

Raising HTTPException in an action works because FastAPI converts it when the action runs inside a request. For code that also runs from scripts or workers, raise your own exception type and translate it in the router.

Keep commit in overrides

ModelAction.create_random (used by tests and seeders) checks whether your create and create_multi accept commit. An override without it still works but emits a DeprecationWarning, and support for such overrides is slated for removal.

To use a decorator of your own while the subclass also needs one, wrap it. For example, fill a slug and then call the caller's decorator:

def create(self, session, *, data, update=None, decorator=None, commit=True):
    def fill_slug(article):
        if not article.slug:
            article.slug = article.title.lower().replace(" ", "-")
        if decorator:
            decorator(article)

    return super().create(
        session, data=data, update=update, decorator=fill_slug, commit=commit
    )

Extending a module action

Module actions are subclasses of ModelAction and are generic in the same way. Subclass them with your concrete types and add behaviour. This account action normalises the username before the module's own create logic (password hashing, email lower-casing, profile creation) runs:

from typing import Any

from msflib.account.actions import AccountAction
from sqlmodel import Session


class MyAccountAction(AccountAction[Account, AccountCreate, AccountUpdate]):
    def create(
        self,
        session: Session,
        *,
        data: AccountCreate,
        update: dict[str, Any] | None = None,
        commit: bool = True,
    ) -> Account:
        if data.username:
            data = data.model_copy(update={"username": data.username.strip().lower()})
        return super().create(session, data=data, update=update, commit=commit)


aa = MyAccountAction(settings=settings, profile_action=pa)

Pass the instance to the router factory to make the module's endpoints use it:

account_router(
    ...,
    account_type=Account,
    profile_type=Profile,
    account_action=aa,
    profile_action=pa,
)

POST /account/open then creates " QBert " as qbert. Note that AccountAction.create has no decorator argument; match the signature of the method you override.

Routers accept actions under these names: account_action and profile_action for the account and profile routers, and workspace_action and user_action for the workspace router. When you pass none, the router builds a default one from the model types you gave it.

Workspace membership rules

UserAction.create_membership decides which role a new member gets. There are three supported ways to change it, in increasing order of precedence: override resolve_membership_type in a subclass, pass membership_type_resolver= to the constructor, or pass membership_type= on the call.

from msflib.workspaces.actions import UserAction
from msflib.workspaces.models import UserType


class MyUserAction(UserAction[WorkspaceMember, UserCreate, UserUpdate]):
    def resolve_membership_type(self, *, account_id: int, workspace_id: int, owner_id: int):
        return UserType.owner if account_id == owner_id else UserType.guest


# or, without subclassing
ua = UserAction[WorkspaceMember, UserCreate, UserUpdate](
    membership_type_resolver=lambda account_id, workspace_id, owner_id: UserType.admin
)

Lifecycle events and hooks

Every write that goes through a ModelAction emits events on the event bus. For a model class named Article, an update emits:

Event When Listener failure
article-update-pre-commit and model-update-pre-commit After flush, before commit, in the same transaction Raises EventBusListenerExecutionError out of the action call
article-update-post-commit and model-update-post-commit After the outermost commit Logged, not raised

The same pairs exist for create and delete. The model-specific event name is the class name lower-cased. The model- events are generic and fire for every model.

A listener takes the row and an options dict. In pre-commit events options["session"] is the active session, so writes the listener makes commit or roll back with the row itself. Pre-commit listeners must be synchronous. Post-commit listeners receive a plain snapshot of the row's attributes rather than the live ORM object, because the object is expired after commit.

from fastapi import FastAPI
from msflib.eventbus import bind_app_emitter

app = FastAPI()
app_emitter = bind_app_emitter(app)


@app_emitter.on("article-create-pre-commit")
def on_pre(article, options):
    session = options["session"]
    ...  # extra writes in the same transaction


@app_emitter.on("article-create-post-commit")
def on_post(article, options):
    ...  # side effects once the row is durable

Events reach the app's emitter in request handlers, in BackgroundTasks, and in call_later or create_task work started from a request. Outside a request, such as in a script or test, wrap the work in with use_app_emitter(app): (from msflib.eventbus), or the global emitter is used and your listeners are not called. The same applies to threads: a threading.Thread does not inherit the app emitter, so events fired there go to the global emitter. A Celery worker has no FastAPI app; register its listeners on the worker's emitter, AppEmitter(get_emitter()).

A pre-commit listener that raises aborts the write. The row has been flushed but not committed, so roll the session back (a request that fails normally does this when the session closes).

on_model_write: one callback for all writes

When you want the same code on create, update and delete of a model, msflib.actions.on_model_write is shorter than three listeners. The callback runs in a savepoint, so a failure only undoes its own writes.

from msflib.actions import on_model_write


# AuditEntry is your own table
def audit(session, row, operation):
    session.add(AuditEntry(table="article", row_id=row.id, operation=operation))


on_model_write(Article, audit, emitter=app_emitter, key="myapp.audit")

emitter here is the app emitter from the previous example, not the global proxy. key makes the registration idempotent per emitter. With the default on_error="log", a failing callback is logged and the write goes ahead; on_error="raise" aborts the write with EventBusListenerExecutionError. operations=["update"] limits it to some operations, and reset_model_write_hooks(emitter=app_emitter, key="myapp.audit") removes it.

Only writes through ModelAction emit these events. Direct session.add(...), raw SQL and session.exec(update(...)) do not.

Events your own code should emit after commit

If your action also emits events of its own, emit them with msflib.actions.emit_after_commit(session, "event-name", instance, payload). It queues the event until the outermost transaction commits, which matters when a caller passed commit=False. A rollback discards it. The lower-level run_after_commit(session, callback) does the same for any callback.

Router events are separate

Module routers also emit their own domain events around the action calls, for example account-created and account-updated from the account router (msflib.account.eventbus.EventName) and account-logged-in from the auth router. These fire only for requests through those routers, not for every AccountAction call. Use them for behaviour tied to an HTTP flow and the lifecycle events for behaviour tied to data.

Testing an action

Actions need nothing but a session and, for module actions, settings. Use an in-memory SQLite engine:

from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine

engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
    note = NoteAction(max_per_account=1).create(
        session, data=NoteCreate(title="a"), update={"account_id": 1}
    )

(The account table must exist for the foreign key to be valid on databases that enforce it; SQLite does not by default.)

action.create_random(session, **overrides) builds a create schema from its required fields (values in overrides replace the generated ones) and saves it, which is convenient for fixtures.

Known issue (#276)

create_random only fills what the create schema holds, so it cannot satisfy a required column that is set through update, such as account_id on the notes table.