rio

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: MIT Imports: 19 Imported by: 0

README

rio

Go Reference Go Release Test License

The zero-surprise ORM for Go. Generic, connection-free query values; writes that do exactly what they say; relations that fail loudly instead of lazily; and the speed to have nothing to hide — faster than GORM everywhere, within arm's reach of hand-written database/sql.

rio's core has zero dependencies. Drivers are separate modules: go-rio/postgres (pgx), go-rio/mysql (go-sql-driver), go-rio/sqlite (modernc, pure Go).

db, _ := postgres.Open(dsn)

users, err := rio.From[User]().
    Where("age > ?", 18).
    OrderBy("created_at DESC").Limit(10).
    With("Posts", rio.RelWhere("published = ?", true)).
    All(ctx, db)

Why rio

Twenty years of ORM evolution — Rails, Laravel, Django, Hibernate, SQLAlchemy, EF Core, Ecto, Prisma — converged on one lesson: implicit magic loses to explicit declaration. rio starts where they ended up:

  • Queries are immutable values. Builders carry no connection and no state; deriving from a shared base — concurrently, at package level — can never cross-contaminate (the foot-gun GORM documents as "condition pollution" is structurally impossible).
  • Zero values are real values. Update writes what you give it, including 0, false, and "" — never silently skipping fields (GORM issue #6860 is a design decision here, not a trap). Partial updates are an explicit column whitelist; DB defaults are an explicit omitzero tag.
  • No hidden queries, ever. Relations are typed containers (rio.HasMany[Post]) that know whether they were loaded. Accessing an unloaded relation panics with instructions instead of returning silently empty data — and lazy loading, the one source of invisible N+1, does not exist.
  • Errors behave like Go. ErrNotFound wraps sql.ErrNoRows (both errors.Is checks work), unique violations translate to rio.ErrDuplicateKey with the driver error intact in the chain, and nothing is ever logged for you.
  • Set-based writes refuse to run without a WHERE unless you call AllRows() — on every dialect.
  • SQL you can predict. One builder call, one SQL fragment; ? placeholders everywhere (with IN (?) slice expansion); the escape hatch rio.Raw[T] shares the same scanner, hooks, and transactions.

Install

go get github.com/go-rio/rio
go get github.com/go-rio/postgres   # or go-rio/mysql, go-rio/sqlite

Quickstart

package main

import (
    "context"
    "time"

    "github.com/go-rio/rio"
    "github.com/go-rio/sqlite"
)

type User struct {
    ID        int64
    Email     string
    Age       int
    Bio       *string    // pointer = nullable
    Version   int64      `rio:",version"`    // opt-in optimistic locking
    DeletedAt *time.Time `rio:",softdelete"` // opt-in soft delete
    CreatedAt time.Time  // maintained automatically
    UpdatedAt time.Time  // maintained on every write path

    Posts rio.HasMany[Post]
}

type Post struct {
    ID     int64
    UserID int64
    Title  string

    Author rio.BelongsTo[User] `rio:",fk:user_id"`
}

func main() {
    ctx := context.Background()
    db, _ := sqlite.Open("file:app.db")

    u := &User{Email: "alice@example.com", Age: 30}
    _ = rio.Insert(ctx, db, u) // u.ID, timestamps, version filled in

    u.Age = 31
    _ = rio.Update(ctx, db, u)            // full row, version-checked
    _ = rio.Update(ctx, db, u, "age")     // just age (+updated_at)

    user, _ := rio.Find[User](ctx, db, u.ID)
    users, _ := rio.From[User]().
        Where("age BETWEEN ? AND ?", 18, 65).
        With("Posts").
        All(ctx, db)

    _ = db.Tx(ctx, func(tx *rio.Tx) error { // nested Tx = savepoints
        return rio.Insert(ctx, tx, &Post{UserID: u.ID, Title: "hello"})
    })

    _, _ = user, users
}

Schema migrations are deliberately out of scope — rio pairs with go-rio/migrate, where migrations are Go code compiled into your binary.

Model mapping

Declaration Meaning
ID int64 primary key + auto-increment, by convention; a rename or other role-neutral tag keeps it — an explicit ,pk anywhere in the model opts out
rio:"col_name" rename the column (default: snake_case, UserIDuser_id)
rio:",pk" primary key (tag several fields for composite keys)
rio:",noautoincr" integer single PK that rio must not treat as auto-increment
rio:",version" optimistic locking: UPDATE … SET version = version+1 WHERE … AND version = ?; a lost race returns ErrStaleObject
rio:",softdelete" on time.Time/*time.Time: Delete becomes an UPDATE, default queries filter, WithTrashed/OnlyTrashed/ForceDelete opt out
rio:",json" (de)serialize the field as JSON
rio:",countof:Posts" int64 target that WithCount("Posts") fills with the related row count (HasMany/ManyToMany)
rio:",omitzero" skip the column when zero so DB defaults apply (and RETURNING fills it back); a single-row Upsert conflict then leaves the existing value untouched
rio:"-" not a column
CreatedAt, UpdatedAt maintained automatically when present (rio:",nostamp" opts out)
TableName() string override the pluralized table name (Userusers, Personpeople)

Relations: rio.HasMany[T], rio.HasOne[T], rio.BelongsTo[T], rio.ManyToMany[T] — with fk:, ref:, and join: tag overrides. With takes the Go field name (With("Posts")), while every column API (Update whitelists, rio.Set, OnConflict, DoUpdate) takes database column names — relations are Go-side concepts, columns are SQL-side. Preloading always uses per-relation WHERE … IN split queries (the strategy ActiveRecord, Eloquent, Ecto, and SQLAlchemy converged on): no cartesian explosion, pagination stays correct, identical behavior on all three dialects. Paths nest: With("Posts.Comments").

Semantics that refuse to surprise

Operation Behavior
First/Find/Sole miss rio.ErrNotFound (wraps sql.ErrNoRows), never logged
All miss empty slice, nil error
Sole with 2+ rows rio.ErrMultipleRows
version conflict rio.ErrStaleObject
Update/Delete matching no row (no version column) rio.ErrNotFound
UpdateAll/DeleteAll without WHERE rio.ErrMissingWhere
unique / FK violation rio.ErrDuplicateKey / rio.ErrForeignKeyViolated
NULL into a non-pointer field error naming the column — not a silent zero
MySQL Insert fills the auto-increment ID; rio never issues a hidden second SELECT
Upsert onto a soft-deleted row DoUpdate revives it — a successful DoUpdate upsert is never invisible (rio.KeepTrashed() opts out); DoNothing never revives
times stored UTC, microsecond precision — insert-then-reload compares Equal

Upsert ships complete: conflict target, update whitelist, RETURNING backfill, timestamp maintenance:

err := rio.Upsert(ctx, db, &user, rio.OnConflict("email"), rio.DoUpdate("name"))

On MySQL the DoUpdate branch uses the 8.0.19+ row-alias syntax (VALUES() is deprecated); MySQL before 8.0.19 and MariaDB support DoNothing only.

Batch writes chunk automatically to each dialect's bind limit; backfill promises only what dialects can keep (documented per dialect on InsertAll).

Compiled queries

Most application SQL is fixed in shape. Entity CRUD is cached invisibly; hand-built queries compile once, regexp.MustCompile style:

var adults = rio.MustCompile[User](
    rio.From[User]().Where("age > ?").OrderBy("created_at DESC").Limit(10),
)

users, err := adults.All(ctx, db, 18) // binds parameters only

Structural problems panic at startup; SQL renders lazily per dialect and is cached. rio.WithStmtCache() additionally caches prepared statements (off by default — PgBouncer transaction pooling breaks server-side prepared statements; enable it only when talking to the database directly).

Column constants, without a tool chain

rio.WriteColumns generates typo-proof column references from rio's own mapping plans — no source parsing, no binary to install, and the output can never drift from runtime behavior:

//go:generate sh -c "go run ./internal/gencols > cols_gen.go"
// internal/gencols: rio.WriteColumns(os.Stdout, "models", User{}, Post{})

users, err := rio.From[User]().Where(UserCols.Email+" = ?", e).All(ctx, db)

Pagination that scales

Offset pagination degrades linearly; keyset pagination is a WHERE clause, and rio deliberately ships the pattern instead of an API:

// Page 1: rio.From[Post]().OrderBy("created_at DESC, id DESC").Limit(20)
// Next page, keyed by the last row you handed out:
next, err := rio.From[Post]().
    Where("(created_at, id) < (?, ?)", last.CreatedAt, last.ID).
    OrderBy("created_at DESC, id DESC").Limit(20).
    All(ctx, db)

Row-value comparison works on PostgreSQL, MySQL, and SQLite 3.15+. For result sets too large to page at all, stream with Rows.

Observability

db, _ := postgres.Open(dsn, rio.WithQueryHook(myHook))

QueryHook sees every statement — op, model, SQL, args (redactable with rio.WithoutArgs()), duration, rows affected — and cannot alter any of them. There are no model hooks: side effects belong in visible application code, invariants in database constraints.

Performance

Benchmarked against GORM and hand-written database/sql on the same pure-Go SQLite driver (Apple M4; rio/bench, reproducible with go test -bench . -benchmem ./...):

Scenario rio hand-written GORM
read one (compiled) 8.8 µs 7.8 µs 11.6 µs
read one (Find) 8.8 µs 7.8 µs 11.6 µs
read 100 rows 119 µs 120 µs 158 µs
insert 11.0 µs 7.8 µs 19.9 µs
update 6.7 µs 6.2 µs 15.5 µs
insert 100 (batch) 262 µs 294 µs

Reads beat GORM by ~25% and land within ~12% of hand-written scanning (scanning 100 rows is a dead heat); inserts are ~45% faster than GORM, updates ~57% faster and within 7% of hand-written SQL. Batch inserts are driver-dominated — both sides send one multi-VALUES statement — so the ~11% edge there is mostly allocation discipline. The techniques: per-type mapping plans, per-grammar SQL caches, offset-based scanning with a reflect fallback, and []byte-appended rendering — no code generation anywhere.

What rio deliberately does not have

No model hooks. No implicit lazy loading. No dirty tracking, unit of work, or identity map. No AutoMigrate. No second-level cache. No association auto-writes. No client-side evaluation. Each refusal is a researched decision with the receipts in DESIGN.md.

License

rio is released under the MIT License, © 2026-now TreeNewBee.

Documentation

Overview

Package rio is a zero-surprise ORM: GORM's ergonomics, sqlc's honesty, type safety through generics, and the weight of none of them.

Structs are pure data and every database operation is a visible call — there is no user.Save(), no session, no change tracking, and rio never issues a query you didn't ask for. Queries are immutable, connection-free builder values; nothing touches the database until an execution method takes the (ctx, db) pair:

users, err := rio.From[User]().
	Where("age > ?", 18).
	OrderBy("created_at DESC").
	Limit(10).
	With("Posts").
	All(ctx, db)

db is any rio.Queryer — the *rio.DB returned by a driver module (go-rio/postgres, go-rio/mysql, go-rio/sqlite) or by rio.New, and equally the *rio.Tx inside DB.Tx, so data-access code runs unchanged in and out of transactions (nested Tx calls become savepoints). Placeholders are always ?, rebound per dialect; IN (?) expands slices.

Reading starts at From, Find, and the builder's All, First, Sole, Count, and Exists. Writes are immediate and explicit: Insert, InsertAll, Update (full-column by default, whitelist when partial), Delete, Upsert, and the set-based UpdateAll/DeleteAll, which refuse to run without a WHERE. Relations declared as typed containers (HasMany, HasOne, BelongsTo, ManyToMany) load only on request — With preloads, WithCount aggregates, Attach/Detach/SyncRelation write join rows — and panic loudly when accessed unloaded instead of returning silently empty data.

Escape hatches and hot paths: Raw scans any SQL into any shape with the same scanner and transactions, Exec runs bare statements, MustCompile renders a fixed-shape query once per dialect, and WithStmtCache opts into prepared-statement reuse. QueryHook observes every statement (it cannot rewrite them); sentinel errors (ErrNotFound, ErrDuplicateKey, …) answer errors.Is with the driver's error kept in the chain.

The full design rationale — including the list of features rio refuses to have — lives in DESIGN.md; schema migrations live in go-rio/migrate.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound is returned by First, Find, Sole and friends when no row
	// matches. It wraps sql.ErrNoRows, so both
	// errors.Is(err, rio.ErrNotFound) and errors.Is(err, sql.ErrNoRows) hold.
	ErrNotFound = fmt.Errorf("rio: record not found (%w)", sql.ErrNoRows)

	// ErrMultipleRows is returned by Sole when more than one row matches.
	ErrMultipleRows = errors.New("rio: expected exactly one row, found more")

	// ErrStaleObject is returned by Update and Delete when the row's version
	// column no longer matches, meaning another writer got there first —
	// or the row is gone entirely.
	ErrStaleObject = errors.New("rio: stale object: version conflict or row deleted")

	// ErrMissingWhere guards set-based writes: UpdateAll and DeleteAll refuse
	// to touch a whole table unless AllRows was called explicitly.
	ErrMissingWhere = errors.New("rio: UpdateAll/DeleteAll without conditions; call AllRows() to affect the whole table")

	// ErrDuplicateKey reports a unique constraint violation, translated from
	// the driver error (which remains in the chain for errors.As).
	ErrDuplicateKey = errors.New("rio: duplicate key violates unique constraint")

	// ErrForeignKeyViolated reports a foreign key constraint violation.
	ErrForeignKeyViolated = errors.New("rio: foreign key constraint violated")

	// ErrNoPrimaryKey is returned when Find, Update, or Delete is called on
	// a model that declares no primary key.
	ErrNoPrimaryKey = errors.New("rio: model has no primary key")
)

Sentinel errors. Every error returned by rio can be inspected with errors.Is; none of them are ever logged by rio itself.

Functions

func Attach added in v0.2.0

func Attach[T any, K any](ctx context.Context, db Queryer, row *T, relation string, ids ...K) error

Attach links rows to a ManyToMany relation by inserting join-table rows — idempotently: existing links are left alone (bare ON CONFLICT DO NOTHING; a no-op assignment on MySQL), assuming the join table's standard composite unique key. Nothing here is implicit: this is the explicit inverse of hand-writing the join-table INSERT. Typed id slices spread directly: Attach(ctx, db, &u, "Tags", tagIDs...). Attaching zero ids is a no-op (spell the id type as Attach[User, int64] when nothing infers it).

Large id sets are chunked to the dialect's bind-parameter ceiling, like InsertAll. Outside a transaction each chunk commits independently — a failure leaves earlier chunks linked; wrap the call in db.Tx for atomicity, or simply retry: idempotency makes a rerun converge.

func Delete

func Delete[T any](ctx context.Context, db Queryer, row *T) error

Delete removes a row by primary key. Models with a softdelete column get an UPDATE setting the deletion timestamp instead; ForceDelete really deletes. The version column, when present, is checked like Update.

func Detach added in v0.2.0

func Detach[T any, K any](ctx context.Context, db Queryer, row *T, relation string, ids ...K) error

Detach unlinks rows from a ManyToMany relation by deleting join-table rows. The ids are required — detaching "everything" must be written as an explicit set-based delete on the join table, never implied by an empty call. Typed id slices spread directly: Detach(ctx, db, &u, "Tags", ids...).

Large id sets are chunked to the dialect's bind-parameter ceiling. Outside a transaction each chunk commits independently; deleting is naturally idempotent, so a retry after a partial failure converges.

func Exec

func Exec(ctx context.Context, db Queryer, sqlText string, args ...any) (sql.Result, error)

Exec runs a hand-written statement through the shared pipeline and returns the driver result.

func Find

func Find[T any](ctx context.Context, db Queryer, key ...any) (*T, error)

Find fetches a row by primary key. Composite keys pass every part in struct-field declaration order. The statement shape is fixed, so the SQL is rendered once per grammar and cached — Find is the hottest read there is.

func ForceDelete

func ForceDelete[T any](ctx context.Context, db Queryer, row *T) error

ForceDelete removes the row with a real DELETE even when the model soft deletes.

func Insert

func Insert[T any](ctx context.Context, db Queryer, row *T) error

Insert writes one row and fills in what the database generated: the columns the INSERT skipped — the zero auto-increment PK and zero omitzero columns — via RETURNING on PostgreSQL/SQLite, the auto-increment ID on MySQL. Never a hidden second SELECT; columns rewritten by triggers are not read back. Zero values are written as-is; columns tagged omitzero are skipped when zero so DB defaults apply, and a zero auto-increment PK is skipped implicitly. CreatedAt/UpdatedAt are set to the clock when zero; a zero version column starts at 1. Time fields are normalized in place as they bind (UTC, monotonic reading stripped, microsecond precision), so after Insert the struct holds exactly what the database stores and an insert-then-reload comparison stays Equal.

func InsertAll

func InsertAll[T any](ctx context.Context, db Queryer, rows []T) error

InsertAll writes rows in multi-VALUES statements, auto-chunked to the dialect's bind-parameter ceiling. Outside a transaction each chunk commits independently — a failure in chunk N leaves earlier chunks written; wrap the call in db.Tx for atomicity (rio adds no hidden transaction).

Backfill promises only what the dialects can keep: auto-increment PKs on PostgreSQL (RETURNING, positional) and SQLite (RETURNING sorted by PK — its output order is documented as undefined), nothing on MySQL (its default interleaved autoinc mode makes first-ID+i arithmetic wrong). Timestamps, version, and every explicit value are already in your slice. omitzero does not apply on the batch path: one statement, one column list.

func Pluck added in v0.2.0

func Pluck[V any, T any](ctx context.Context, db Queryer, q Query[T], column string) ([]V, error)

Pluck extracts a single column under the query's conditions: emails, err := rio.Pluck[string](ctx, db, q, "email"). The column must be one of T's mapped columns — expressions go through Raw.

func Restore

func Restore[T any](ctx context.Context, db Queryer, row *T) error

Restore clears the deletion timestamp of one soft-deleted row by primary key — the entity-form inverse of Delete. The version column, when present, is checked and bumped like any other write.

func SyncRelation added in v0.3.0

func SyncRelation[T any, K any](ctx context.Context, db Queryer, row *T, relation string, ids []K) error

SyncRelation makes the ManyToMany relation match ids exactly, inside one transaction (or savepoint, when db already is one): links not in ids are removed, missing ones are added idempotently. An empty ids slice explicitly empties the relation — unlike Detach, where "no ids" is a refused footgun, Sync's whole meaning is "converge on this set".

Sync reads the existing join rows and computes the difference in memory: removals and additions are then chunked to the dialect's bind limit — a NOT IN over the full id set would break at the limit, and chunking a NOT IN would delete every other chunk's ids. Rows already in sync are not touched.

Concurrent SyncRelation calls on the same owner serialize on a row lock (SELECT ... FOR UPDATE on the owner; SQLite's single-writer model serializes anyway) — without it, two syncs interleaving their DELETE and INSERT under READ COMMITTED would converge on the union of both sets, which is exactly not what "sync" promises. The join-row read locks too, so it sees committed state rather than an older snapshot under REPEATABLE READ.

func Update

func Update[T any](ctx context.Context, db Queryer, row *T, cols ...string) error

Update writes a row by primary key. With no column list it writes every column — honestly, zero values included (partial scans beware: unscanned fields overwrite with zeros). With a list it updates exactly those columns plus UpdatedAt. A version column is checked and incremented atomically; losing the race returns ErrStaleObject.

func Upsert

func Upsert[T any](ctx context.Context, db Queryer, row *T, opts ...UpsertOption) error

Upsert inserts the row or updates it on unique-key conflict, in one statement. All four elements ship together: conflict target, update whitelist, RETURNING backfill (PG/SQLite; MySQL fills the auto-increment ID only on the insert path), and timestamp maintenance.

Version backfill on the update path is RETURNING-only. Timestamps and every bound column already match the database on all three dialects — rio wrote the in-memory values into the statement — but a version column is incremented server-side (version = version + 1) from the row's *current* database value, which rio cannot know without RETURNING. On PostgreSQL and SQLite it is read back; on MySQL (no RETURNING) the in-memory version stays at what you set while the database moved on, and rio will not issue a hidden second SELECT to reconcile it. A later optimistic-locked Update would then see ErrStaleObject: reload the row after a MySQL upsert when you keep updating through the same struct, or run version-tracking upserts on PostgreSQL/SQLite.

Soft-delete invariant: a successful DoUpdate upsert leaves the row visible — deleted_at is cleared on the inserted values and conflict update unless KeepTrashed is given. The explicit softdelete tag opted the model into deletion semantics; resurrect-on-upsert is its consistent extension (the alternative is Eloquent's famous trap: "upsert succeeded but the row is invisible").

UpdatedAt is reset to the clock on every non-DoNothing upsert, even when nonzero: the conflict branch applies the would-be inserted row's stamp, so that stamp must be this call's now — the same unconditional rule as entity Update. DoNothing keeps Insert's fill-only-when-zero rule.

omitzero: a zero omitzero column is skipped from the INSERT column list, and the default conflict update set skips it too — on conflict the existing value survives (an uninserted column's excluded value is the DB DEFAULT, not something rio should write over data). Naming such a column in DoUpdate errors. UpsertAll differs: the batch path binds every column, so batch zeros are written on both branches.

MySQL version floor: the DoUpdate branch references the would-be inserted row through a row alias — 8.0.19+ syntax (the VALUES() function it replaces is deprecated). MySQL before 8.0.19 and MariaDB reject it; DoNothing renders alias-free and runs everywhere.

func UpsertAll

func UpsertAll[T any](ctx context.Context, db Queryer, rows []T, opts ...UpsertOption) error

UpsertAll upserts rows in multi-VALUES statements with the same conflict clause as Upsert. It never backfills: DoNothing shrinks the returned row set, so positional matching would silently misalign (the batch-backfill killer). Reload rows you need generated values for.

omitzero does not apply on the batch path (one statement, one column list): unlike Upsert, zero omitzero columns are inserted and stay in the default conflict update set, so batch zeros overwrite on conflict. UpdatedAt is reset to the clock on every non-DoNothing row, like Upsert. The MySQL DoUpdate version floor (8.0.19+, no MariaDB) also matches Upsert.

func WriteColumns added in v0.3.0

func WriteColumns(w io.Writer, pkgName string, models ...any) error

WriteColumns generates Go source declaring column-name constants for the given models — typo-proof, IDE-completable column references for query strings:

users, err := rio.From[User]().Where(UserCols.Email+" = ?", e).All(ctx, db)

The generator runs on rio's own mapping plans, so the output can never drift from runtime behavior; there is no source parsing and no build-time tool chain. Wire it up with go:generate and a three-line main:

//go:generate sh -c "go run ./internal/gencols > cols_gen.go"

// internal/gencols/main.go
func main() {
	if err := rio.WriteColumns(os.Stdout, "models", models.User{}, models.Post{}); err != nil {
		log.Fatal(err)
	}
}

Each model yields a <Name>Table constant (the convention- or TableName-derived name; a WithTableNamer handle may rename it at runtime) and a <Name>Cols struct value with one string field per mapped column.

Types

type BelongsTo

type BelongsTo[T any] struct {
	// contains filtered or unexported fields
}

BelongsTo holds the parent row referenced by a foreign key on this row. After preloading, a NULL foreign key yields the loaded-nil state — Row returns nil instead of panicking, preserving "With makes access safe".

func (BelongsTo[T]) Loaded

func (r BelongsTo[T]) Loaded() bool

Loaded reports whether the relation has been populated by With or Set.

func (BelongsTo[T]) MarshalJSON

func (r BelongsTo[T]) MarshalJSON() ([]byte, error)

MarshalJSON behaves like HasOne.MarshalJSON.

func (BelongsTo[T]) Row

func (r BelongsTo[T]) Row() *T

Row returns the loaded parent, or nil when the foreign key was NULL. It panics if the relation was never loaded.

func (*BelongsTo[T]) Set

func (r *BelongsTo[T]) Set(row *T)

Set marks the relation loaded. A nil row means "loaded, no parent".

func (*BelongsTo[T]) UnmarshalJSON

func (r *BelongsTo[T]) UnmarshalJSON(b []byte) error

UnmarshalJSON behaves like HasOne.UnmarshalJSON.

type Compiled

type Compiled[T any] struct {
	// contains filtered or unexported fields
}

Compiled is a query compiled once and executed many times, in the spirit of regexp.MustCompile: declare it at package level, bind parameters at the call site. Structural validation runs eagerly; SQL renders lazily per grammar (dialect + namer) and is cached, so the hot path only binds.

func Compile

func Compile[T any](q Query[T]) (*Compiled[T], error)

Compile is MustCompile returning an error.

func MustCompile

func MustCompile[T any](q Query[T]) *Compiled[T]

MustCompile compiles a connection-free query for reuse, panicking on structural problems — bad tags, unknown relation paths, mixed inline and exec-time parameters, or a ? placeholder in a clause with no argument channel (Join, GroupBy, OrderBy). Passing MustCompile does not mean execution cannot fail: dialect capabilities, exact placeholder arity, and driver errors surface at the execution point. Queries are either fully inline (every ? already has its argument — a frozen constant query) or fully exec-parameterized (no inline arguments; every ? binds at the call). Slice expansion inside IN (?) needs value shapes and is inline-only, on every execution point including Count and Exists.

Rendered SQL is cached per DB handle for the Compiled value's lifetime. Treat *DB as the long-lived object it is meant to be; churning through short-lived handles against package-level Compiled queries accumulates one cache entry per handle.

func (*Compiled[T]) All

func (c *Compiled[T]) All(ctx context.Context, db Queryer, args ...any) ([]T, error)

All executes the compiled query, binding args in placeholder order.

func (*Compiled[T]) Count

func (c *Compiled[T]) Count(ctx context.Context, db Queryer, args ...any) (int64, error)

Count runs the compiled conditions under SELECT count(*). Its shape differs from the cached row SELECT, so Count renders on every call — when a count sits on a hot path, measure it, and reach for Raw if rendering shows up.

func (*Compiled[T]) Exists

func (c *Compiled[T]) Exists(ctx context.Context, db Queryer, args ...any) (bool, error)

Exists reports whether any row matches. Like Count it renders per call — its shape differs from the cached row SELECT.

func (*Compiled[T]) First

func (c *Compiled[T]) First(ctx context.Context, db Queryer, args ...any) (*T, error)

First executes the compiled query and returns the first row or ErrNotFound. Compile with Limit(1) to avoid over-fetching.

func (*Compiled[T]) Rows added in v0.3.0

func (c *Compiled[T]) Rows(ctx context.Context, db Queryer, args ...any) iter.Seq2[T, error]

Rows streams the compiled query without materializing the result; see Query.Rows for semantics. Compiled queries carrying With/WithCount refuse to stream.

func (*Compiled[T]) Sole

func (c *Compiled[T]) Sole(ctx context.Context, db Queryer, args ...any) (*T, error)

Sole returns the single matching row, ErrNotFound, or ErrMultipleRows. Like First it runs the compiled SQL as-is: to distinguish one row from many it scans whatever the query returns, so compile with Limit(2) to keep it from materializing a large result set just to detect duplicates.

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB wraps a *sql.DB with a dialect. rio never replaces or tunes the connection pool — configure pooling on the *sql.DB you pass in.

func New

func New(db *sql.DB, dialect Dialect, opts ...Option) *DB

New wraps an existing *sql.DB. Driver modules (go-rio/postgres, go-rio/mysql, go-rio/sqlite) call this for you and add driver-specific error translation; use New directly when you bring your own driver.

func (*DB) Close

func (d *DB) Close() error

Close closes the prepared-statement cache (if enabled) and the underlying *sql.DB.

func (*DB) Tx

func (d *DB) Tx(ctx context.Context, fn func(tx *Tx) error) error

Tx runs fn in a transaction with default options.

func (*DB) TxWith

func (d *DB) TxWith(ctx context.Context, opts *sql.TxOptions, fn func(tx *Tx) error) (err error)

TxWith runs fn in a transaction with the given options (isolation level, read-only).

func (*DB) Unwrap

func (d *DB) Unwrap() *sql.DB

Unwrap returns the underlying *sql.DB for anything rio does not cover.

type Dialect

type Dialect interface {
	// contains filtered or unexported methods
}

Dialect identifies one of the built-in SQL dialects. The interface is deliberately opaque (all methods unexported): rio's renderers evolve together with the grammars, and a cross-module implementation would freeze them. Driver modules pick a built-in value; they never implement this.

var (
	Postgres Dialect = postgresDialect{}
	MySQL    Dialect = mysqlDialect{}
	SQLite   Dialect = sqliteDialect{}
)

Built-in dialects. Driver modules re-export the matching value from their Open/New constructors.

type Expr

type Expr string

Expr is a raw SQL fragment used as an UpdateAll value — the escape hatch for database-side arithmetic: rio.Set{"count": rio.Expr("count + 1")}. It is spliced into the statement verbatim; never build one from user input.

type HasMany

type HasMany[T any] struct {
	// contains filtered or unexported fields
}

HasMany holds the "child rows pointing at this row" side of a one-to-many relation. It is a container rather than a bare slice so that "not loaded" and "loaded, empty" are different states: rio never returns silently empty data and never lazy-loads. Structs containing relation containers are not comparable, and cmp.Diff panics on the containers' unexported state — pass cmpopts.IgnoreUnexported(rio.HasMany[Post]{}, ...) and compare relation contents through the exported accessors (Rows/Row) instead.

func (HasMany[T]) Loaded

func (r HasMany[T]) Loaded() bool

Loaded reports whether the relation has been populated by With or Set.

func (HasMany[T]) MarshalJSON

func (r HasMany[T]) MarshalJSON() ([]byte, error)

MarshalJSON encodes unloaded relations as null and loaded ones as arrays, so API payloads distinguish "not fetched" from "none".

func (HasMany[T]) Rows

func (r HasMany[T]) Rows() []T

Rows returns the loaded children. It panics if the relation was never loaded — accessing unloaded data is a programming error, not an empty result.

func (*HasMany[T]) Set

func (r *HasMany[T]) Set(rows []T)

Set marks the relation loaded with the given rows. Manual assembly (from a custom query or fixture) is a supported use.

func (*HasMany[T]) UnmarshalJSON

func (r *HasMany[T]) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts null (leaving the relation unloaded) or an array.

type HasOne

type HasOne[T any] struct {
	// contains filtered or unexported fields
}

HasOne holds the "single child row pointing at this row" side of a one-to-one relation.

func (HasOne[T]) Loaded

func (r HasOne[T]) Loaded() bool

Loaded reports whether the relation has been populated by With or Set.

func (HasOne[T]) MarshalJSON

func (r HasOne[T]) MarshalJSON() ([]byte, error)

MarshalJSON encodes unloaded as null; loaded-none also encodes as null.

func (HasOne[T]) Row

func (r HasOne[T]) Row() *T

Row returns the loaded child, or nil when the parent has none. It panics if the relation was never loaded.

func (*HasOne[T]) Set

func (r *HasOne[T]) Set(row *T)

Set marks the relation loaded. A nil row means "loaded, has none".

func (*HasOne[T]) UnmarshalJSON

func (r *HasOne[T]) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts null (leaving the relation unloaded) or an object.

type ManyToMany

type ManyToMany[T any] struct {
	// contains filtered or unexported fields
}

ManyToMany is HasMany across a join table.

func (ManyToMany[T]) Loaded

func (r ManyToMany[T]) Loaded() bool

Loaded reports whether the relation has been populated by With or Set.

func (ManyToMany[T]) MarshalJSON

func (r ManyToMany[T]) MarshalJSON() ([]byte, error)

MarshalJSON behaves like HasMany.MarshalJSON.

func (ManyToMany[T]) Rows

func (r ManyToMany[T]) Rows() []T

Rows returns the loaded rows, panicking when the relation was never loaded.

func (*ManyToMany[T]) Set

func (r *ManyToMany[T]) Set(rows []T)

Set marks the relation loaded with the given rows.

func (*ManyToMany[T]) UnmarshalJSON

func (r *ManyToMany[T]) UnmarshalJSON(b []byte) error

UnmarshalJSON behaves like HasMany.UnmarshalJSON.

type Option

type Option func(*config)

Option configures a DB handle at construction time.

func WithClock

func WithClock(now func() time.Time) Option

WithClock replaces the time source used for CreatedAt/UpdatedAt and soft deletes. Intended for tests.

func WithErrorTranslator

func WithErrorTranslator(f func(error) error) Option

WithErrorTranslator installs a driver-specific error translator, mapping driver errors to rio sentinels (ErrDuplicateKey, ErrForeignKeyViolated). The go-rio driver modules install one automatically; the translator runs before the dialect's SQLSTATE fallback. Returning nil means "not mine".

func WithQueryHook

func WithQueryHook(h QueryHook) Option

WithQueryHook installs an observability hook. Hooks see every statement rio executes — entity CRUD, builder queries, compiled queries, Raw, and transaction control — and cannot alter them.

func WithStmtCache

func WithStmtCache(capacity ...int) Option

WithStmtCache enables the prepared-statement cache on this DB handle.

Off by default: connection poolers in transaction/statement mode (PgBouncer et al.) break server-side prepared statements. Enable it only when rio talks to the database directly. Statements are cached per SQL text on the DB only — transactions bypass the cache — and the cache is LRU-bounded (cap configurable) because IN (?) expansion makes every slice length a distinct statement. On schema-change errors the entry is evicted and the error propagates; rio never retries on its own.

func WithTableNamer

func WithTableNamer(f func(structName string) string) Option

WithTableNamer overrides the convention-based struct-name→table-name derivation for this DB handle. Models implementing TableName() string still win. The namer is part of the DB's grammar identity: SQL caches are keyed by it, so two handles with different namers never share rendered SQL.

func WithoutArgs

func WithoutArgs() Option

WithoutArgs redacts bind arguments from QueryEvent before hooks see them.

type Query

type Query[T any] struct {
	// contains filtered or unexported fields
}

Query is an immutable, connection-free query description. Every builder method returns a derived copy; deriving several queries from one shared base — including concurrently, including package-level bases — never cross-contaminates. Queries hold no rendered SQL: rendering happens at the execution point against the handle's grammar (Compiled caches it).

func From

func From[T any]() Query[T]

From starts a query for T's table: rio.From[User]().Where(...).All(ctx, db).

func (Query[T]) All

func (q Query[T]) All(ctx context.Context, db Queryer) ([]T, error)

All runs the query and returns every matching row.

func (Query[T]) AllRows

func (q Query[T]) AllRows() Query[T]

AllRows is the explicit opt-in for UpdateAll/DeleteAll without conditions.

func (Query[T]) Count

func (q Query[T]) Count(ctx context.Context, db Queryer) (int64, error)

Count runs SELECT count(*) with the query's conditions. Combined with GroupBy or Having the intent (rows vs groups) is ambiguous, and a bare HAVING would filter the single implicit aggregate group and silently return 0 — both route through Raw instead.

func (Query[T]) CreateOrFirst

func (q Query[T]) CreateOrFirst(ctx context.Context, db Queryer, row *T) error

CreateOrFirst inserts row, and on unique-key conflict returns the existing row instead — the race-honest inverse of FirstOrCreate (INSERT first, so the unique constraint arbitrates instead of a racy SELECT).

func (Query[T]) DeleteAll

func (q Query[T]) DeleteAll(ctx context.Context, db Queryer) (int64, error)

DeleteAll deletes every matching row in one statement — as an UPDATE setting the deletion timestamp on soft-delete models (already-trashed rows are excluded by the default filter), a real DELETE otherwise. It refuses to run without conditions unless AllRows() was called.

func (Query[T]) Exists

func (q Query[T]) Exists(ctx context.Context, db Queryer) (bool, error)

Exists reports whether any row matches.

func (Query[T]) First

func (q Query[T]) First(ctx context.Context, db Queryer) (*T, error)

First returns the first matching row, or ErrNotFound. No implicit ORDER BY is added: like SQL itself, order is undefined unless you ask for one. Without a caller Limit, First fetches with LIMIT 1; an explicit Limit is respected — Limit(0).First matches no rows and returns ErrNotFound, just like All returning none.

func (Query[T]) FirstOrCreate

func (q Query[T]) FirstOrCreate(ctx context.Context, db Queryer, row *T) error

FirstOrCreate returns the first row matching the query, inserting row when none exists. SELECT-then-INSERT races: a concurrent creator makes the INSERT hit ErrDuplicateKey, and FirstOrCreate then re-reads. When even the re-read misses, a soft-deleted row is probably squatting on the unique key (WithTrashed reveals it) and the duplicate-key error is returned as-is.

func (Query[T]) ForUpdate

func (q Query[T]) ForUpdate() Query[T]

ForUpdate renders SELECT ... FOR UPDATE for read-modify-write inside a transaction. SQLite locks the whole database anyway; there it is a no-op.

func (Query[T]) ForceDeleteAll

func (q Query[T]) ForceDeleteAll(ctx context.Context, db Queryer) (int64, error)

ForceDeleteAll hard-deletes matching rows even on soft-delete models. Like the other set-based writes it refuses to run without conditions unless AllRows() was called, so emptying the recycle bin is the explicit OnlyTrashed().AllRows().ForceDeleteAll() (AllRows opts into the bulk write; OnlyTrashed scopes it to the trashed rows).

func (Query[T]) GroupBy

func (q Query[T]) GroupBy(expr string) Query[T]

GroupBy appends a GROUP BY term. Entity queries with GROUP BY are almost always projections — prefer Raw — but filtering EXISTS-style uses remain.

func (Query[T]) Having

func (q Query[T]) Having(expr string, args ...any) Query[T]

Having adds an AND-ed HAVING condition.

func (Query[T]) Join

func (q Query[T]) Join(clause string) Query[T]

Join appends a verbatim JOIN clause, for filtering through other tables: Join("INNER JOIN orgs ON orgs.id = users.org_id"). Projections across joins go through Raw — entity queries always select exactly T's columns.

func (Query[T]) Limit

func (q Query[T]) Limit(n int) Query[T]

Limit caps the result. The value is rendered into the SQL, not bound.

func (Query[T]) Offset

func (q Query[T]) Offset(n int) Query[T]

Offset skips n rows.

func (Query[T]) OnlyTrashed

func (q Query[T]) OnlyTrashed() Query[T]

OnlyTrashed selects only soft-deleted rows.

func (Query[T]) OrderBy

func (q Query[T]) OrderBy(expr string) Query[T]

OrderBy appends an ORDER BY term, verbatim SQL ("created_at DESC").

func (Query[T]) RestoreAll added in v0.4.0

func (q Query[T]) RestoreAll(ctx context.Context, db Queryer) (int64, error)

RestoreAll clears the deletion timestamp on every matching soft-deleted row — the set-based peer of the entity-form Restore, named like UpdateAll and DeleteAll. Conditions are required (or AllRows); combine with OnlyTrashed as needed.

func (Query[T]) Rows added in v0.2.0

func (q Query[T]) Rows(ctx context.Context, db Queryer) iter.Seq2[T, error]

Rows streams the result without materializing it, for result sets too large to hold: for u, err := range q.Rows(ctx, db). Iteration stops on the first error (yielded with a zero T) and closing happens automatically, including on early break. Preloading needs the full set and cannot stream — With/WithCount on a streamed query yields an error immediately.

func (Query[T]) Scope added in v0.3.0

func (q Query[T]) Scope(fns ...func(Query[T]) Query[T]) Query[T]

Scope applies reusable query functions in order, keeping the chain readable — a scope is just func(Query[T]) Query[T], no registry and no magic:

func Active(q rio.Query[User]) rio.Query[User] { return q.Where("active") }
users, err := rio.From[User]().Scope(Active, Recent).All(ctx, db)

func (Query[T]) Sole

func (q Query[T]) Sole(ctx context.Context, db Queryer) (*T, error)

Sole returns the single matching row; ErrNotFound when none match and ErrMultipleRows when more than one does. Without a caller Limit it probes with LIMIT 2; an explicit Limit is respected (like Compiled.Sole running the compiled SQL as-is), so Limit(1) cannot detect a second row.

func (Query[T]) UpdateAll

func (q Query[T]) UpdateAll(ctx context.Context, db Queryer, set Set) (int64, error)

UpdateAll updates every matching row in one statement, returning the affected count. It refuses to run without conditions unless AllRows() was called. UpdatedAt is maintained automatically (override by assigning it yourself). Set-based writes bypass the version column — like every bulk path, and documented as such: optimistic locking guards entity Update.

func (Query[T]) Where

func (q Query[T]) Where(expr string, args ...any) Query[T]

Where adds an AND-ed condition written in SQL with ? placeholders. Slice arguments expand inside IN (?).

func (Query[T]) WhereHas added in v0.2.0

func (q Query[T]) WhereHas(path string, opts ...RelOption) Query[T]

WhereHas keeps only rows whose relation at path has at least one matching row, rendered as EXISTS (...). Options constrain the related rows; nested paths ("Posts.Comments") nest the EXISTS. Related soft-delete models are filtered like preloading; RelWithTrashed applies to the leaf.

func (Query[T]) WhereHasNot added in v0.2.0

func (q Query[T]) WhereHasNot(path string, opts ...RelOption) Query[T]

WhereHasNot keeps only rows whose relation at path has no matching row — NOT EXISTS (...).

func (Query[T]) With

func (q Query[T]) With(path string, opts ...RelOption) Query[T]

With preloads a relation after the main query, using one IN query per relation (never JOINs — no row explosion, pagination stays correct). Paths nest with dots: With("Posts.Comments"). Options customize the leaf.

func (Query[T]) WithCount added in v0.2.0

func (q Query[T]) WithCount(relation string) Query[T]

WithCount fills the relation's count target field with one GROUP BY query per relation — the aggregate sibling of With. HasMany and ManyToMany only. The target is an int64 field tagged countof:

PostsCount int64 `rio:",countof:Posts"`

func (Query[T]) WithTrashed

func (q Query[T]) WithTrashed() Query[T]

WithTrashed includes soft-deleted rows.

type QueryEvent

type QueryEvent struct {
	// Op is a stable label usable as a metrics dimension without parsing
	// SQL: "select", "insert", "update", "delete", "upsert", "raw", "exec",
	// "begin", "commit", "rollback", "savepoint".
	Op string
	// Model is the Go struct name behind the statement, "" for Raw/Exec and
	// transaction control.
	Model string
	// Query is the rendered, dialect-form SQL.
	Query string
	// Args are the bind arguments, nil when the DB was built WithoutArgs.
	Args []any
	// Err is the translated execution error, nil on success. After only.
	Err error
	// Duration is the execution wall time; for row-returning queries it runs
	// through row consumption. After only.
	Duration time.Duration
	// RowsAffected is the driver-reported count for writes, -1 when unknown
	// (row-returning queries). After only.
	RowsAffected int64
}

QueryEvent describes one statement execution. Hooks receive the same event pointer in Before and After; After sees Err, Duration, and RowsAffected filled in.

type QueryHook

type QueryHook interface {
	BeforeQuery(ctx context.Context, e *QueryEvent) context.Context
	AfterQuery(ctx context.Context, e *QueryEvent)
}

QueryHook observes statement execution. BeforeQuery may derive a context (tracing spans); AfterQuery completes it. Hooks must not retain the event past the call and cannot modify the statement — rio has no mutating middleware by design.

For row-returning queries AfterQuery fires once the rows are consumed: Err includes scan and iteration failures, and Duration spans execution through row consumption. One exception: a First/Find/Sole miss reports Err = nil — ErrNotFound is a successfully executed query, and telemetry would otherwise count every miss as an error.

type Queryer

type Queryer interface {
	// Tx runs fn inside a transaction (on *DB) or a savepoint (on *Tx),
	// committing when fn returns nil and rolling back when it returns an
	// error or panics.
	Tx(ctx context.Context, fn func(tx *Tx) error) error
	// contains filtered or unexported methods
}

Queryer is what every execution point accepts: a *DB or a *Tx. Data-access code written against Queryer runs unchanged inside and outside transactions. Tx opens a transaction on a DB and a savepoint on a Tx, so transactional helpers compose transparently.

type RawQuery

type RawQuery[T any] struct {
	// contains filtered or unexported fields
}

RawQuery is the escape hatch: hand-written SQL through the same rebind pipeline, hooks, error translation, and scanner as everything else, into any target shape — DTO structs, scalars, entities. Like builders it is a connection-free value; placeholders are ? with IN (?) expansion.

func Raw

func Raw[T any](sqlText string, args ...any) RawQuery[T]

Raw builds a raw query. Scanning into a struct matches by column name and errors on result columns with no matching field: silently dropped data is how schema drift hides. Scanning half an entity and then calling Update writes zero values to the columns you did not select — project into DTOs.

func (RawQuery[T]) All

func (r RawQuery[T]) All(ctx context.Context, db Queryer) ([]T, error)

All runs the query and scans every row.

func (RawQuery[T]) First

func (r RawQuery[T]) First(ctx context.Context, db Queryer) (*T, error)

First returns the first row or ErrNotFound. rio does not append LIMIT to hand-written SQL; add your own when it matters.

func (RawQuery[T]) Sole

func (r RawQuery[T]) Sole(ctx context.Context, db Queryer) (*T, error)

Sole returns the single row, ErrNotFound when none match, and ErrMultipleRows when several do — same contract as Query.Sole.

type RelOption

type RelOption func(*relQuery)

RelOption customizes how one preloaded relation is fetched.

func RelLimit added in v0.2.0

func RelLimit(n int) RelOption

RelLimit caps the preloaded rows per parent (not overall), via ROW_NUMBER() OVER (PARTITION BY the foreign key) — the query stays one round trip and pagination-correct. Order within each parent follows RelOrder, defaulting to the target's primary key for determinism. Requires window functions: PostgreSQL, MySQL 8+, SQLite 3.25+. RelLimit(0) loads no children per parent (like Query.Limit(0)), not all of them.

func RelOrder

func RelOrder(expr string) RelOption

RelOrder orders the preloaded rows before they are grouped per parent.

func RelWhere

func RelWhere(expr string, args ...any) RelOption

RelWhere restricts the preloaded rows. The condition runs inside the preload's own query, so it can only reference the related table's columns.

func RelWithTrashed

func RelWithTrashed() RelOption

RelWithTrashed includes soft-deleted rows in the preload when the target model declares a softdelete column.

type Set

type Set map[string]any

Set is the column assignment map for UpdateAll. Keys are database column names; assignments render in sorted key order so the SQL is deterministic (stable goldens, stable statement-cache keys). Values bind as parameters unless they are Expr.

type TableNamer

type TableNamer interface {
	TableName() string
}

TableNamer overrides the convention-derived table name for one model.

type Tx

type Tx struct {
	// contains filtered or unexported fields
}

Tx is a transaction handle. It satisfies Queryer, so every rio entry point accepts it in place of a *DB. Like *sql.Tx it is bound to one connection and must not be used concurrently.

func (*Tx) Tx

func (t *Tx) Tx(ctx context.Context, fn func(tx *Tx) error) (err error)

Tx runs fn inside a savepoint, giving nested transactional code partial rollback. Savepoints commit ("RELEASE") when fn returns nil; on error the savepoint is rolled back and the error returned, leaving the outer transaction usable.

func (*Tx) Unwrap

func (t *Tx) Unwrap() *sql.Tx

Unwrap returns the underlying *sql.Tx.

type UpsertOption

type UpsertOption func(*upsertSpec)

UpsertOption shapes the conflict clause.

func DoNothing

func DoNothing() UpsertOption

DoNothing turns conflicts into no-ops. On MySQL this renders as a no-op assignment rather than INSERT IGNORE, which would swallow unrelated errors — and without the DoUpdate row alias, so it stays valid on MariaDB and MySQL before 8.0.19. A conflicting soft-deleted row stays deleted.

func DoUpdate

func DoUpdate(cols ...string) UpsertOption

DoUpdate lists the columns to overwrite on conflict. With no columns, every non-PK, non-CreatedAt, non-conflict-target column updates. Listed columns render deduplicated in canonical field order regardless of call order — the same rule as Update's whitelist, for the same SQL-cache reason.

func KeepTrashed

func KeepTrashed() UpsertOption

KeepTrashed opts out of the restore-on-upsert invariant: with it, an upsert hitting a soft-deleted row updates the tombstone without clearing deleted_at — and the row stays invisible to default queries. On an insert path, KeepTrashed also preserves an explicitly set deleted_at value.

func OnConflict

func OnConflict(cols ...string) UpsertOption

OnConflict names the conflict target columns (the unique index). Required with DoUpdate on PostgreSQL/SQLite; MySQL has no conflict target — its ON DUPLICATE KEY reacts to any unique index, which is a documented semantic difference, not something rio papers over.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL