Implement CRUD and typed queries¶
This chapter follows a request from FastAPI validation through a transaction and back into a stable response model.
%% request flow
sequenceDiagram
participant C as Client
participant R as FastAPI route
participant S as TodoService
participant D as Ormdantic
C->>R: POST /projects/{id}/todos
R->>R: validate TodoCreate
R->>S: create_todo(project_id, payload)
S->>D: begin transaction
S->>D: find Project
S->>D: insert Todo
D-->>S: persisted model
S-->>R: Todo
R-->>C: 201 TodoResponse
Keep persistence behavior in a service¶
"""Application use cases for projects and todo items."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Generic, TypeVar
from ormdantic import Ormdantic, QueryExpression, column, selectinload
from .database import db
from .errors import ResourceNotFound
from .models import Project, Todo, TodoStatus, utc_now
from .schemas import ProjectCreate, TodoCreate, TodoUpdate
ModelT = TypeVar("ModelT")
@dataclass(frozen=True)
class Page(Generic[ModelT]):
"""A stable slice of matching domain models."""
items: list[ModelT]
total: int
limit: int
offset: int
class TodoService:
"""Coordinate Todo application persistence operations."""
def __init__(self, database: Ormdantic) -> None:
self.db = database
async def create_project(self, payload: ProjectCreate) -> Project:
"""Create a Project from validated input."""
return await self.db[Project].insert(Project(name=payload.name))
async def list_projects(self, *, limit: int, offset: int) -> Page[Project]:
"""List Projects in stable creation order."""
table = self.db[Project]
result = await table.find_many(
order_by=["created_at", "id"],
limit=limit,
offset=offset,
)
return Page(
items=result.data,
total=await table.count(),
limit=limit,
offset=offset,
)
async def get_project(self, project_id: str) -> Project:
"""Return a Project or raise a stable domain error."""
project = await self.db[Project].find_one(project_id)
if project is None:
raise ResourceNotFound("Project", project_id)
return project
async def create_todo(self, project_id: str, payload: TodoCreate) -> Todo:
"""Create a Todo only when its owning Project exists."""
async with self.db.transaction():
project = await self.get_project(project_id)
todo = Todo(
project=project.id,
title=payload.title,
description=payload.description,
priority=payload.priority,
due_at=payload.due_at,
)
return await self.db[Todo].insert(todo)
async def list_todos(
self,
*,
project_id: str | None = None,
status: TodoStatus | None = None,
priority: int | None = None,
search: str | None = None,
limit: int,
offset: int,
load_project: bool = False,
) -> Page[Todo]:
"""List Todos using composed typed filters."""
clauses: list[QueryExpression] = []
if project_id is not None:
clauses.append(column("project").eq(project_id))
if status is not None:
clauses.append(column("status").eq(status.value))
if priority is not None:
clauses.append(column("priority").eq(priority))
if search is not None and search.strip():
clauses.append(column("title").ilike(f"%{search.strip()}%"))
where: QueryExpression | None = None
for clause in clauses:
where = clause if where is None else where & clause
table = self.db[Todo]
loaders = [selectinload("project")] if load_project else None
result = await table.find_many(
where=where,
order_by=["created_at", "id"],
limit=limit,
offset=offset,
load=loaders,
)
return Page(
items=result.data,
total=await table.count(where),
limit=limit,
offset=offset,
)
async def get_todo(self, todo_id: str, *, load_project: bool = True) -> Todo:
"""Return one Todo with an optional explicit Project load."""
loaders = [selectinload("project")] if load_project else None
todo = await self.db[Todo].find_one(todo_id, load=loaders)
if todo is None:
raise ResourceNotFound("Todo", todo_id)
return todo
async def update_todo(self, todo_id: str, payload: TodoUpdate) -> Todo:
"""Apply supplied PATCH fields and update the modification time."""
current = await self.get_todo(todo_id, load_project=False)
values = current.model_dump()
values.update(payload.model_dump(exclude_unset=True))
values["updated_at"] = utc_now()
updated = Todo.model_validate(values)
return await self.db[Todo].update(updated)
async def delete_todo(self, todo_id: str) -> None:
"""Delete an existing Todo."""
await self.get_todo(todo_id, load_project=False)
await self.db[Todo].delete(todo_id)
service = TodoService(db)
The list method composes only the filters supplied by the caller. column()
creates typed expressions, ilike() performs case-insensitive title matching,
and selectinload("project") loads relationships only where the response needs
them. Stable ordering by creation time and identifier prevents pagination from
shuffling equal rows.
Todo creation checks the parent and inserts the child within one transaction.
Any exception rolls the write back. Update uses exclude_unset=True, so omitted
PATCH fields keep their stored values while nullable description and due date can
be cleared explicitly.
Keep HTTP behavior in routes¶
"""HTTP routes for the Todo reference application."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request, Response, status
from .models import TodoStatus
from .schemas import (
ProjectCreate,
ProjectPage,
ProjectResponse,
TodoCreate,
TodoPage,
TodoResponse,
TodoUpdate,
UUIDString,
)
from .service import TodoService
router = APIRouter()
PageLimit = Annotated[int, Query(ge=1, le=100)]
PageOffset = Annotated[int, Query(ge=0)]
ResourceId = Annotated[UUIDString, Path()]
def get_service(request: Request) -> TodoService:
"""Return the application-scoped service used by an endpoint."""
return request.app.state.todo_service
Service = Annotated[TodoService, Depends(get_service)]
@router.get("/health", tags=["operations"])
async def health(request: Request) -> dict[str, str]:
"""Report readiness without exposing a database URL or credentials."""
return {
"status": "ready",
"database": request.app.state.database_dialect,
}
@router.post(
"/projects",
response_model=ProjectResponse,
status_code=status.HTTP_201_CREATED,
tags=["projects"],
)
async def create_project(
payload: ProjectCreate,
service: Service,
) -> ProjectResponse:
"""Create a project."""
project = await service.create_project(payload)
return ProjectResponse.model_validate(project)
@router.get("/projects", response_model=ProjectPage, tags=["projects"])
async def list_projects(
service: Service,
limit: PageLimit = 50,
offset: PageOffset = 0,
) -> ProjectPage:
"""Return a stable, bounded page of projects."""
page = await service.list_projects(limit=limit, offset=offset)
return ProjectPage(
items=[ProjectResponse.model_validate(project) for project in page.items],
total=page.total,
limit=page.limit,
offset=page.offset,
)
@router.get(
"/projects/{project_id}",
response_model=ProjectResponse,
tags=["projects"],
)
async def get_project(
project_id: ResourceId,
service: Service,
) -> ProjectResponse:
"""Return one project by its canonical UUID."""
project = await service.get_project(project_id)
return ProjectResponse.model_validate(project)
@router.post(
"/projects/{project_id}/todos",
response_model=TodoResponse,
status_code=status.HTTP_201_CREATED,
tags=["todos"],
)
async def create_todo(
project_id: ResourceId,
payload: TodoCreate,
service: Service,
) -> TodoResponse:
"""Create a todo in an existing project."""
todo = await service.create_todo(project_id, payload)
return TodoResponse.from_model(todo)
@router.get("/todos", response_model=TodoPage, tags=["todos"])
async def list_todos(
service: Service,
project_id: Annotated[UUIDString | None, Query()] = None,
todo_status: Annotated[TodoStatus | None, Query(alias="status")] = None,
priority: Annotated[int | None, Query(ge=1, le=5)] = None,
search: Annotated[str | None, Query(max_length=200)] = None,
limit: PageLimit = 50,
offset: PageOffset = 0,
) -> TodoPage:
"""Filter and paginate todo items."""
page = await service.list_todos(
project_id=project_id,
status=todo_status,
priority=priority,
search=search,
limit=limit,
offset=offset,
)
return TodoPage(
items=[TodoResponse.from_model(todo) for todo in page.items],
total=page.total,
limit=page.limit,
offset=page.offset,
)
@router.get("/todos/{todo_id}", response_model=TodoResponse, tags=["todos"])
async def get_todo(todo_id: ResourceId, service: Service) -> TodoResponse:
"""Return one todo with its project relationship loaded."""
return TodoResponse.from_model(await service.get_todo(todo_id))
@router.patch("/todos/{todo_id}", response_model=TodoResponse, tags=["todos"])
async def update_todo(
todo_id: ResourceId,
payload: TodoUpdate,
service: Service,
) -> TodoResponse:
"""Apply a validated partial update."""
return TodoResponse.from_model(await service.update_todo(todo_id, payload))
@router.delete(
"/todos/{todo_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["todos"],
)
async def delete_todo(todo_id: ResourceId, service: Service) -> Response:
"""Delete one todo."""
await service.delete_todo(todo_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
FastAPI enforces UUID formats, enum values, priority bounds, and pagination bounds
before the service runs. Domain errors remain framework-independent and the app
maps them to stable 404, 409, and 503 payloads.
"""FastAPI application factory for the Ormdantic Todo example."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from ormdantic import Ormdantic
from .database import db
from .errors import TodoApplicationError, error_response
from .routes import router
from .service import TodoService
def create_app(database: Ormdantic | None = None) -> FastAPI:
"""Build an application around an injected or configured database target."""
runtime_database = database or db
@asynccontextmanager
async def lifespan(app: FastAPI):
await runtime_database.init()
app.state.todo_service = TodoService(runtime_database)
app.state.database_dialect = runtime_database.runtime_diagnostics()["backend"]
yield
application = FastAPI(
title="Ormdantic Todo API",
version="0.1.0",
lifespan=lifespan,
)
application.include_router(router)
@application.exception_handler(TodoApplicationError)
async def handle_application_error(
_request: Request,
error: TodoApplicationError,
) -> JSONResponse:
status_code, payload = error_response(error)
return JSONResponse(status_code=status_code, content=payload)
return application
app = create_app()
Initialization belongs in the lifespan so a failed database connection prevents
the application from reporting ready. runtime_diagnostics() supplies only a
safe backend label to the health route.
Continue with Migrations. For the full query vocabulary, read Querying and Query expressions.