Custom queries¶
The base class gives you CRUD and bulk writes. Real repositories grow
past both: a free-text search, an upsert, a query that joins three tables to
answer one question.
repositron's job is to remove the boilerplate, not to box you in, so everything
on your repository is an ordinary class with self.session and self.model_class
to build on.
Setup
The examples below all share one task-tracker domain: a Task model, its
DTOs and payloads, a TaskRepository, and two extra models (Member,
Subtask) that some examples join against or write to.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import ForeignKey
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from repositron import Repository, UNSET, UnsetType
class Base(DeclarativeBase): ...
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
workspace_id: Mapped[int]
title: Mapped[str]
description: Mapped[str | None] = mapped_column(default=None)
status: Mapped[str] = mapped_column(default="open")
assignee_id: Mapped[int | None] = mapped_column(default=None)
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
archived_at: Mapped[datetime | None] = mapped_column(default=None)
class Member(Base):
__tablename__ = "members"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
class Subtask(Base):
__tablename__ = "subtasks"
id: Mapped[int] = mapped_column(primary_key=True)
task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"))
title: Mapped[str]
@dataclass
class TaskDTO:
id: int
title: str
status: str
assignee_id: int | None
@dataclass
class TaskCreate:
workspace_id: int
title: str
description: str | None | UnsetType = UNSET
assignee_id: int | None | UnsetType = UNSET
@dataclass
class TaskUpdate:
title: str | UnsetType = UNSET
description: str | None | UnsetType = UNSET
status: str | UnsetType = UNSET
assignee_id: int | None | UnsetType = UNSET
class TaskRepository(
Repository[Task, TaskDTO, TaskCreate, TaskUpdate, int, AsyncSession]
): ...
Domain queries¶
A method that does not fit get / list is just a method. You have the session,
the model, and the full SQLAlchemy API:
The examples use AsyncSession. Await SQLAlchemy I/O and the inherited
repository methods; the synchronous counterpart uses the same logic with its
explicit _sync calls.
from sqlalchemy import func, select
class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate, int, AsyncSession]):
async def open_in_workspace(self, workspace_id: int) -> list[TaskDTO]:
return await self.list(status="open", workspace_id=workspace_id)
async def status_counts(self, workspace_id: int) -> dict[str, int]:
rows = (await self.session.execute(
select(Task.status, func.count())
.where(Task.workspace_id == workspace_id)
.group_by(Task.status)
)).all()
return dict(rows)
Note the first method reuses await self.list instead of reaching for the session.
Build on the inherited methods where they fit; drop to raw SQLAlchemy only where
they do not. A plain bulk update or delete is one of the cases that does fit,
update_where / delete_where cover it
without a custom method.
Filter builders¶
When the same WHERE fragment shows up in several calls, a free-text search being
the classic case, give it a name. A method that returns a SQLAlchemy expression
plugs straight into extra_filters:
from sqlalchemy import ColumnElement, or_
class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate, int, AsyncSession]):
def search(self, q: str) -> ColumnElement[bool]:
pattern = f"%{q}%"
return or_(
Task.title.ilike(pattern),
Task.description.ilike(pattern),
)
await repo.list(extra_filters=[repo.search("deploy")], status="open")
Callers express intent ("search for deploy") and the column logic lives in one place.
Custom hydration¶
The automatic model-to-DTO conversion handles the common cases: a dataclass built
by field name, a Pydantic model through model_validate, or the model returned
as-is.
For the rest, the question is add or replace. In an async application, a hook is the right way to add a value that needs database I/O:
- To add a derived field to the built DTO, use a
hydratehook. It hands you the finished DTO to enrich, so you write one field, not all of them. - To replace the build, when the automatic path cannot produce the DTO at
all, use a
buildhook or override_hydratewith a construction that does not issue I/O.
from dataclasses import dataclass
from sqlalchemy import select
from repositron import ReadOnlyRepository, on
@dataclass
class TaskDetail:
id: int
title: str
status: str
assignee_name: str | None # rolled up from Member, not a column on Task
class TaskRepository(ReadOnlyRepository[Task, TaskDetail, int, AsyncSession]):
@on("hydrate", mode="after")
async def add_assignee_name(self, model: Task, dto: TaskDetail) -> TaskDetail:
dto.assignee_name = await self.session.scalar(
select(Member.name).where(Member.id == model.assignee_id)
)
return dto
The hook runs for every read, so get, first, and list all return a
fully-formed TaskDetail. Do not use this exact pattern for a long list: it
would make one extra query per row. Write a custom query that preloads or
aggregates the related data instead. (Column projection via repo[Shape] builds
the narrow shape positionally and does not run hydrate hooks.)
Overriding _hydrate and tagging a method with
@on("hydrate", mode="build") are the same
mechanism, the override is just the build hook spelled as a method. Use either
when construction needs no database work; use an async after-hook when it does.
Hydrating from your own method
Built-in reads hydrate through _hydrate_one_sync() or _hydrate_one_async().
Both apply the build hook and the hydrate after-hooks.
When a custom method needs to hydrate a row itself, call
await self._hydrate_one_async(model) in normal repository code, not
self._hydrate(model). _hydrate is the default build only. Calling it
directly skips a separately registered build hook and all after-hooks.
The synchronous counterpart is self._hydrate_one_sync(model).
Transactions on custom writes¶
A custom write is responsible for the same flush / commit / rollback dance
the built-in create / update / delete handle for you. @writes gives a
custom method that dance, so its body is only the session work. Reach for it when
the write is past what bulk writes cover, an upsert here:
from sqlalchemy.dialects.postgresql import insert
from repositron import Repository, writes
class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate, int, AsyncSession]):
@writes
async def upsert_by_title(self, workspace_id: int, title: str, status: str) -> None:
stmt = insert(Task).values(workspace_id=workspace_id, title=title, status=status)
await self.session.execute(
stmt.on_conflict_do_update(index_elements=["title"], set_={"status": status})
) # flushed for you; rolled back on error
The decorated method flushes after the body, commits if the repository is
autocommit=True, and rolls back on error, exactly like the built-in writes (see
committing). To let a caller commit a single write,
declare a commit parameter and @writes honors it:
@writes
async def upsert_by_title(
self, workspace_id: int, title: str, status: str, *, commit: bool | None = None
) -> None:
stmt = insert(Task).values(workspace_id=workspace_id, title=title, status=status)
await self.session.execute(
stmt.on_conflict_do_update(index_elements=["title"], set_={"status": status})
)
await repo.upsert_by_title(1, "Ship docs", "open", commit=True)
When the method needs the primary key mid-way, to attach child rows or return it,
flush yourself at that point. @writes still owns the final flush and the
commit/rollback:
@writes
async def create_with_subtasks(self, payload: TaskCreate, subtasks: list[str]) -> int:
task = Task(workspace_id=payload.workspace_id, title=payload.title)
self.session.add(task)
await self.session.flush() # need task.id for the subtasks below
for title in subtasks:
self.session.add(Subtask(task_id=task.id, title=title))
return task.id
To return a DTO from a custom write instead of an id, hydrate the saved row with
await self._hydrate_one_async, so a custom build or hydrate after-hooks
apply:
@writes
async def create_with_subtasks(self, payload: TaskCreate, subtasks: list[str]) -> TaskDTO:
task = Task(workspace_id=payload.workspace_id, title=payload.title)
self.session.add(task)
await self.session.flush()
for title in subtasks:
self.session.add(Subtask(task_id=task.id, title=title))
return await self._hydrate_one_async(task)
Synchronous counterpart
Bind the repository to Session, write a normal function, call
self.session.flush(), and return self._hydrate_one_sync(task). The
@writes decorator applies the same policy in that execution mode.
Without @writes, a custom write should still flush, never commit, the same
as the base class, so it composes inside the caller's transaction. See the
design principles.