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 ¶
- Variables
- func Attach[T any, K any](ctx context.Context, db Queryer, row *T, relation string, ids ...K) error
- func Delete[T any](ctx context.Context, db Queryer, row *T) error
- func Detach[T any, K any](ctx context.Context, db Queryer, row *T, relation string, ids ...K) error
- func Exec(ctx context.Context, db Queryer, sqlText string, args ...any) (sql.Result, error)
- func Find[T any](ctx context.Context, db Queryer, key ...any) (*T, error)
- func ForceDelete[T any](ctx context.Context, db Queryer, row *T) error
- func Insert[T any](ctx context.Context, db Queryer, row *T) error
- func InsertAll[T any](ctx context.Context, db Queryer, rows []T) error
- func Pluck[V any, T any](ctx context.Context, db Queryer, q Query[T], column string) ([]V, error)
- func Restore[T any](ctx context.Context, db Queryer, row *T) error
- func SyncRelation[T any, K any](ctx context.Context, db Queryer, row *T, relation string, ids []K) error
- func Update[T any](ctx context.Context, db Queryer, row *T, cols ...string) error
- func Upsert[T any](ctx context.Context, db Queryer, row *T, opts ...UpsertOption) error
- func UpsertAll[T any](ctx context.Context, db Queryer, rows []T, opts ...UpsertOption) error
- func WriteColumns(w io.Writer, pkgName string, models ...any) error
- type BelongsTo
- type Compiled
- func (c *Compiled[T]) All(ctx context.Context, db Queryer, args ...any) ([]T, error)
- func (c *Compiled[T]) Count(ctx context.Context, db Queryer, args ...any) (int64, error)
- func (c *Compiled[T]) Exists(ctx context.Context, db Queryer, args ...any) (bool, error)
- func (c *Compiled[T]) First(ctx context.Context, db Queryer, args ...any) (*T, error)
- func (c *Compiled[T]) Rows(ctx context.Context, db Queryer, args ...any) iter.Seq2[T, error]
- func (c *Compiled[T]) Sole(ctx context.Context, db Queryer, args ...any) (*T, error)
- type DB
- type Dialect
- type Expr
- type HasMany
- type HasOne
- type ManyToMany
- type Option
- type Query
- func (q Query[T]) All(ctx context.Context, db Queryer) ([]T, error)
- func (q Query[T]) AllRows() Query[T]
- func (q Query[T]) Count(ctx context.Context, db Queryer) (int64, error)
- func (q Query[T]) CreateOrFirst(ctx context.Context, db Queryer, row *T) error
- func (q Query[T]) DeleteAll(ctx context.Context, db Queryer) (int64, error)
- func (q Query[T]) Exists(ctx context.Context, db Queryer) (bool, error)
- func (q Query[T]) First(ctx context.Context, db Queryer) (*T, error)
- func (q Query[T]) FirstOrCreate(ctx context.Context, db Queryer, row *T) error
- func (q Query[T]) ForUpdate() Query[T]
- func (q Query[T]) ForceDeleteAll(ctx context.Context, db Queryer) (int64, error)
- func (q Query[T]) GroupBy(expr string) Query[T]
- func (q Query[T]) Having(expr string, args ...any) Query[T]
- func (q Query[T]) Join(clause string) Query[T]
- func (q Query[T]) Limit(n int) Query[T]
- func (q Query[T]) Offset(n int) Query[T]
- func (q Query[T]) OnlyTrashed() Query[T]
- func (q Query[T]) OrderBy(expr string) Query[T]
- func (q Query[T]) RestoreAll(ctx context.Context, db Queryer) (int64, error)
- func (q Query[T]) Rows(ctx context.Context, db Queryer) iter.Seq2[T, error]
- func (q Query[T]) Scope(fns ...func(Query[T]) Query[T]) Query[T]
- func (q Query[T]) Sole(ctx context.Context, db Queryer) (*T, error)
- func (q Query[T]) UpdateAll(ctx context.Context, db Queryer, set Set) (int64, error)
- func (q Query[T]) Where(expr string, args ...any) Query[T]
- func (q Query[T]) WhereHas(path string, opts ...RelOption) Query[T]
- func (q Query[T]) WhereHasNot(path string, opts ...RelOption) Query[T]
- func (q Query[T]) With(path string, opts ...RelOption) Query[T]
- func (q Query[T]) WithCount(relation string) Query[T]
- func (q Query[T]) WithTrashed() Query[T]
- type QueryEvent
- type QueryHook
- type Queryer
- type RawQuery
- type RelOption
- type Set
- type TableNamer
- type Tx
- type UpsertOption
Constants ¶
This section is empty.
Variables ¶
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
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 ¶
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
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 ¶
Exec runs a hand-written statement through the shared pipeline and returns the driver result.
func Find ¶
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 ¶
ForceDelete removes the row with a real DELETE even when the model soft deletes.
func Insert ¶
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 ¶
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
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 ¶
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 ¶
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 ¶
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 ¶
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
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]) MarshalJSON ¶
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 ¶
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 MustCompile ¶
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]) Count ¶
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 ¶
Exists reports whether any row matches. Like Count it renders per call — its shape differs from the cached row SELECT.
func (*Compiled[T]) First ¶
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
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 ¶
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 ¶
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 ¶
Close closes the prepared-statement cache (if enabled) and the underlying *sql.DB.
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.
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]) MarshalJSON ¶
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 ¶
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]) MarshalJSON ¶
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 ¶
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 ¶
WithClock replaces the time source used for CreatedAt/UpdatedAt and soft deletes. Intended for tests.
func WithErrorTranslator ¶
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 ¶
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 ¶
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 ¶
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 (Query[T]) AllRows ¶
AllRows is the explicit opt-in for UpdateAll/DeleteAll without conditions.
func (Query[T]) Count ¶
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 ¶
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 ¶
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]) First ¶
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 ¶
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 ¶
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 ¶
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 ¶
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]) Join ¶
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]) OnlyTrashed ¶
OnlyTrashed selects only soft-deleted rows.
func (Query[T]) RestoreAll ¶ added in v0.4.0
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
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
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 ¶
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 ¶
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 ¶
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
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
WhereHasNot keeps only rows whose relation at path has no matching row — NOT EXISTS (...).
func (Query[T]) With ¶
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
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 ¶
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 ¶
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.
type RelOption ¶
type RelOption func(*relQuery)
RelOption customizes how one preloaded relation is fetched.
func RelLimit ¶ added in v0.2.0
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 RelWhere ¶
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 ¶
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.
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.