Pular para o conteúdo
Esta página ainda não foi traduzida. O conteúdo abaixo está em inglês. Ler em inglês

Database

ORM

Plain-struct models and a generic, always-parameterised query builder with pagination, streaming, soft deletes and hooks.

Última atualização:

The orm package maps plain structs to tables. There is no Active Record: persistence functions take an executor (*database.DB or *database.Tx), so models carry no hidden connection.

Models#

app/models/post.go
type Post struct {
	orm.Model                    // ID, CreatedAt, UpdatedAt
	orm.SoftDeletes              // optional: DeletedAt, Delete becomes a soft delete

	Title    string             `json:"title"`
	Body     string             `json:"body"`
	AuthorID int64              `json:"author_id" db:"author_id"`
	Meta     orm.JSON[PostMeta] `json:"meta"` // stored as JSON text; the value is Meta.V
}
ConventionDefaultOverride
Tablesnake_case plural of the type (Post → posts)Table() string (orm.Tabler)
Columnsnake_case of the field (AuthorID → author_id)db:"name"; db:"-" skips a field
Primary keyidPrimaryKey() string (orm.Keyer)

orm.TableOf[T]() and orm.Columns[T]() report what Vukem derived.

Writing#

Go
post := models.Post{Title: "Hello"}
err := orm.Create(ctx, db, &post) // sets ID, CreatedAt, UpdatedAt
post.Title = "Edited"
err = orm.Update(ctx, db, &post)  // bumps UpdatedAt
err = orm.Delete(ctx, db, &post)  // soft delete when the model embeds SoftDeletes
err = orm.Restore(ctx, db, &post)
err = orm.ForceDelete(ctx, db, &post)

Unique-constraint violations return an error matching orm.ErrConflict (HTTP 409 when returned from a handler).

Bulk writes go through a query:

Go
n, err := orm.QueryOf[models.Post](db).Where("published", false).Update(ctx, orm.Set{"archived": true})
n, err = orm.QueryOf[models.Post](db).Where("created_at", "<", cutoff).Delete(ctx)

orm.CreateMany inserts a slice with multi-row INSERT statements, batched under the bound-parameter limit (32766 on SQLite, 65535 on PostgreSQL and MySQL):

Go
numbers := make([]models.Number, 1000)
for i := range numbers {
	numbers[i] = models.Number{RaffleID: raffle.ID, Value: i + 1}
}
err := orm.CreateMany(ctx, db, numbers) // fills timestamps and ids
  • Atomicity holds for db and tx only: on db the batches share one transaction (all rows or none); on a tx they join it. Any other database.Executor (a wrapper) runs the statements directly, so batches written before a failure stay written and keep their ids.
  • BeforeCreate runs for every model before the first insert. AfterCreate runs after the last, once the transaction on db has committed: an AfterCreate error there means the rows are stored and you still get the error.
  • Auto-increment ids are filled when every model leaves its id zero (mixing set and zero ids is an error). PostgreSQL and SQLite return them with RETURNING; they must be strictly increasing (an ascending sequence), otherwise CreateMany fails and rolls back instead of guessing which row got which id. MySQL cannot report the ids of a multi-row insert, so there such rows are inserted one statement each.
  • A model whose only column is its generated key is rejected; use orm.Create.

Querying#

Go
posts, err := orm.QueryOf[models.Post](db).
	Where("published", true).                 // col = ?
	Where("views", ">=", 100).                // allow-listed operators, including LIKE
	WhereIn("author_id", 1, 2, 3).
	WhereGroup(func(q orm.Query[models.Post]) orm.Query[models.Post] {
		return q.Where("title", "LIKE", "%go%").OrWhere("body", "LIKE", "%go%")
	}).
	OrderBy("id", orm.Desc).
	Limit(20).
	Get(ctx) // []models.Post
BuildersTerminals
Select, Where, OrWhere, WhereIn, WhereNotIn, WhereNull, WhereNotNull, WhereBetween, WhereRaw, WhereGroupGet, First, Find(id), Count, Exists
LockForUpdate(), ForShare(), SkipLocked(), NoWait() (row locks)
OrderBy(col, orm.Asc/orm.Desc), Latest(), Limit, OffsetPluck[V](col), Each, Chunk, Paginate
Scope(fns...), WithTrashed(), OnlyTrashed()Update(set), Delete(), SQL()

Column names are validated against the model, never interpolated from input; values are always bound parameters. WhereRaw(sql, args...) is the explicit escape hatch and checks the number of ? placeholders. It finds them the way the dialect lexes SQL, with the same rules as database.Rebind: ? inside literals, quoted identifiers, comments and PostgreSQL dollar-quoted bodies does not count, and ?? is a literal ? on PostgreSQL (jsonb operators). A fragment that ends inside a literal, identifier or comment (a trailing -- comment too) is rejected, since it would swallow the SQL appended after it, and so are explicit $n parameters on PostgreSQL. Without an executor the fragment is lexed with SQLite's rules. The lexer assumes the servers' default string and quote settings; placeholders inside MySQL's executable /*! */ and /*+ */ comments are not supported.

Go
post, err := orm.QueryOf[models.Post](db).Find(ctx, id) // orm.ErrNotFound → 404 in handlers

titles, err := orm.QueryOf[models.Post](db).Pluck[string](ctx, "title")

for post, err := range orm.QueryOf[models.Post](db).Each(ctx) { // streams rows
	if err != nil {
		return err
	}
	index(post)
}

page, err := orm.QueryOf[models.Post](db).Latest().Paginate(ctx, c.QueryInt("page", 1), 15)
// page.Items, page.Total, page.Page, page.PerPage, page.LastPage; JSON: {"data": [...], "total": …}

Reusable conditions are plain functions:

Go
func Published(q orm.Query[models.Post]) orm.Query[models.Post] { return q.Where("published", true) }

orm.QueryOf[models.Post](db).Scope(Published).Get(ctx)

Row locks#

Inside a transaction, lock the rows you read so concurrent requests cannot change them before you write:

Go
err := db.Tx(ctx, func(tx *database.Tx) error {
	n, err := orm.QueryOf[models.Number](tx).
		Where("raffle_id", raffleID).Where("value", value).
		LockForUpdate(). // SELECT … FOR UPDATE
		First(ctx)
	if err != nil {
		return err
	}
	n.Status = "reserved"
	return orm.Update(ctx, tx, &n)
})
BuilderPostgreSQLMySQL / MariaDBSQLite
LockForUpdate()FOR UPDATEFOR UPDATEno-op
ForShare()FOR SHARELOCK IN SHARE MODEno-op
.SkipLocked() / .NoWait() after a lockSKIP LOCKED / NOWAITsame (with ForShare, MySQL 8 only: FOR SHARE …)no-op (NoWait never fails)
  • A locking query on db returns orm.ErrLockOutsideTx: without a transaction the lock ends with the statement. This holds on SQLite too, so code stays portable.
  • SQLite has no row locks; its write transactions (BEGIN IMMEDIATE) already lock the whole database, so the lock clauses are dropped.
  • Count and Exists ignore locks (PostgreSQL rejects them on aggregates), and eager-loaded relations are not locked.

Hooks#

Models can implement BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate and BeforeDelete (each func(ctx context.Context) error). An error aborts the write.

Go
func (p *Post) BeforeCreate(ctx context.Context) error {
	p.Title = strings.TrimSpace(p.Title)
	return nil
}

Timestamps use orm.WithClock(ctx, now) when present, so tests can fix the time.