Configuration¶
A repository is configured in three places: the type parameters it inherits
with, the class attributes it sets, and the hooks it declares with @on.
This page is the full map of what you can change and how each piece behaves. Most
repositories touch only the first two.
Setup (models and DTO used throughout this page)
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from repositron import Repository, UNSET, UnsetType
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
workspace_id: Mapped[int]
title: Mapped[str]
description: Mapped[str | None]
status: Mapped[str]
assignee_id: Mapped[int | None]
created_at: Mapped[datetime]
archived_at: Mapped[datetime | None]
@dataclass(frozen=True, slots=True)
class TaskDTO:
id: int
title: str
status: str
assignee_id: int | None
@dataclass(frozen=True, slots=True)
class TaskCreate:
workspace_id: int
title: str
status: str
@dataclass(frozen=True, slots=True)
class TaskUpdate:
title: str | UnsetType = UNSET
status: str | UnsetType = UNSET
assignee_id: int | None | UnsetType = UNSET
class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate]):
pass
The one idea: convention first, configuration only when you diverge¶
Here is the mental model that makes the rest of this page obvious.
By default, repositron expects your DTO to mirror your model: the same field names, the same types. When that holds, nothing needs configuring. You declare the DTO, inherit the repository, and hydration just works, field by matching field.
from dataclasses import dataclass
from sqlalchemy.orm import Mapped, mapped_column
from repositron import Repository
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
status: Mapped[str]
@dataclass(frozen=True, slots=True)
class TaskDTO: # same names, same types as the columns
id: int
title: str
status: str
class TaskRepository(Repository[Task, TaskDTO]):
pass # no field_mapping, no overrides, nothing
That pass is the point. The common case has zero configuration.
You only reach for the knobs below when the DTO needs to diverge from the model:
- a field is named differently from its column? add it to
field_mapping - the DTO should carry fewer fields than the model? leave them out and hydration
does not copy them into the DTO. To select fewer database columns, use
repo[Shape] - the DTO needs a value the row alone cannot give (a join, a computed field)?
add it with a
hydratehook - the primary key is not called
id? setpk_column
So the beauty is the gradient: the zero-config path covers most tables, and every divergence has exactly one small place to express it. You pay for flexibility only on the tables that need it.
Type parameters and their defaults¶
Repository[Model, DTO = Model, Create = object, Update = object, PK = int, SessionT = Session]
ReadOnlyRepository[Model, DTO = Model, PK = int, SessionT = Session]
Only Model is required. Everything after it has a default, so you supply each
parameter only when you actually need it.
| Parameter | If you omit it... |
|---|---|
Model |
required; this is the SQLAlchemy class the repository queries |
DTO |
defaults to Model: reads return the model itself, unhydrated |
Create |
defaults to object: you simply do not call create |
Update |
defaults to object: you simply do not call update |
PK |
defaults to int: the type of the primary key, so get/exists/delete take it and create returns it |
SessionT |
defaults to Session: use AsyncSession or async_scoped_session[AsyncSession] for the awaited API |
That is why both of these are valid, each adding only what it uses:
from dataclasses import dataclass
from sqlalchemy.orm import Mapped, mapped_column
from repositron import ReadOnlyRepository, Repository
# Full CRUD over Task, int key, hydrating to TaskDTO with payloads.
class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate]): ...
# Read-only cache table keyed by a string hash, hydrating to PageDTO.
class Page(Base):
__tablename__ = "pages"
url_hash: Mapped[str] = mapped_column(primary_key=True)
html: Mapped[str]
@dataclass(frozen=True, slots=True)
class PageDTO:
url_hash: str
html: str
class PageRepository(ReadOnlyRepository[Page, PageDTO, str]):
pk_column = "url_hash"
The key type is the PK slot, immediately before SessionT. When the key is a
str or uuid, declare it there, see
primary keys for the why and the slot mechanics.
The parameters are read off your class declaration at runtime, so you do not
register the model or wire anything up. Inheriting with the types is the
configuration. (If you forget to parameterize, repositron raises a clear
TypeError telling you to pass the generic arguments.)
field_mapping: when a DTO field is named differently¶
The most common reason a DTO cannot be built automatically is a name mismatch:
the column is created_at, but your DTO (and your API) calls it opened_at.
field_mapping records that rename once.
The direction is {model_column: dto_field}, read as "the model's
created_at is the DTO's opened_at":
from datetime import datetime
from sqlalchemy.orm import Mapped
class Task(Base):
created_at: Mapped[datetime] # the column
@dataclass
class TaskDTO:
opened_at: datetime # the field
It applies to hydration and projection¶
field_mapping translates between model columns and DTO fields on reads. It
does not rename create or update payload fields.
- Reading (hydration). When a row becomes a
TaskDTO, the columncreated_atis read into the fieldopened_at. - Projecting. When you do
await repo[TaskCard].list()andTaskCardhas anopened_atfield, repositron resolves it back to thecreated_atcolumn to build theSELECT. See projection. - Writing. Create and update payloads always use model attribute names. A
payload for
created_atmust namecreated_at, even if the DTO calls itopened_at.
A field not listed in field_mapping has the same name on the model and DTO.
pk_column: when the key is not id¶
The base assumes the key column is named id. When it is not, set pk_column,
either to the column name or to the column reference itself, and every id-based
method follows:
from dataclasses import dataclass
from sqlalchemy.orm import Mapped, mapped_column
from repositron import ReadOnlyRepository
class Page(Base): # a cache table keyed by a content hash
__tablename__ = "pages"
url_hash: Mapped[str] = mapped_column(primary_key=True)
html: Mapped[str]
@dataclass(frozen=True, slots=True)
class PageDTO:
url_hash: str
html: str
class PageRepository(ReadOnlyRepository[Page, PageDTO, str]):
pk_column = "url_hash" # by name
class PageRepository(ReadOnlyRepository[Page, PageDTO, str]):
pk_column = Page.url_hash # by column reference (checked against the model)
The column's name is a runtime concern (pk_column); the key's type is the
PK parameter before SessionT. They are separate knobs. The details, including
str/uuid keys and composite keys, live in primary keys.
Defaults you can override at the class level¶
Beyond the two configured attributes, a common convention is to attach a canonical ordering to the repository so callers do not repeat it:
class TaskRepository(Repository[Task, TaskDTO]):
field_mapping = {"created_at": "opened_at"}
ORDER = [Task.created_at.desc(), Task.id]
await repo.list(order_by=repo.ORDER)
await repo.list_paginated(0, 20, order_by=repo.ORDER)
ORDER is not special to repositron, it is just an attribute you define and pass
in. But it is the idiomatic place to keep "the way this table is normally
sorted", so it is worth adopting.
Adding behavior: hooks¶
When configuration is not enough because there is logic to run, a write that
needs a derived column, a DTO that needs an extra field, you add it with a
hook, not an override. Tag a method with @on and the base runs it
inside its own create / update / delete / hydration:
from dataclasses import replace
from repositron import Repository, on
class TaskRepository(Repository[Task, TaskDetail]):
@on("hydrate", mode="after")
def with_assignee(self, model: Task, dto: TaskDetail) -> TaskDetail:
return replace(dto, assignee=self._load_assignee(model.assignee_id))
Overriding _hydrate is the rarer fallback, only when the automatic build cannot
produce the DTO at all. Hooks covers both, and where the line falls.
The short version¶
| To change... | Do this |
|---|---|
| what reads return | set the DTO type parameter |
| a field whose name differs from the column | add it to field_mapping |
| the primary-key column name | set pk_column |
| the default sort for callers | set a class attribute like ORDER |
| add a derived field, column, or side effect | a hook with @on |
| build a DTO the automatic path can't | override _hydrate |