Documentation
¶
Overview ¶
Package orm is a generic query builder and model mapper over database/sql. Queries are typed (Query[T] returns T values), always use bound parameters, and validate column names against the model so input can never become SQL (A05). Models are plain structs; persistence functions take an Executor (*database.DB or *database.Tx), so there is no Active Record handle hidden in models.
Embed Model for an ID and timestamps, build reads with QueryOf, and write with Create, Update and Delete. Relations, scopes, soft deletes and encrypted columns (Encrypted) are declared on the struct. Tables come from package schema migrations.
An invalid model (not a struct, a bad table or column name, no primary key, a relation without its foreign key, an unknown orm tag option) panics on its first use, usually at boot: it is a programming error, not a runtime condition.
See https://github.com/AlexGalhardo/vukem/blob/main/docs/database.md.
Index ¶
- Constants
- Variables
- func Attach[T any](ctx context.Context, ex database.Executor, model *T, relation string, ...) error
- func Columns[T any]() []string
- func Create[T any](ctx context.Context, ex database.Executor, model *T) error
- func CreateMany[T any](ctx context.Context, ex database.Executor, models []T) error
- func Delete[T any](ctx context.Context, ex database.Executor, model *T) error
- func Detach[T any](ctx context.Context, ex database.Executor, model *T, relation string, ...) error
- func ForceDelete[T any](ctx context.Context, ex database.Executor, model *T) error
- func PrimaryKeyOf[T any]() string
- func RelationsOf[T any]() []string
- func Restore[T any](ctx context.Context, ex database.Executor, model *T) error
- func Sync[T any](ctx context.Context, ex database.Executor, model *T, relation string, ...) error
- func TableOf[T any]() string
- func Update[T any](ctx context.Context, ex database.Executor, model *T) error
- func Validate(ctx context.Context, ex database.Executor, rules ...Rule) error
- func WithClock(ctx context.Context, now func() time.Time) context.Context
- type AfterCreator
- type AfterUpdater
- type BeforeCreator
- type BeforeDeleter
- type BeforeUpdater
- type BelongsTo
- type CursorPage
- type Direction
- type Encrypted
- type FieldInfo
- type HasMany
- type HasOne
- type JSON
- type Keyer
- type ManyToMany
- type Model
- type NullTime
- type Page
- type Query
- func (q Query[T]) Chunk(ctx context.Context, size int, fn func([]T) error) error
- func (q Query[T]) Count(ctx context.Context) (int64, error)
- func (q Query[T]) CursorPaginate(ctx context.Context, cursor string, perPage int, dir Direction) (CursorPage[T], error)
- func (q Query[T]) Delete(ctx context.Context) (int64, error)
- func (q Query[T]) Each(ctx context.Context) iter.Seq2[T, error]
- func (q Query[T]) Exists(ctx context.Context) (bool, error)
- func (q Query[T]) Find(ctx context.Context, id any) (T, error)
- func (q Query[T]) First(ctx context.Context) (T, error)
- func (q Query[T]) ForShare() Query[T]
- func (q Query[T]) Get(ctx context.Context) ([]T, error)
- func (q Query[T]) Latest() Query[T]
- func (q Query[T]) Limit(n int) Query[T]
- func (q Query[T]) LockForUpdate() Query[T]
- func (q Query[T]) MaxPerPage(n int) Query[T]
- func (q Query[T]) NoWait() Query[T]
- func (q Query[T]) Offset(n int) Query[T]
- func (q Query[T]) OnlyTrashed() Query[T]
- func (q Query[T]) OrWhere(col string, args ...any) Query[T]
- func (q Query[T]) OrderBy(colName string, dir Direction) Query[T]
- func (q Query[T]) Paginate(ctx context.Context, page, perPage int) (Page[T], error)
- func (q Query[T]) Pluck[V any](ctx context.Context, colName string) ([]V, error)
- func (q Query[T]) SQL() (string, []any, error)
- func (q Query[T]) Scope(fns ...func(Query[T]) Query[T]) Query[T]
- func (q Query[T]) Select(cols ...string) Query[T]
- func (q Query[T]) SkipLocked() Query[T]
- func (q Query[T]) Update(ctx context.Context, set Set) (int64, error)
- func (q Query[T]) Where(col string, args ...any) Query[T]
- func (q Query[T]) WhereBetween(colName string, low, high any) Query[T]
- func (q Query[T]) WhereGroup(fn func(g Query[T]) Query[T]) Query[T]
- func (q Query[T]) WhereIn(col string, values ...any) Query[T]
- func (q Query[T]) WhereNotIn(col string, values ...any) Query[T]
- func (q Query[T]) WhereNotNull(col string) Query[T]
- func (q Query[T]) WhereNull(col string) Query[T]
- func (q Query[T]) WhereRaw(sqlFrag string, args ...any) Query[T]
- func (q Query[T]) With(relations ...string) Query[T]
- func (q Query[T]) WithTrashed() Query[T]
- type Rule
- type Set
- type SoftDeletes
- type Tabler
Examples ¶
Constants ¶
const DefaultMaxPerPage = 100
DefaultMaxPerPage bounds perPage in Paginate and CursorPaginate unless the query sets MaxPerPage, so a per_page taken from a request cannot make one call load a whole table.
Variables ¶
var ErrConflict error = &conflictError{}
ErrConflict is returned (wrapped) when a write violates a unique constraint; it reports HTTP status 409 so handlers answer Conflict.
var ErrInvalidCursor = &errs.HTTPError{Status: 400, Message: "Invalid pagination cursor."}
ErrInvalidCursor is returned (400) when a cursor cannot be decoded.
var ErrLockOutsideTx = errors.New("orm: row locks need a transaction (run the query on a *database.Tx)")
ErrLockOutsideTx is returned when a locking query (LockForUpdate, ForShare) runs on a *database.DB instead of a *database.Tx: the lock would be released as soon as the statement ends.
var ErrNoEncrypter = errors.New("orm: encrypted columns need an encrypter (database.WithEncrypter)")
ErrNoEncrypter is returned when a model with Encrypted columns is written or read through an executor without an encrypter (see database.WithEncrypter). Values are never stored in plaintext instead.
var ErrNotFound = &errs.HTTPError{Status: 404, Message: "Record not found."}
ErrNotFound is returned when First/Find match no row. It matches errs.ErrNotFound, so handlers answer 404 by returning it.
var ErrNotLoaded = errors.New("orm: relation not loaded (use Query.With)")
ErrNotLoaded is returned when a relation is read without being eager loaded with Query.With. Relations never query lazily, so there are no hidden N+1 queries (strict mode).
Functions ¶
func Attach ¶ added in v0.4.0
func Attach[T any](ctx context.Context, ex database.Executor, model *T, relation string, ids ...any) error
Attach links model to the related ids through the pivot table of the many-to-many relation (field name). Ids already attached are skipped.
func CreateMany ¶ added in v0.24.0
CreateMany inserts models with multi-row INSERT statements, split into batches that stay under the dialect's bound-parameter limit. It fills timestamps and runs BeforeCreate on every model first, then AfterCreate once every row is stored.
Atomicity holds only for *database.DB and *database.Tx: on a DB the batches run in one transaction, so either every row is stored or none is; on a Tx they join it and the caller commits or rolls back. Any other executor (a wrapper) runs the statements directly, so batches written before a failure stay written and keep their ids.
AfterCreate runs after that transaction commits (a hook writing through the DB would otherwise deadlock on SQLite's single writer), so on a DB an AfterCreate error means the rows are stored and the caller still gets the error.
Auto-increment ids are written back when every model has a zero id (or every model sets its own; mixing both is an error). PostgreSQL and SQLite return them with RETURNING, in VALUES order; the ids must be strictly increasing (an ascending sequence), otherwise CreateMany fails instead of guessing which row got which id. MySQL cannot report the ids of a multi-row insert reliably, so there each row is its own INSERT. A model with no column besides its generated key is rejected.
func Detach ¶ added in v0.4.0
func Detach[T any](ctx context.Context, ex database.Executor, model *T, relation string, ids ...any) error
Detach unlinks the related ids, or every related row when ids is empty.
func ForceDelete ¶
ForceDelete removes model even when it uses soft deletes.
func PrimaryKeyOf ¶ added in v0.20.0
PrimaryKeyOf returns the primary key column of model T.
func RelationsOf ¶ added in v0.28.0
RelationsOf returns the relation field names of model T (the names Query.With takes), sorted.
func Sync ¶ added in v0.4.0
func Sync[T any](ctx context.Context, ex database.Executor, model *T, relation string, ids ...any) error
Sync makes ids the exact set of related rows (in a transaction): missing ids are attached, others detached.
func Update ¶
Update writes every column of model (except the primary key and created_at) and refreshes updated_at.
func Validate ¶ added in v0.4.0
Validate runs rules and returns an *errs.ValidationError (422) listing the failing fields, or nil. Empty values (nil, "", zero numbers) are skipped: required-ness belongs to the z schema. The built-in messages follow the request locale ("validation.unique", "validation.exists"; see errs.WithTranslator, installed by i18n.Middleware).
Types ¶
type AfterCreator ¶ added in v0.21.0
AfterCreator runs after INSERT.
type AfterUpdater ¶
AfterUpdater runs after UPDATE.
type BeforeCreator ¶ added in v0.21.0
BeforeCreator runs before INSERT.
type BeforeDeleter ¶
BeforeDeleter runs before DELETE (or soft delete).
type BeforeUpdater ¶
BeforeUpdater runs before UPDATE.
type BelongsTo ¶ added in v0.4.0
type BelongsTo[T any] struct { // contains filtered or unexported fields }
BelongsTo is a relation to the model referenced by a foreign key on this model: Post.User with column user_id. Override the column with the tag `orm:"fk:author_id"`.
func (BelongsTo[T]) Get ¶ added in v0.4.0
Get returns the related model, nil when the foreign key points nowhere, or ErrNotLoaded.
func (BelongsTo[T]) IsZero ¶ added in v0.4.0
IsZero makes `json:",omitzero"` drop relations that were not loaded.
func (BelongsTo[T]) MarshalJSON ¶ added in v0.4.0
MarshalJSON encodes the related model (null when none or not loaded).
type CursorPage ¶
type CursorPage[T any] struct { Items []T `json:"data"` PerPage int `json:"per_page"` NextCursor string `json:"next_cursor,omitempty"` }
CursorPage is one page of keyset pagination. NextCursor is empty on the last page.
type Encrypted ¶ added in v0.18.0
type Encrypted[T any] struct { V T }
Encrypted stores T as JSON encrypted with the executor's encrypter (AES-256-GCM via app.Crypt). The table and column are authenticated with the value, so a ciphertext copied to another column is rejected; values sealed with a previous key (APP_PREVIOUS_KEYS) still decrypt and are re-sealed with the current key on the next save.
Encrypted columns are written and read only through orm (Create, Update, Query.Update, queries and Pluck); passing an Encrypted value to database/sql directly fails. They cannot be filtered with Where because every encryption is randomized. NULL reads as the zero value. JSON encoding (API output) uses the plain V: tag the field `json:"-"` to hide it.
Example ¶
package main
import (
"bytes"
"context"
"fmt"
"github.com/AlexGalhardo/vukem/crypt"
"github.com/AlexGalhardo/vukem/database"
"github.com/AlexGalhardo/vukem/orm"
)
type Patient struct {
orm.Model
Name string
Diagnosis orm.Encrypted[string] `json:"-"`
}
func main() {
ctx := context.Background()
// Apps pass app.Crypt; app.DB() does it for you.
enc, _ := crypt.New(bytes.Repeat([]byte{1}, crypt.KeySize))
db, _ := database.Open(ctx, ":memory:", database.WithEncrypter(enc))
defer db.Close()
_, _ = db.ExecContext(ctx, `CREATE TABLE patients (id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP, updated_at TIMESTAMP, name TEXT, diagnosis TEXT)`)
p := Patient{Name: "Ada", Diagnosis: orm.Encrypted[string]{V: "flu"}}
if err := orm.Create(ctx, db, &p); err != nil {
fmt.Println(err)
return
}
var stored string
_ = db.QueryRowContext(ctx, "SELECT diagnosis FROM patients").Scan(&stored)
fmt.Println("stored plaintext:", stored == "flu" || stored == `"flu"`)
got, _ := orm.QueryOf[Patient](db).Find(ctx, p.ID)
fmt.Println("decrypted:", got.Diagnosis.V)
}
Output: stored plaintext: false decrypted: flu
func (Encrypted[T]) MarshalJSON ¶ added in v0.18.0
MarshalJSON encodes the plain inner value.
func (*Encrypted[T]) Scan ¶ added in v0.18.0
Scan implements sql.Scanner and always fails: Encrypted columns are opened by orm queries.
func (*Encrypted[T]) UnmarshalJSON ¶ added in v0.18.0
UnmarshalJSON decodes into the inner value.
type FieldInfo ¶ added in v0.20.0
type FieldInfo struct {
// Column is the column name.
Column string
// Name is the Go field name.
Name string
// Index locates the field for reflect.Value.FieldByIndex.
Index []int
// Type is the Go field type.
Type reflect.Type
// Tag is the struct field tag.
Tag reflect.StructTag
}
FieldInfo describes a mapped column of a model, for tools that work with any model (the admin panel, exporters).
type HasMany ¶ added in v0.4.0
type HasMany[T any] struct { // contains filtered or unexported fields }
HasMany is a relation to the models holding a foreign key to this one: User.Posts with posts.user_id. Tag: `orm:"fk:author_id"`.
func (HasMany[T]) IsZero ¶ added in v0.4.0
IsZero makes `json:",omitzero"` drop relations that were not loaded.
func (HasMany[T]) MarshalJSON ¶ added in v0.4.0
MarshalJSON encodes the related models ([] when loaded and empty).
type HasOne ¶ added in v0.4.0
type HasOne[T any] struct { // contains filtered or unexported fields }
HasOne is a relation to one model holding a foreign key to this one: User.Profile with profiles.user_id. Tag: `orm:"fk:owner_id"`.
func (HasOne[T]) Get ¶ added in v0.4.0
Get returns the related model (nil when none) or ErrNotLoaded.
func (HasOne[T]) IsZero ¶ added in v0.4.0
IsZero makes `json:",omitzero"` drop relations that were not loaded.
func (HasOne[T]) MarshalJSON ¶ added in v0.4.0
MarshalJSON encodes the related model (null when none or not loaded).
type JSON ¶
type JSON[T any] struct { V T }
JSON stores T as JSON text in one column.
func (JSON[T]) MarshalJSON ¶
MarshalJSON encodes the inner value.
func (*JSON[T]) UnmarshalJSON ¶
UnmarshalJSON decodes into the inner value.
type Keyer ¶
type Keyer interface{ PrimaryKey() string }
Keyer overrides the primary key column (default "id").
type ManyToMany ¶ added in v0.4.0
type ManyToMany[T any] struct { // contains filtered or unexported fields }
ManyToMany relates models through a pivot table: Post.Tags with table post_tag (the two singular table names, sorted) and columns post_id and tag_id. Tag: `orm:"pivot:taggings,fk:post_id,rk:tag_id"`.
func (ManyToMany[T]) Get ¶ added in v0.4.0
func (r ManyToMany[T]) Get() ([]T, error)
Get returns the related models or ErrNotLoaded.
func (ManyToMany[T]) IsZero ¶ added in v0.4.0
func (r ManyToMany[T]) IsZero() bool
IsZero makes `json:",omitzero"` drop relations that were not loaded.
func (ManyToMany[T]) Loaded ¶ added in v0.4.0
func (r ManyToMany[T]) Loaded() bool
Loaded reports whether the relation was eager loaded.
func (ManyToMany[T]) MarshalJSON ¶ added in v0.4.0
func (r ManyToMany[T]) MarshalJSON() ([]byte, error)
MarshalJSON encodes the related models ([] when loaded and empty).
type Model ¶
type Model struct {
ID int64 `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
Model provides an auto-increment id and timestamps; embed it in models.
type NullTime ¶ added in v0.4.0
NullTime is a nullable timestamp that encodes as JSON null or an RFC 3339 time (sql.Null would encode as {"V":...,"Valid":...}).
func (NullTime) IsZero ¶ added in v0.4.0
IsZero reports a NULL value, so `json:",omitzero"` omits it.
func (NullTime) MarshalJSON ¶ added in v0.4.0
MarshalJSON implements json.Marshaler.
func (*NullTime) UnmarshalJSON ¶ added in v0.4.0
UnmarshalJSON implements json.Unmarshaler.
type Page ¶
type Page[T any] struct { Items []T `json:"data"` Total int64 `json:"total"` Page int `json:"page"` PerPage int `json:"per_page"` LastPage int `json:"last_page"` }
Page is one page of results.
type Query ¶
type Query[T any] struct { // contains filtered or unexported fields }
Query builds a SELECT for model T. Builders are values: every method returns a modified copy, so a base query can be shared and refined.
func (Query[T]) Chunk ¶
Chunk processes rows in batches of size ordered by primary key; fn may stop early by returning an error. Batches are read by keyset (primary key greater than the last one seen), not OFFSET, so fn may delete or update the rows it receives without making Chunk skip rows. The query must not have its own OrderBy, Limit or Offset.
func (Query[T]) Count ¶
Count returns the number of matching rows. It ignores LockForUpdate and ForShare: PostgreSQL rejects row locks on aggregates, so lock the rows with Get or Pluck when the count must stay stable.
func (Query[T]) CursorPaginate ¶
func (q Query[T]) CursorPaginate( ctx context.Context, cursor string, perPage int, dir Direction, ) (CursorPage[T], error)
CursorPaginate returns perPage rows after cursor, ordered by the primary key in dir. Unlike Paginate it needs no COUNT and stays fast and stable on large or changing tables. Pass "" for the first page and the returned NextCursor for the following ones. The query must not have its own OrderBy: the keyset order is the primary key. perPage is clamped to 1..DefaultMaxPerPage (see MaxPerPage).
func (Query[T]) Delete ¶
Delete removes every matching row (soft-deleting for SoftDeletes models unless WithTrashed/OnlyTrashed was used, which deletes for real) and returns the count.
func (Query[T]) Each ¶
Each streams rows without loading them all; breaking out of the loop closes the rows.
for post, err := range orm.QueryOf[Post](db).Each(ctx) { ... }
func (Query[T]) ForShare ¶ added in v0.24.0
ForShare appends FOR SHARE (LOCK IN SHARE MODE on MySQL, which MariaDB also understands): matching rows can be read but not changed by other transactions until this one ends. Like LockForUpdate it needs a *database.Tx and is a no-op on SQLite.
func (Query[T]) LockForUpdate ¶ added in v0.24.0
LockForUpdate appends FOR UPDATE: matching rows stay locked against other writers until the transaction ends. The query must run on a *database.Tx. On SQLite it is a no-op, because SQLite has no row locks and a write transaction (BEGIN IMMEDIATE) already locks the database.
func (Query[T]) MaxPerPage ¶ added in v0.28.0
MaxPerPage sets the largest perPage Paginate and CursorPaginate accept for this query (default DefaultMaxPerPage); a larger perPage is lowered to n, and Page.PerPage reports the value used. n < 1 is an error.
func (Query[T]) NoWait ¶ added in v0.24.0
NoWait makes the lock fail at once when a row is held by another transaction instead of waiting. Call it after LockForUpdate or ForShare. It is a no-op on SQLite, where it never fails: a second writer waits for the database lock (busy timeout) instead.
func (Query[T]) OnlyTrashed ¶
OnlyTrashed returns only soft-deleted rows.
func (Query[T]) Paginate ¶
Paginate returns page (1-based) with perPage items and the total count. perPage is clamped to 1..DefaultMaxPerPage (see MaxPerPage).
func (Query[T]) SkipLocked ¶ added in v0.24.0
SkipLocked makes the lock skip rows other transactions hold instead of waiting (work queues). Call it after LockForUpdate or ForShare. Like the lock itself it is a no-op on SQLite.
func (Query[T]) Update ¶
Update sets columns on every matching row and returns the count. updated_at is refreshed unless set explicitly.
func (Query[T]) Where ¶
Where adds "col = value", or "col op value" when three arguments are given: Where("age", ">=", 18). Operators are allow-listed.
func (Query[T]) WhereBetween ¶
WhereBetween adds "col BETWEEN low AND high".
func (Query[T]) WhereGroup ¶
WhereGroup adds a parenthesised group built by fn.
func (Query[T]) WhereNotIn ¶
WhereNotIn adds "col NOT IN (...)". An empty list matches everything.
func (Query[T]) WhereNotNull ¶
WhereNotNull adds "col IS NOT NULL".
func (Query[T]) WhereRaw ¶
WhereRaw adds a raw condition. It must use "?" placeholders for every value; never concatenate input into sql. Placeholders are found the way the dialect lexes SQL (database.Rebind's rules): "?" inside literals, quoted identifiers, comments and PostgreSQL dollar-quoted bodies does not count, and "??" is a literal "?" on PostgreSQL. A fragment that ends inside a literal, identifier or comment (including a "--" comment without a newline) is rejected, since it would swallow the SQL appended after it, and so are explicit PostgreSQL "$n" parameters. Without an executor the fragment is lexed with SQLite's rules.
func (Query[T]) With ¶ added in v0.4.0
With eager loads relations (by field name) for Get, First, Find, Paginate and CursorPaginate: one query per relation, batched with IN, so N rows cost 1 + len(relations) queries. Each does not eager load.
func (Query[T]) WithTrashed ¶
WithTrashed includes soft-deleted rows.
type Rule ¶ added in v0.4.0
type Rule struct {
// contains filtered or unexported fields
}
Rule is a validation rule that needs the database (unique, exists). z schemas validate shape and format; Rules run after parsing:
in, err := web.Parse(c, requests.RegisterSchema)
if err != nil {
return err
}
if err := orm.Validate(c.Context(), db, orm.Unique[models.User]("email", in.Email)); err != nil {
return err // 422 (or flashed errors for forms), like any validation error
}
func Unique ¶ added in v0.4.0
Unique passes when no row of T has column = value (soft-deleted rows count, as the database constraint does). Use Ignore(id) when updating.
func (Rule) Field ¶ added in v0.4.0
Field reports the error on name instead of the column name; the built-in message names it too ("The contact has already been taken.").
type SoftDeletes ¶
type SoftDeletes struct {
DeletedAt NullTime `db:"deleted_at" json:"deleted_at,omitzero"`
}
SoftDeletes makes Delete set deleted_at instead of removing rows; queries exclude deleted rows unless WithTrashed or OnlyTrashed is used.