rio

package module
v0.9.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

Generic ORM for Go. Query values carry no connection; writes are explicit; relations are typed and never lazy-load. Faster than GORM; close to hand-written database/sql.

rio's core has zero dependencies. Drivers are separate modules:

Module Driver
go-rio/postgres pgx
go-rio/mysql go-sql-driver
go-rio/sqlite modernc, pure Go
go-rio/clickhouse clickhouse-go v2
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

Guarantee Detail
Immutable query values Builders carry no connection or state; concurrent, package-level derivation from a shared base cannot cross-contaminate (GORM's "condition pollution").
Zero values are real Update writes what you give it, including 0, false, and ""; it never silently skips fields (GORM issue #6860). Partial updates are an explicit column whitelist; DB defaults are an explicit omitzero tag.
No hidden queries Typed containers (rio.HasMany[Post]) know whether they loaded; accessing an unloaded relation panics with instructions. No lazy loading, no invisible N+1.
Go-style errors ErrNotFound wraps sql.ErrNoRows (both errors.Is checks pass); unique violations translate to rio.ErrDuplicateKey with the driver error intact in the chain. Nothing is logged for you.
Guarded set-based writes UpdateAll/DeleteAll refuse to run without a WHERE unless you call AllRows(), on every dialect.
Predictable SQL One builder call, one SQL fragment; ? placeholders everywhere, with IN (?) slice expansion. The escape hatch rio.Raw[T] shares the 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, go-rio/clickhouse

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    // nullable
    Version   int64      `rio:",version"`
    DeletedAt *time.Time `rio:",softdelete"`
    CreatedAt time.Time
    UpdatedAt time.Time

    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) // by primary key

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

    _ = user
}

Schema migrations are 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)

Relation types: 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")); column APIs (Update whitelists, rio.Set, OnConflict, DoUpdate) take database column names. Preloading always uses per-relation WHERE … IN split queries: no cartesian explosion, pagination stays correct, identical behavior on all dialects. Paths nest: With("Posts.Comments").

Semantics

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; no hidden second SELECT
Upsert onto a soft-deleted row DoUpdate revives it (rio.KeepTrashed() opts out); DoNothing never revives
times stored UTC, microsecond precision; insert-then-reload compares Equal

Upsert supports conflict target, update whitelist, RETURNING backfill, and 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 (per-dialect notes on InsertAll).

Compiled queries

Entity CRUD is cached automatically; 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() also caches prepared statements (off by default: PgBouncer transaction pooling breaks server-side prepared statements; enable only when talking to the database directly).

Column constants

rio.WriteColumns generates column references from rio's own mapping plans: no source parsing, no binary to install, output cannot 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

Offset pagination degrades linearly. Keyset pagination is a WHERE clause; rio provides the pattern, not 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, stream with Rows.

Observability

QueryHook sees every statement rio runs — op, model, SQL, args (redactable with rio.WithoutArgs()), duration, rows affected — and cannot alter them. There are no model hooks.

type SlogHook struct {
    Log  *slog.Logger
    Slow time.Duration // statements at or over this log at WARN
}

func (SlogHook) BeforeQuery(ctx context.Context, e *rio.QueryEvent) context.Context {
    return ctx
}

func (h SlogHook) AfterQuery(ctx context.Context, e *rio.QueryEvent) {
    level := slog.LevelDebug
    switch {
    case e.Err != nil:
        level = slog.LevelError
    case h.Slow > 0 && e.Duration >= h.Slow:
        level = slog.LevelWarn
    }
    h.Log.LogAttrs(ctx, level, "query",
        slog.String("op", e.Op), slog.String("model", e.Model),
        slog.Duration("dur", e.Duration), slog.Int64("rows", e.RowsAffected),
        slog.String("sql", e.Query), slog.Any("err", e.Err))
}

db, _ := postgres.Open(dsn, rio.WithQueryHook(SlogHook{
    Log: slog.Default(), Slow: 200 * time.Millisecond,
}))

The context BeforeQuery returns is the execution context rio hands the driver, so tracing spans and deadlines you attach there flow into the query, and AfterQuery receives that same context. To emit OpenTelemetry spans, start a span in BeforeQuery (return its context) and End() it in AfterQuery, recording Op and Model as span attributes.

Security

rio sorts every string argument into two kinds:

Argument APIs Treatment
Column names Update whitelist, rio.Set keys, Pluck, OnConflict/DoUpdate, With paths validated against the model's mapped columns (relation names for With) and emitted as escaped identifiers; an unknown name errors, never injects SQL
SQL fragments Where, OrderBy, GroupBy, Having, Join, RelWhere, Expr, Raw rendered verbatim — build them from constants, never from untrusted input

Values bind as ? parameters everywhere; only fragment text is verbatim. For an identifier chosen at runtime, map user input to a column constant (rio.WriteColumns) instead of concatenating it:

allowed := map[string]string{"newest": UserCols.CreatedAt, "name": UserCols.Name}
col, ok := allowed[userInput]; if !ok { col = UserCols.ID } // reject unknown keys
users, err := rio.From[User]().OrderBy(col + " DESC").All(ctx, db)

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: ~25% faster than GORM, within ~12% of hand-written scanning; the 100-row scan is a dead heat.
  • Inserts: ~45% faster than GORM. Updates: ~57% faster than GORM, within 7% of hand-written SQL.
  • Batch inserts are driver-dominated (both sides send one multi-VALUES statement), so the ~11% edge is mostly allocation discipline.
  • Techniques: per-type mapping plans, per-grammar SQL caches, offset-based scanning with a reflect fallback, []byte-appended rendering. No code generation.

What rio 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

Rationale for each is 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. On PostgreSQL the driver module offers three execution tiers behind this same API — database/sql (Open), pgxpool with database/sql queries (OpenPool), and fully pgx-native (OpenNative, the fastest read path); see go-rio/postgres for the table.

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.

Struct tags map fields and assign column roles (rio:"col,opt,…"):

  • "col_name": rename the column (default snake_case, UserID → user_id).
  • ",pk": primary key; tag several fields for a composite key.
  • ",noautoincr": a single integer primary key rio must not auto-increment.
  • ",version": optimistic-lock counter (integer, int64 recommended); a lost race returns ErrStaleObject.
  • ",softdelete": on time.Time/*time.Time, Delete becomes a timestamp UPDATE and default reads filter the row out.
  • ",json": store the field as JSON.
  • ",countof:Rel": int64 target that WithCount("Rel") fills with a HasMany/ManyToMany row count.
  • ",omitzero": skip the column while the field is zero so the database default applies.
  • ",nostamp": opt a time field out of CreatedAt/UpdatedAt maintenance.
  • ",fk:col" / ",ref:col" / ",join:table": relation-container overrides — foreign key, referenced key, join table.
  • "-": not a column.

Conventions need no tag: an ID field is the primary key and, as a single integer, auto-increments — a rename/omitzero/noautoincr keeps that, an explicit pk anywhere in the model opts out; CreatedAt and UpdatedAt (time.Time or *time.Time) are stamped on write; a TableName() string method overrides the pluralized table name (User → users, Person → people).

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). SyncRelation is the declarative sibling — it converges the relation on an exact id set instead of adding to 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...). SyncRelation is the declarative sibling — it converges the relation on an exact id set instead of removing named links.

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. Stamping happens before execution: after a failed Insert the struct may already carry this attempt's timestamps and version=1 while the database is untouched — retrying with the same struct is safe.

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". Attach and Detach are the imperative siblings — add or remove specific links without reading the current 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. UpdatedAt is stamped before execution: after a failed Update the struct may already carry this attempt's stamp while the database is untouched — retrying with the same struct is safe (the stamp is reset to the clock on every call anyway).

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. All of this stamping happens before execution: after a failed Upsert the struct may already carry this attempt's timestamps and version=1 while the database is untouched — retrying with the same struct is safe.

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.

Panics on a nil db or dialect, and on WithStmtCache with the ClickHouse dialect (clickhouse-go prepares only INSERT batches, so a prepared SELECT fails on first use).

func NewNative added in v0.8.0

func NewNative(nc NativeConfig, dialect Dialect, opts ...Option) *DB

NewNative constructs a *DB on a driver-native execution channel. Driver-module SPI, not application API — applications construct through the driver module (postgres.OpenNative), which wires the adapter, the pool handle, and the database/sql view together.

Like New taking over the *sql.DB's Close, Close on the returned DB closes what the config carries: first the SQLView, then the channel (whose adapter closes the pool). Panics if NativeConfig.DB or dialect is nil. WithStmtCache panics here too — a native channel has no database/sql prepared statements; statement caching belongs to the driver (with pgx, the DSN parameter default_query_exec_mode, which defaults to caching already).

func (*DB) Close

func (d *DB) Close() error

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

func (*DB) Native added in v0.8.0

func (d *DB) Native() any

Native returns the driver-native pool handle behind the native channel — NativeConfig.Handle, a *pgxpool.Pool under go-rio/postgres — and nil on the database/sql channel. Application code goes through the driver module's typed accessor (postgres.PoolOf); this is the raw door those accessors are built on. Its transaction-scoped sibling is Tx.NativeTx, which returns the SPI transaction adapter instead of a pool handle.

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. On the native channel it returns the database/sql view the driver module supplied over the same pool (NativeConfig.SQLView; go-rio/postgres always provides one), or nil when the driver module supplied none. Never tune pooling on a native view — the pool belongs to the driver's configuration.

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{}
	ClickHouse Dialect = clickhouseDialect{}
)

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 untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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 NativeCell added in v0.8.0

type NativeCell interface {
	sql.Scanner // the fallback path: the cell scans driver-canonical values itself
	ScanKind() NativeScanKind
	SetInt64(int64) error
	SetUint64(uint64) error
	SetFloat64(float64) error
	SetBool(bool) error
	SetString(string) error
	SetBytes([]byte) error
	SetTime(time.Time) error
	SetNull() error
	// contains filtered or unexported methods
}

NativeCell is the typed sink a NativeRows implementation feeds decoded column values into — rio's side of the native scan path, one cell per column. Driver-module SPI, not application API.

Drivers consume NativeCell; they never implement it. The interface is sealed (sealedNativeCell), so rio may add Set methods in a minor version without breaking a driver — a new sink is one more optional route a codec can take, never a compile break. This is the mirror of the NativeDB, NativeTx, and NativeRows freeze: those three, driver-implemented, cannot gain methods within v1; NativeCell, rio-implemented, can.

Every Set method is exactly Scan with the interface boxing removed: SetInt64(v) behaves like Scan(int64(v)), SetUint64(v) like Scan(uint64(v)), SetNull like Scan(nil), and so on — same conversion rules, same overflow and NULL handling, same error shapes, mismatched-kind fallback included, because both paths run the same store helpers (scan.go). SetBytes never retains v — driver memory is copied where it is stored. SetString stores its argument as-is, so hand over an owned string, never an unsafe view of driver memory.

ScanKind reports the cell's strategy. Pointer fields report their element's kind: the sinks allocate and publish the *T cell internally and SetNull stores nil, so pointer-ness never crosses the SPI. A NativeKindJSON cell takes the raw JSON payload through SetBytes or SetString — the bytes rio unmarshals — not a value decoded driver-side.

type NativeConfig added in v0.8.0

type NativeConfig struct {
	// DB is the native execution channel. Required.
	DB NativeDB

	// Handle is the driver-native pool handle (a *pgxpool.Pool under
	// go-rio/postgres), returned verbatim by (*DB).Native so the driver
	// module's typed accessors (postgres.PoolOf) can reach it.
	Handle any

	// SQLView is an optional database/sql view over the same pool, returned
	// by (*DB).Unwrap so pool-agnostic helpers (pings, migrations) keep
	// working on the native channel. (*DB).Close closes it before DB.
	// Without one, Unwrap returns nil.
	SQLView *sql.DB
}

NativeConfig carries what a driver module hands NewNative, all wired to the same underlying pool.

type NativeDB added in v0.8.0

type NativeDB interface {
	Query(ctx context.Context, sql string, args []any) (NativeRows, error)
	Exec(ctx context.Context, sql string, args []any) (rowsAffected int64, err error)
	Begin(ctx context.Context, opts *sql.TxOptions) (NativeTx, error)
	Close() error
}

NativeDB is a driver-native execution channel: what rio needs from a driver pool. Driver-module SPI, not application API.

SQL arrives fully rendered in the dialect's placeholder form; args are the bind values rio would hand database/sql. Exec returns the statement's affected-row count (the driver's command tag). Begin maps *sql.TxOptions (possibly nil) onto the driver's transaction options. Close releases the channel's resources — for a pooled driver, the pool itself.

type NativeRows added in v0.8.0

type NativeRows interface {
	Columns() []string
	Next() bool
	Scan(dest ...any) error
	Err() error
	Close()
}

NativeRows is a driver-native result set, shaped like pgx.Rows: Close returns nothing, and errors — including those Close itself discovers, such as the failed statement behind an undrained single-row read — converge in Err. rio reads Err after Close, so deferred protocol errors still surface (see mergeClose). Driver-module SPI, not application API.

rio passes the same dest slots, in the same order, for every row of one result. Each slot is either a NativeCell (rio's per-column typed sink) or a plain pointer (rare; scan it the way the driver natively would). Implementations may therefore classify the dest list on the first Scan and reuse that classification for the remaining rows.

type NativeScanKind added in v0.8.0

type NativeScanKind uint8

NativeScanKind names the plan-time scan strategy of one NativeCell, so a NativeRows implementation can pick a typed decode path per column. Driver-module SPI, not application API.

The enum can grow. Treat any kind you do not recognize as NativeKindScanner: the sql.Scanner fallback is correct for every kind — only slower — because the cell's Scan accepts the same driver-canonical values database/sql delivers.

const (
	// NativeKindScanner is the fallback: pass the cell itself to the
	// driver's sql.Scanner path. Also the zero value, so an adapter that
	// never learned a newer kind degrades to correct-but-slower.
	NativeKindScanner NativeScanKind = iota
	NativeKindInt
	NativeKindUint
	NativeKindFloat
	NativeKindBool
	NativeKindString
	NativeKindBytes
	NativeKindTime
	// NativeKindJSON takes the column's raw JSON payload through SetBytes or
	// SetString — the bytes rio unmarshals into the field — not a value
	// decoded driver-side.
	NativeKindJSON
)

type NativeTx added in v0.8.0

type NativeTx interface {
	Query(ctx context.Context, sql string, args []any) (NativeRows, error)
	Exec(ctx context.Context, sql string, args []any) (int64, error)
	Commit(ctx context.Context) error
	Rollback(ctx context.Context) error
}

NativeTx is one driver-native transaction. Driver-module SPI, not application API.

Finished-transaction contract: once the transaction has ended — committed, rolled back, or destroyed by the driver on its own (a begin context that died, a broken connection) — Commit and Rollback must return an error satisfying errors.Is(err, sql.ErrTxDone), translating the driver's own sentinel (pgx.ErrTxClosed) where needed. rio's cleanup paths tolerate exactly sql.ErrTxDone, which keeps rollback semantics identical across channels without the core importing any driver.

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. With the ClickHouse dialect it panics at New — clickhouse-go prepares only INSERT batches, so a prepared SELECT fails on first use.

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.

Whether it pays depends on the driver. go-sql-driver/mysql runs every parameterized query as prepare+execute — two blocking round-trips — so the cache halves single-statement latency there (measured −35% to −46% against a local MySQL 8.4; the DSN option interpolateParams=true is the alternative, see the go-rio/mysql README). pgx's database/sql adapter already caches statements per connection, and stacking database/sql's Stmt layer on top measured slower, not faster — leave it off for pgx. modernc SQLite is in-process; the cache saves parsing, worth measuring per workload.

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]) Final added in v0.7.0

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

Final reads through ClickHouse's FINAL table modifier, merging row versions at query time — the read-side companion of ReplacingMergeTree deduplication (the Upsert replacement on that dialect). It applies to this query's own SELECT only: preloads, WithCount, and WhereHas subqueries read without FINAL. Every other dialect rejects it at render, and ClickHouse itself rejects it on table engines without versioned merges (ILLEGAL_FINAL). A blanket alternative is the final=1 setting on the connection's DSN or profile, which applies FINAL server-side to every table in every query.

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. ClickHouse has no row locks at all and rejects it at render.

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. The term is included verbatim; never build it from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

func (Query[T]) Having

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

Having adds an AND-ed HAVING condition. The expression is included verbatim; never build it from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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. Never build the clause from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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"). Never build the term from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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. 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; 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 (?). The expression is included verbatim; never build it from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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. A write whose
	// Result.RowsAffected fails carries that failure here — the caller
	// returns the same error, and the hook must not record the statement as
	// a 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, or the driver failed to report it — the
	// failure is then in Err). 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. The context BeforeQuery returns is the execution context: rio runs the statement — and, for row-returning queries, the row consumption its context governs — under it, so a tracing span or deadline the hook installs flows into the driver, and AfterQuery receives that same context. Returning nil leaves the incoming context in force. Hooks must not retain the event past the call and cannot alter the statement (Op, Query, Args) — 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.

The method set is fixed: later hook capabilities arrive as optional interfaces a hook may also satisfy, discovered by type assertion, never as methods added here — so existing hooks keep compiling.

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. The SQL text is used verbatim; never build it from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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]) Rows added in v0.9.0

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

Rows streams the raw query's rows without materializing them, for result sets too large to hold: for v, err := range Raw[T](...).Rows(ctx, db). Iteration stops on the first error (yielded with a zero T) and the rows close automatically, including on early break. Like All it scans scalars, DTOs, or entities and holds the result to the same full-column-coverage rule — a struct target missing a mapped column is an error, not a silent partial scan.

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. The term is included verbatim; never build it from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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. The expression is included verbatim; never build it from untrusted input — dynamic identifiers belong in column whitelists or rio.WriteColumns constants.

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, which is spliced verbatim (see Expr) — never build one from untrusted input.

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) NativeTx added in v0.8.0

func (t *Tx) NativeTx() any

NativeTx returns the NativeTx SPI transaction adapter the native channel runs this transaction on, and nil on the database/sql channel. Like (*DB).Native — which hands back the driver pool handle — it is the raw door: application code uses the driver module's typed accessor (postgres.TxOf) instead.

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 — and nil on the native channel, which has no *sql.Tx to give. Use the driver module's typed accessor there: postgres.TxOf returns the pgx.Tx this transaction runs on.

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