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:
updateis 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).decoratoris a callable(model) -> Nonerun on the row before it is added to the session. Use it to compute or normalise fields.commit=Falseleaves 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.