Skip to content
devituz edited this page Jun 3, 2026 · 1 revision

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.

Defining a model

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.

Struct tags

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)

Overriding the table name

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 (Userusers, PostCommentpost_comments).

Pinning a model to a specific connection

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.

Querying — orm.Query[T]

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.

Pluck a single column

emails, err := orm.Pluck[User, string](
    ctx,
    orm.Query[User](conn).Where("is_admin", "=", true),
    "email",
)
// emails is []string

The 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.

Aggregations

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).

Operators

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.

Nested groups

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.

Saving — orm.Save

orm.Save(ctx, conn, &model) is the single entry point for inserts and updates:

  • inserts when model.ID == 0
  • updates otherwise
  • populates CreatedAt / UpdatedAt automatically (in the connection's location — see Configuration)
  • fires the BeforeSave / BeforeCreate / AfterCreate / BeforeUpdate / AfterUpdate / AfterSave hooks 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)   // UPDATE

orm.Delete(ctx, conn, &model) removes a row by its primary key (and fires BeforeDelete / AfterDelete).

Hooks

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.

Casts

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.

Transactions

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.

Savepoints

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.

Raw access

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).

Relations

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.

See also

Clone this wiki locally