-
Notifications
You must be signed in to change notification settings - Fork 0
ORM
orm/ is an ActiveRecord-style layer built on top of the
Migrations-and-Schema DSL and the underlying
database/sql. The mental model maps closely to Eloquent: models are
plain structs that embed orm.Model, queries flow through a generic
orm.Query[T] builder, and a single orm.Save does insert-or-update
based on the primary-key value.
Generics-based. No code generation. The builder type-checks at compile time so
orm.Query[User](conn).First(ctx)returns a*User— not an interface you'd need to assert.
import "github.com/devituz/lagodev/orm"
type User struct {
orm.Model // ID, CreatedAt, UpdatedAt
Name string
Email string `column:"email" orm:"unique"`
IsAdmin bool `column:"is_admin"`
Meta any `column:"meta" orm:"cast:json"`
}orm.Model is the canonical timestamp + primary-key embed.
orm.Timestamps is an alias kept for compatibility with older code.
| Tag | Purpose |
|---|---|
column:"name" |
Map a field to a specific column (default: snake_case) |
column:"-" |
Skip the field entirely |
orm:"primary" |
Mark a field as primary key (default: ID) |
orm:"autoincrement" |
Auto-incrementing integer primary key |
orm:"nullable" |
Allow NULL |
orm:"hidden" |
Hide from ToMap() serialization |
orm:"fillable" |
Allow mass-assignment |
orm:"unique" |
Document a unique constraint |
orm:"cast:NAME" |
Apply a registered cast (json, bool, int, date, …) |
orm:"default:VALUE" |
Document a default (for schema generation) |
func (User) TableName() string { return "app_users" }Implement TableName() to opt out of the inflector. Otherwise lagodev
pluralises the struct name and snake-cases it (User → users,
PostComment → post_comments).
func (Audit) ConnectionName() string { return "audit-log" }Useful for multi-tenant or multi-database apps. The query builder will
resolve this against the global database.Manager if you pass it the
default connection but the model wants a different name.
orm.Query[T](conn) returns a typed *Builder[T]. Every chained
method returns the same *Builder[T], so the call chain stays
fluent.
// Simple list
var users []User
err := orm.Query[User](conn).Get(ctx, &users)
// Filters + ordering + limit
var users []User
err := orm.Query[User](conn).
Where("is_admin", "=", true).
Where("created_at", ">", "2026-01-01").
OrderBy("id", "desc").
Limit(20).
Get(ctx, &users)
// First (returns orm.ErrNotFound when missing)
ada, err := orm.Query[User](conn).
Where("email", "=", "ada@example.com").
First(ctx)
// Find by PK
u, err := orm.Query[User](conn).Find(ctx, 42)First and Find both return (*T, error). orm.ErrNotFound is the
standard sentinel — the Web-Framework auto-maps it to 404.
emails, err := orm.Pluck[User, string](
ctx,
orm.Query[User](conn).Where("is_admin", "=", true),
"email",
)
// emails is []stringThe two type parameters are [T any, V any] — T is the model, V
is the column's Go type. The function reads only the named column, so
it's much cheaper than a full Get(ctx, &users) followed by a slice
comprehension.
n, err := orm.Query[User](conn).Count(ctx)
exists, err := orm.Query[User](conn).Where("email", "=", e).Exists(ctx)
total, err := orm.Query[User](conn).QB().Sum(ctx, "balance")
avg, err := orm.Query[User](conn).QB().Avg(ctx, "score")QB() drops down to the underlying query.Builder for operations
the typed wrapper doesn't directly expose (sum/avg/min/max, raw
SQL).
Where("col", op, value) accepts =, <>, <, <=, >, >=,
like, ilike. Use the dedicated methods for everything else:
.WhereIn("id", []int{1, 2, 3})
.WhereNotIn("status", []string{"banned", "deleted"})
.WhereNull("verified_at")
.WhereNotNull("verified_at")
.WhereBetween("age", 18, 65)
.WhereRaw("LOWER(name) = LOWER(?)", "Ada")WhereIn is overloaded for the common slice types ([]int, []int64,
[]uint64, []string, []any) so you rarely need to convert.
import "github.com/devituz/lagodev/query"
orm.Query[User](conn).
Where("active", "=", true).
Where(func(q *query.Builder) {
q.Where("role", "=", "admin").OrWhere("role", "=", "owner")
}).
Get(ctx, &out)Produces WHERE active = ? AND (role = ? OR role = ?). Nested groups
compose to arbitrary depth — pass another func(q *query.Builder)
inside one.
orm.Save(ctx, conn, &model) is the single entry point for inserts
and updates:
- inserts when
model.ID == 0 - updates otherwise
- populates
CreatedAt/UpdatedAtautomatically (in the connection's location — see Configuration) - fires the
BeforeSave/BeforeCreate/AfterCreate/BeforeUpdate/AfterUpdate/AfterSavehooks in the right order
u := &User{Name: "Ada", Email: "ada@example.com"}
_ = orm.Save(ctx, conn, u) // INSERT, u.ID now non-zero
u.Name = "Augusta"
_ = orm.Save(ctx, conn, u) // UPDATEorm.Delete(ctx, conn, &model) removes a row by its primary key (and
fires BeforeDelete / AfterDelete).
Implement any subset of the hook interfaces on your model. The framework looks them up via type assertion at call time — there's no global registry.
import (
"strings"
"github.com/devituz/lagodev/orm"
)
type User struct {
orm.Model
Email string
}
func (u *User) BeforeCreate(*orm.HookContext) error {
u.Email = strings.ToLower(u.Email)
return nil
}
func (u *User) AfterCreate(ctx *orm.HookContext) error {
return notifyWelcomeEmail(ctx.Ctx, u)
}Available hooks:
-
BeforeCreate,AfterCreate -
BeforeUpdate,AfterUpdate -
BeforeSave,AfterSave -
BeforeDelete,AfterDelete AfterFind
A returned error aborts the operation (for Before* hooks) and is
propagated up the call stack so the handler / caller can see why the
save / delete failed.
orm:"cast:NAME" runs a casts.Cast on the value when reading from
the database and writing back. Built-in casts:
| Cast | Storage | Go type |
|---|---|---|
json |
TEXT / JSON | any (via encoding/json) |
jsonb |
JSONB / TEXT | any (PG-native) |
bool / boolean
|
INTEGER / BOOLEAN | bool |
int / integer
|
TEXT | int |
date |
TEXT / DATE | time.Time |
Register custom casts:
import "github.com/devituz/lagodev/casts"
type encryptedCast struct{ key []byte }
func (c encryptedCast) From(raw any) (any, error) { /* decrypt */ }
func (c encryptedCast) To(value any) (any, error) { /* encrypt */ }
func init() {
casts.Register("encrypted", encryptedCast{key: getKey()})
}Then mark the field with orm:"cast:encrypted" and the encrypt /
decrypt happens transparently in orm.Save / Get / Find.
err := conn.Transaction(ctx, func(tx *database.Tx) error {
if err := orm.Query[Account](conn).WithTx(tx).
Where("id", "=", from).
QB().Update(ctx, map[string]any{"balance": -100}); err != nil {
return err
}
return orm.Query[Account](conn).WithTx(tx).
Where("id", "=", to).
QB().Update(ctx, map[string]any{"balance": +100})
})The closure receives a *Tx that satisfies database.Executor — pass
it to WithTx() on a query builder, or call its methods directly.
Roll-back happens automatically if the closure returns non-nil.
conn.Transaction(ctx, func(tx *database.Tx) error {
_ = tx.Savepoint(ctx, "sp1")
if err := dodgyOperation(tx); err != nil {
_ = tx.RollbackTo(ctx, "sp1")
}
return nil
})Savepoints let you roll back a portion of work without aborting the whole transaction — useful for "try, fall back" flows.
When the builder isn't enough, drop to *sql.DB:
rows, err := conn.QueryContext(ctx,
"SELECT id, complex_func($1) FROM x WHERE y = $2", filter, y)conn.ExecContext, conn.QueryRowContext, conn.PrepareContext
round out the API. SQL logging picks them up automatically when
DB_LOG_QUERIES=true (see Configuration).
The relations/ package complements the ORM with HasOne,
HasMany, BelongsTo, BelongsToMany, MorphOne, MorphMany:
import "github.com/devituz/lagodev/relations"
// Single parent, many children — issues ONE query for the children:
var posts []Post
err := relations.HasManyOf(conn, &user, &posts, "user_id").Load(ctx)
// Batch of parents — eager loading without N+1:
var users []User
_ = orm.Query[User](conn).Get(ctx, &users)
err := relations.HasManyOf(conn, &users, /* dst-callback */, "user_id").LoadMany(ctx)LoadMany issues a single WHERE foreign_key IN (...) query and
distributes results by callback — the canonical N+1 fix.
BelongsToMany joins through a pivot table; MorphMany /
MorphOne filter on a <name>_type discriminator column for
polymorphic relations.
- Migrations-and-Schema — the DSL that produces the tables these queries run against.
- Factories-and-Seeders — generating realistic data for tests and dev.
- Architecture — how the layers fit together; the reflection cache, the executor abstraction.
lagodev on GitHub · MIT-licensed — see LICENSE.