Actions and CRUD¶
An action is the service layer for one model: the object that reads and writes its rows. ModelAction gives you create, read, update and delete without writing queries, plus filtered and paged reads and lifecycle events. Routers call actions; so do seed scripts, background jobs and tests. This page continues from Models, data and tables.
Create an action¶
ModelAction is generic over three types: the table, the create schema and the update schema. Subclass it with concrete types, which gives you a place for your own queries:
# app/actions.py
from msflib.actions import ModelAction
from sqlmodel import Session
from .models import Task, TaskCreate, TaskUpdate
class TaskAction(ModelAction[Task, TaskCreate, TaskUpdate]):
def list_open(self, session: Session, *, owner: str) -> list[Task]:
return self.get_multi_by_expressions(
session,
Task.owner == owner,
Task.done == False, # noqa: E712
order_by=Task.id,
)
task_action = TaskAction()
Create one instance and share it; actions hold no per-request state. If you have nothing to add, skip the subclass: ModelAction[Task, TaskCreate, TaskUpdate]() works, and so does msflib.actions.action(Task, TaskCreate, TaskUpdate), which returns a cached instance per type triple. Instantiating a bare ModelAction() without type arguments raises TypeError: ModelAction must be instantiated with concrete generic types. the first time it needs the model.
Every method takes a SQLModel Session as its first argument. The action never opens or closes sessions; you do, or a FastAPI dependency does (see the next page).
Create, read, update, delete¶
This script runs against an in-memory SQLite database:
from msflib.db.sqlite import enable_savepoints
from sqlmodel import Session, SQLModel, create_engine
from app import models # noqa: F401
from app.actions import task_action
from app.models import TaskCreate, TaskUpdate
engine = enable_savepoints(create_engine("sqlite://"))
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
task = task_action.create(
session,
data=TaskCreate(title="write docs", priority="high"),
update={"owner": "ann"},
)
print(task.id, task.title, task.owner, task.done)
# 1 write docs ann False
mine = task_action.get_by_all(session, id=task.id, owner="ann")
theirs = task_action.get_by_all(session, id=task.id, owner="bob")
print(mine is task, theirs)
# True None
task = task_action.update(session, model=task, data=TaskUpdate(done=True))
print(task.done)
# True
deleted = task_action.delete(session, task.id)
print(deleted.title, task_action.get(session, task.id))
# write docs None
Points to notice:
createreturns the committed row, refreshed, soidandcreated_atare filled in.updatetakes the row you loaded, not an id. It applies only the fields the update schema has set, soTaskUpdate(done=True)leaves the title alone.deletetakes an id and returns the deleted row, orNoneif there was no such row.- Reads return
Nonefor no match. Turning that into a 404 is the router's job.
Methods¶
| Group | Methods |
|---|---|
| One row | get(session, id), get_by_all(session, **filters), get_by_any(session, **filters), get_by_expressions(session, *exprs) |
| Many rows | get_multi, get_multi_by_all, get_multi_by_any, get_multi_by_expressions, each with offset=0 and limit=100 |
| Create | create(session, data=, update=, decorator=, commit=), create_multi(session, data=[...], ...) |
| Update | update(session, model=, data=, update=, decorator=, commit=) |
| Delete | delete(session, id), delete_by_all, delete_by_any, delete_by_expressions, delete_multi_by_all, delete_multi_by_any, delete_multi_by_expressions |
| Test data | random(**overrides), random_multi(count=, **overrides), create_random(session, count=, **overrides) |
*_by_all filters are keyword arguments that must all match (owner="ann", done=False); *_by_any matches if any does. *_by_expressions takes SQLAlchemy expressions such as Task.id > 10. The get_multi* methods return at most 100 rows unless you pass limit=; pass limit=None for no limit. Pass order_by= to get_multi_by_expressions when you page, otherwise the row order between calls is not defined.
Known issue (#276)
delete_by_expressions is annotated -> list[ModelType] but returns a single row or None. Type checkers will accept list operations on the result; do not rely on the annotation.
Scoping: values the client does not supply¶
The update={...} argument on create and update merges extra values over what the schema produced. Use it for everything that comes from the request context rather than the request body: the owner, the workspace, the tenant. That is why TaskCreate has no owner:
task_action.create(session, data=payload, update={"owner": current_owner})
On create, keys in update that are not model fields are dropped, and so are schema fields the model does not have. For reads, scope with the filters. Scope every read and write by the owner (or workspace, or tenant) the same way, so a row id alone never selects data:
mine = task_action.get_by_all(session, id=task_id, owner=current_owner)
decorator= takes a function called with the model before it is saved, for last-minute changes that are not plain field values. In real apps the identity values come from msflib-auth dependencies, and the scope dimensions (tenant, workspace, account) from get_scope_dependencies.
Transactions¶
By default each call commits. Pass commit=False to flush instead and leave the commit to you, so several writes succeed or fail together:
with Session(engine) as session:
task_action.create(session, data=TaskCreate(title="a"), update={"owner": "ann"}, commit=False)
task_action.create(session, data=TaskCreate(title="b"), update={"owner": "ann"}, commit=False)
session.commit()
A rollback discards both. On SQLite this needs the engine wrapped with enable_savepoints, as in Basics. Rows created with commit=False already have their id after the flush.
Lifecycle events¶
Every write also emits events, so other code can react without the router knowing about it. For Task the names are task-create-pre-commit, task-update-pre-commit, task-delete-pre-commit and their -post-commit counterparts, and generic model-... versions of each. Pre-commit listeners run after the flush, inside the transaction, and see the live row and the session; an exception in one aborts the write. Post-commit listeners run once the outermost commit succeeds, receive a plain snapshot of the row, and cannot affect the write.
@app_emitter.on("task-create-post-commit")
def announce(task, options):
print("created task", task.id)
Listeners are registered on the app emitter you bound in main.py (app_emitter = bind_app_emitter(app)). The app emitter is active during requests, including BackgroundTasks and tasks started from the request. Only writes made through a ModelAction emit events; session.add(...) does not. See Event bus and, for running code inside every write, on_model_write on the core page.
Test data¶
task_action.random() builds a create schema with random values for the required fields, and random(title="fixed") pins some. Because owner is context rather than input, build the schema and create it yourself so you can pass update:
data = task_action.random(title="fixed")
task = task_action.create(session, data=data, update={"owner": "ann"})
create_random(session, count=2) creates rows in one call.
Known issue (#276)
create_random has no way to pass update, so it only suits models whose columns are all in the create schema. Calling it for Task fails with an IntegrityError on the missing owner. Build the schema with random() and call create yourself, as above.
Troubleshooting¶
TypeError: ModelAction must be instantiated with concrete generic types. Subclass ModelAction[Model, Create, Update] or index it when you instantiate.
IntegrityError: NOT NULL constraint failed: task.owner. A column that is not in the create schema was not supplied through update={...}.
A listener never runs. The write bypassed ModelAction, or the listener is on a different emitter. In scripts, threads and lifespan code, wrap the code in with use_app_emitter(app): (from msflib.eventbus); a worker process has no app, so register its listeners on AppEmitter(get_emitter()). Outside a request, events go to the default emitter, which the app emitter's listeners never see.
Next: Endpoints and routers.