Repositories
A repository is the component that reads and writes a model's table. Preá gives every repository the common operations, and you add your own queries in plain SQL.
What you'll learn
- The operations every repository has
- The difference between
UpdateandSave - How to write your own queries with named parameters
- How database errors reach the client
A repository
type Repository struct {
database.BaseRepository[Task]
}Embedding database.BaseRepository[Task] gives the repository its database connection and every operation below, for the Task model. Declare it in the preá's Providers, and ask for it in the service:
type Service struct {
Repo *Repository `inject:""`
}Ready-made operations
| Method | What it does |
|---|---|
GetAll(ctx) | every row, ordered by the primary key |
GetByID(ctx, id) | one row, or database.ErrNotFound |
Where(ctx, database.Args{"list_id": id}) | the rows where every column equals its value (nil means is null) |
First(ctx, args) | the first row that matches, or ErrNotFound |
Count(ctx, args) | how many rows match (nil counts them all) |
Exists(ctx, args) | whether any row matches |
Create(ctx, dto) | inserts a row from a request struct and returns it |
Update(ctx, id, dto) | changes the fields you sent and returns the row |
Save(ctx, entity) | writes the whole row, inserting or updating |
Delete(ctx, id) | deletes the row, or returns ErrNotFound |
The fields of the struct you pass to Create and Update match the columns by name, like the model's.
Create
Create inserts every field of the struct you pass, usually a request struct from dtos/:
// CreateRequest is the body of POST /task.
type CreateRequest struct {
Title string `json:"title" binding:"required"`
ListID *string `json:"listId"`
}task, err := s.Repo.Create(ctx, req)Columns the struct doesn't have, like id and done, get their database defaults. So do pointer fields left nil, which is how an optional field of a request falls back to the default:
type CreateRequest struct {
Name string `json:"name"`
Role *string `json:"role"` // not sent: the column default ('member')
}A plain string field is always written, even empty: "" replaces the default. The returned row has every column filled in.
Update: change what was sent
Update changes only the fields that are not zero. A request with only title leaves done alone. That is what a PATCH needs.
The catch is that a zero value can't be sent: false, 0 and "" are skipped. To allow them, use a pointer. nil means "keep it", and a pointer to false means "set it to false":
// UpdateRequest is the body of PATCH /task/:id. Only the fields you send
// change: nil keeps the column as it is.
type UpdateRequest struct {
Title *string `json:"title"`
Done *bool `json:"done"`
}task, err := s.Repo.Update(ctx, id, req) // {"done": false} now writes falseYou can also pass database.Args{"done": false} for a quick update from code. An update with nothing to change returns database.ErrInvalidInput (400).
Save the whole row
Save writes every column of the model, zeros included. Use it to load a row, change it in code and write it back:
err := s.Transaction(ctx, func(ctx context.Context) error {
task, err := s.Repo.GetByID(ctx, id)
if err != nil {
return err
}
task.Done = !task.Done // false is written too
_, err = s.Repo.Save(ctx, task)
return err
})What Save does depends on the key:
- Empty generated key: it inserts, and the database generates the key.
- Filled generated key: it updates, or returns
ErrNotFoundif the row is gone. - Natural key (not generated, composite included): it inserts or updates.
Safe concurrent updates. If two requests load the same row and both save it, the second one silently overwrites the first. A version:"" field prevents that. Save only writes if the row still has the version it loaded, and then increments it. Otherwise it returns database.ErrConflict (409), and the client can reload and try again.
type Task struct {
ID string `pk:"" generated:"uuid"`
Title string
Version int64 `version:""`
}Your own queries
r.Prepare runs any SQL. Parameters are named with @, and the rows map to the model:
func (r *Repository) Search(ctx context.Context, text string) ([]Task, error) {
return r.Prepare("select * from task where title ilike @q order by title").
Bind("q", "%"+text+"%").
All(ctx)
}| Method | Returns |
|---|---|
All(ctx) | every row |
One(ctx) | exactly one row, or ErrNotFound |
Exec(ctx) | runs a statement without rows and returns how many rows it affected |
Bind several parameters at once with BindArgs(database.Args{...}).
Other shapes. To read only some columns, or a value that isn't a model, use database.Prepare with the type you want:
type Summary struct {
ID string
Title string
}
summaries, err := database.Prepare[Summary](r.DB, "select id, title from task").All(ctx)
pending, err := database.Prepare[int64](r.DB, "select count(*) from task where not done").One(ctx)The ready-made operations select every column. When you need fewer, write the query.
Errors
Database errors become errors with an HTTP status, so a service can usually return them as they are:
| What happened | Error | Status |
|---|---|---|
| no row found | database.ErrNotFound | 404 |
| a unique value already exists | database.ErrConflict | 409 |
a malformed value (like an invalid UUID), a value too long for its column or out of its type's range, a null in a not null column, a broken foreign key or a failed check | database.ErrInvalidInput | 400 |
The client sees only the short message, like "not found". The database's details go to the log.
Next steps
- Transactions: several writes that succeed or fail together
- Models & schema