Documentation
¶
Overview ¶
Package dbx provides typed generic repositories on top of gohan, with a database/sql adapter and transaction helper.
Unlike db.Repository, dbx.Repository[T] returns *T / []*T from every read, takes a context.Context per call instead of storing one at construction, and builds every statement through gohan: values are always bound and identifiers are always escaped.
Not-found semantics ¶
ErrNotFound is an alias for sql.ErrNoRows, so errors.Is(err, sql.ErrNoRows) keeps working against code written against database/sql or db.Repository. Get, GetBy and QueryOne return (nil, ErrNotFound) when no row matches, and UpsertReturning when its DO NOTHING skipped an existing row; List, ListBy, Query and UpdateReturning return an empty, non-nil slice instead. QueryInt64 (used by Count/Exists internally) also returns ErrNotFound on no row, though Count/Exists themselves never surface it (a COUNT(*) query always returns exactly one row).
No unfiltered DELETE/UPDATE ¶
Delete, Update, UpdateFields and UpdateReturning require a non-nil where expression and return gohan.ErrNoWhere without touching the database when where is nil. There is deliberately no "delete all" or "update all" method: a caller who means it uses Exec with an explicit gohan.Delete(table).All() (or gohan.Update(table).All()) statement.
Caller obligations (nothing here can enforce these) ¶
- Inside WithTx or WithTxRetry, use repo.With(tx) to run through the transaction — using the outer repository (bound to the non-transactional Querier) runs the statement outside the transaction, and on SQLite with MaxOpenConns == 1 this deadlocks waiting for the only connection, which WithTx is already holding.
- Custom queries passed to Get/List should start from r.Select(), not a bare gohan.From(table): gohan.From renders "SELECT *", which sqlx rejects when the table has columns T does not map.
- sqlx's default field-name mapper is assumed throughout; a repository bound to a *sqlx.DB with a custom mapper is not supported.
Index ¶
- Constants
- Variables
- func Changes[T any](oldRec, newRec *T) (map[string]any, error)
- func Query[D any](ctx context.Context, q Querier, st *gohan.SelectBuilder) ([]*D, error)
- func QueryOne[D any](ctx context.Context, q Querier, st *gohan.SelectBuilder) (*D, error)
- func SetOptional[T, V any](c *Changeset[T], column string, o optional.Optional[V]) error
- func WithTx(ctx context.Context, q Querier, opts *sql.TxOptions, fn func(tx Querier) error) (err error)
- func WithTxRetry(ctx context.Context, q Querier, opts *sql.TxOptions, attempts int, ...) error
- type BatchInserter
- type Changeset
- type Grid
- func (g *Grid[T]) AddFilterFunc(dbField string, f GridFilterFunc) *Grid[T]
- func (g *Grid[T]) Build(base *gohan.SelectBuilder, q *GridQuery) (*gohan.SelectBuilder, error)
- func (g *Grid[T]) Conds(q *GridQuery) ([]gohan.Expr, error)
- func (g *Grid[T]) ValidQuery(q *GridQuery) error
- func (g *Grid[T]) WithCaseInsensitiveSearch() *Grid[T]
- func (g *Grid[T]) WithMaxLimit(n uint) *Grid[T]
- func (g *Grid[T]) WithTiebreaker(cols ...string) *Grid[T]
- type GridError
- type GridFilterFunc
- type GridQuery
- type KeysetKey
- type KeysetPage
- type Querier
- type RecordChecker
- type Repository
- func (r *Repository[T]) Count(ctx context.Context, where gohan.Expr) (int64, error)
- func (r *Repository[T]) Delete(ctx context.Context, where gohan.Expr) (int64, error)
- func (r *Repository[T]) Exec(ctx context.Context, st gohan.Statement) (int64, error)
- func (r *Repository[T]) Exists(ctx context.Context, where gohan.Expr) (bool, error)
- func (r *Repository[T]) Get(ctx context.Context, q *gohan.SelectBuilder) (*T, error)
- func (r *Repository[T]) GetBy(ctx context.Context, fields map[string]any) (*T, error)
- func (r *Repository[T]) Insert(ctx context.Context, records ...*T) error
- func (r *Repository[T]) InsertIgnore(ctx context.Context, rec *T, conflict ...string) (bool, error)
- func (r *Repository[T]) InsertReturning(ctx context.Context, rec *T) (*T, error)
- func (r *Repository[T]) List(ctx context.Context, q *gohan.SelectBuilder) ([]*T, error)
- func (r *Repository[T]) ListBy(ctx context.Context, fields map[string]any) ([]*T, error)
- func (r *Repository[T]) ListKeyset(ctx context.Context, where gohan.Expr, keys []KeysetKey, pageSize int, ...) (*KeysetPage[T], error)
- func (r *Repository[T]) Querier() Querier
- func (r *Repository[T]) QueryGrid(ctx context.Context, g *Grid[T], q *GridQuery) ([]*T, error)
- func (r *Repository[T]) QueryGridKeyset(ctx context.Context, g *Grid[T], q *GridQuery, cursor string) (*KeysetPage[T], error)
- func (r *Repository[T]) QueryGridWithCount(ctx context.Context, g *Grid[T], q *GridQuery) ([]*T, int64, error)
- func (r *Repository[T]) Select() *gohan.SelectBuilder
- func (r *Repository[T]) Table() string
- func (r *Repository[T]) Update(ctx context.Context, rec *T, where gohan.Expr, opts ...gohan.RecordOption) (int64, error)
- func (r *Repository[T]) UpdateFields(ctx context.Context, fields map[string]any, where gohan.Expr) (int64, error)
- func (r *Repository[T]) UpdateReturning(ctx context.Context, fields map[string]any, where gohan.Expr) ([]*T, error)
- func (r *Repository[T]) Upsert(ctx context.Context, rec *T, conflict []string, update ...string) error
- func (r *Repository[T]) UpsertReturning(ctx context.Context, rec *T, conflict []string, update ...string) (*T, error)
- func (r *Repository[T]) With(q Querier) *Repository[T]
- func (r *Repository[T]) WithGroupedInserts() *Repository[T]
- type RetryClassifier
- type SQLQuerier
- func (q *SQLQuerier) BeginTx(ctx context.Context, opts *sql.TxOptions) (TxQuerier, error)
- func (q *SQLQuerier) Dialect() gohan.Dialect
- func (q *SQLQuerier) Exec(ctx context.Context, query string, args ...any) (int64, error)
- func (q *SQLQuerier) Get(ctx context.Context, dest any, query string, args ...any) error
- func (q *SQLQuerier) IsRetryable(err error) bool
- func (q *SQLQuerier) QueryInt64(ctx context.Context, query string, args ...any) (int64, error)
- func (q *SQLQuerier) Select(ctx context.Context, dest any, query string, args ...any) error
- type SortField
- type TxBeginner
- type TxQuerier
Examples ¶
Constants ¶
const ( // ErrAutoColumn is returned by Changeset.Set for a column whose field is // marked auto (db:",auto", auto:"true", grid:"auto", or goqu // skipinsert/skipupdate): such columns are never written by an update. ErrAutoColumn = utils.Error("dbx: column is auto-generated and cannot be set") // ErrValueType is returned by Changeset.Set when a value cannot be stored // in the column's field type without loss. ErrValueType = utils.Error("dbx: value does not fit the column's field type") // ErrNilRecord is returned by Changes when either record is nil. ErrNilRecord = utils.Error("dbx: record is nil") )
const ( // ErrNotStruct is returned by NewRepository when T is not a struct type. ErrNotStruct = utils.Error("dbx: repository type must be a struct") // ErrUnknownColumn is returned when a caller-supplied field/column name // is not one of the repository's record columns. ErrUnknownColumn = utils.Error("dbx: column is not a field of the record type") // ErrTxUnsupported is returned by WithTx when the given Querier is // neither a TxQuerier nor a TxBeginner. ErrTxUnsupported = utils.Error("dbx: querier cannot begin a transaction") // ErrCountOverflow is returned when a COUNT result does not fit in // int64. ErrCountOverflow = utils.Error("dbx: count does not fit in int64") // ErrNoColumns is returned by NewRepository when the record type maps // to zero columns. ErrNoColumns = utils.Error("dbx: record type has no columns") // ErrDialectDriver is returned by FromClient when the client's driver // resolves to a dialect the database/sql adapter does not support // (currently ClickHouse; use provider/clickhouse's Querier instead). ErrDialectDriver = utils.Error("dbx: driver/dialect not supported by the database/sql adapter") )
const ( // ErrInvalidCursor is returned when a pagination cursor cannot be used: // it is malformed, was tampered with, or was minted for another table, // key list or key type. It is a client error. ErrInvalidCursor = utils.Error("dbx: invalid pagination cursor") // ErrCursorTooLarge is returned when the cursor for the next page would // exceed MaxCursorBytes (the key values are too long). It is a server // error: the client did nothing wrong. ErrCursorTooLarge = utils.Error("dbx: pagination cursor exceeds the size limit") // ErrInvalidKeysetKey is returned when a keyset key list is empty, too // long, repeats a column, or names a column whose Go type cannot be a // keyset key. ErrInvalidKeysetKey = utils.Error("dbx: invalid keyset key") // ErrKeysetNotUnique is returned when two fetched rows have the same key // values: the keys must identify rows uniquely for keyset pagination not // to skip or repeat rows. ErrKeysetNotUnique = utils.Error("dbx: keyset keys do not identify rows uniquely") )
const ( SortAscending = "asc" SortDescending = "desc" SearchNone = 0 SearchStart = 1 SearchEnd = 2 SearchAny = 3 DefaultPageSize = 100 // DefaultMaxLimit is the row cap a new Grid applies to GridQuery.Limit // (see Grid.WithMaxLimit). DefaultMaxLimit = 1000 // MaxFilterValues caps the number of elements a []any filter value may // contain. MaxFilterValues = 1000 // MaxSearchText caps SearchText's length in bytes. MaxSearchText = 256 )
const ( // MaxCursorBytes caps a pagination cursor's length, both the cursors // ListKeyset/QueryGridKeyset accept and the ones they mint. MaxCursorBytes = 4096 // MaxKeysetKeys caps the number of keyset keys (the seek predicate grows // with the square of the key count). MaxKeysetKeys = 8 )
Variables ¶
var ErrNotFound = sql.ErrNoRows
ErrNotFound is returned when a query expected to match a row matches none. It is an alias for sql.ErrNoRows so errors.Is(err, sql.ErrNoRows) keeps working.
Functions ¶
func Changes ¶ added in v0.12.0
Changes compares oldRec and newRec field by field and returns, keyed by column name, newRec's value for every non-auto column whose value differs, for Repository.UpdateFields ("load, modify, save the diff"). Fields are compared with reflect.DeepEqual, so pointers compare by pointee and slices/maps by content; a time.Time compares strictly, so the same instant in another location (or with a monotonic reading stripped) counts as a change. A pointer field's value is its pointee (nil for a nil pointer). It fails with ErrNilRecord for a nil record, ErrNotStruct for a non-struct T, and gohan's shape errors for a T that NewRepository would reject.
func Query ¶ added in v0.12.0
Query builds st against q.Dialect() and scans every row into a new D, for results that are not a repository's record type: joins, aggregates, projections into a DTO. D's fields map to result columns as with Repository (the `db` tag through sqlx; on provider/clickhouse's Querier, the `ch` tag and D must be a struct). When q implements RecordChecker, D is checked with CheckRecord before any query, and its error returned. It never returns a nil slice.
func QueryOne ¶ added in v0.12.0
QueryOne is Query for a single row: it scans the first row of st into a new D, or returns (nil, ErrNotFound) when st matches no row. Unlike Repository.Get it does not add a LIMIT; add Limit(1) to st when it can match several rows. D is checked as by Query.
func SetOptional ¶ added in v0.12.0
SetOptional applies a tri-state value to c: None leaves column untouched (and is not validated), Null sets it to nil, and Some(v) sets it to v. Null and Some are validated exactly as by Changeset.Set.
func WithTx ¶
func WithTx(ctx context.Context, q Querier, opts *sql.TxOptions, fn func(tx Querier) error) (err error)
WithTx runs fn inside a transaction over q:
- if q is already a TxQuerier, fn runs directly in it (composable: no nested BEGIN, no commit/rollback here — the outer WithTx owns them);
- if q is a TxBeginner, WithTx begins a transaction, runs fn, and commits on success or rolls back otherwise;
- otherwise WithTx fails with ErrTxUnsupported.
A panic, or a runtime.Goexit (as t.FailNow triggers in a goroutine), rolls back the transaction it began before propagating; the deferred rollback does not use recover(), so the panic/Goexit continues unimpeded and the transaction is still released. When fn returns an error and the resulting Rollback also fails, WithTx returns errors.Join(fnErr, rollbackErr), so errors.Is(err, fnErr) still holds.
func WithTxRetry ¶ added in v0.12.0
func WithTxRetry(ctx context.Context, q Querier, opts *sql.TxOptions, attempts int, fn func(tx Querier) error) error
WithTxRetry runs fn in a transaction like WithTx, retrying the whole transaction when it fails with an error q classifies as transient (e.g. a PostgreSQL serialization failure under sql.LevelSerializable).
Each attempt is a fresh WithTx over q: a failed attempt is rolled back before the next one begins. Between attempts WithTxRetry sleeps a full-jitter backoff (a random delay below min(2ms<<n, 100ms) before retry n); if ctx is done before the next attempt it stops and returns errors.Join(ctx.Err(), lastErr).
attempts is the maximum number of times fn runs; a value below 1 is treated as 1. When every attempt fails, the last attempt's error is returned.
Only errors q classifies as retryable are retried: q must implement RetryClassifier (SQLQuerier does, for PostgreSQL and SQLite). With any other Querier, or any other error, WithTxRetry returns after the first attempt, exactly like WithTx.
When q is already a TxQuerier, WithTx joins that outer transaction, so WithTxRetry runs fn once and never retries: after a serialization failure the outer transaction is aborted, and only the code that owns it can retry it.
fn may run more than once, so it must be idempotent apart from its database writes, which are rolled back with each failed attempt. Values fn stores outside the transaction persist across attempts: a record reused between attempts keeps what a failed attempt assigned to it (for example an id copied from InsertReturning's result, whose row was rolled back), so build such values inside fn.
Types ¶
type BatchInserter ¶
type BatchInserter interface {
// InsertBatch inserts rows (each a *T) into table in one operation.
InsertBatch(ctx context.Context, table string, rows []any) error
}
BatchInserter is implemented by adapters with a bulk-insert fast path (e.g. provider/clickhouse's Querier). Repository.Insert prefers it over chunked INSERT statements when the bound Querier implements it.
type Changeset ¶ added in v0.12.0
type Changeset[T any] struct { // contains filtered or unexported fields }
Changeset collects validated column values for a partial update of a Repository[T]'s table. Each Set is checked against T before it is accepted, so Changes() can be passed to Repository.UpdateFields without the database being the first to notice a bad column or value. A Changeset is not safe for concurrent use.
func NewChangeset ¶ added in v0.12.0
func NewChangeset[T any](repo *Repository[T]) *Changeset[T]
NewChangeset returns an empty Changeset for repo's record type.
func (*Changeset[T]) Changes ¶ added in v0.12.0
Changes returns a copy of the accepted column values, keyed by column name, ready for Repository.UpdateFields. It is empty (not nil) when nothing was set; check its length before updating, since an UPDATE with no columns fails to build.
func (*Changeset[T]) Set ¶ added in v0.12.0
Set records value for column, replacing any earlier value for the same column. column is a db column name (not a Go field name). Set fails, leaving the changeset unchanged, with:
- ErrUnknownColumn if column is not one of the repository's columns;
- ErrAutoColumn if the column's field is marked auto;
- ErrValueType if value does not fit the field's type.
A value fits when it is assignable to the field type, or converts to it without loss:
- numbers convert between integer and float kinds only when the value is exactly representable: float64(30) fits an int field, 30.7, NaN, ±Inf, an out-of-range value or a negative value into an unsigned field do not; an integer too large to be exact in the float field is rejected; a float64 into a float32 field is rounded (only a magnitude beyond float32's range is rejected);
- a json.Number (from json.Decoder.UseNumber) fits a numeric field under the same rules, parsed exactly: an integer field takes only an integer literal ("30", not "30.0" or "3e1"), so an integer above 2^53 is not rounded as a float64 would be; a non-numeric json.Number, or one for a non-numeric field, does not fit;
- otherwise a conversion is allowed only between types of the same kind (e.g. string into a named string type) or between string and []byte, so an int is never turned into a one-rune string.
For a pointer field the value may be given either as a pointer or as the pointee; the stored value is the pointee (nil for a nil pointer), which is what the driver binds. nil is accepted only for fields that can hold NULL: pointers, interfaces, maps, slices, and NULL-style structs whose pointer implements sql.Scanner and that have an exported bool field named Valid (sql.NullString, sql.Null[T], pgtype.Text, ...); other Scanner types such as uuid.UUID or jsoncol.JSON[T] reject nil (use a pointer field for a nullable column). A string is not parsed into a time.Time: decode such values before calling Set.
type Grid ¶
type Grid[T any] struct { // contains filtered or unexported fields }
Grid[T] builds gohan queries from a GridQuery against T's grid-flagged fields (grid:"sort"/"filter"/"search"). Only such fields are addressable by alias; every other field, tagged or not, answers "field is not valid".
func NewGrid ¶
NewGrid builds a Grid[T]. It fails if any grid-flagged field of T has a duplicate, empty or "-" alias, or if a searchable field is neither string-kind nor a database/sql/driver.Valuer.
func (*Grid[T]) AddFilterFunc ¶
func (g *Grid[T]) AddFilterFunc(dbField string, f GridFilterFunc) *Grid[T]
AddFilterFunc registers a filter function for dbField (T's db column name, not its alias).
func (*Grid[T]) Build ¶
func (g *Grid[T]) Build(base *gohan.SelectBuilder, q *GridQuery) (*gohan.SelectBuilder, error)
Build validates q, then applies it to base: filters (sorted alias order, ANDed with base's own conditions), search (one HasPrefix/HasSuffix/ Contains per searchable field, ORed), sort (Sort order, or SortFields in alias order; default direction desc), then WithTiebreaker's columns, then paging (after the WithMaxLimit cap).
func (*Grid[T]) Conds ¶ added in v0.12.0
Conds validates q (as ValidQuery does) and returns the WHERE conditions Build would add for it: one per filter (sorted alias order), then the ORed search expression, if any. Sort and paging are not included, so the result can feed a COUNT of the rows the query pages over. An empty result means q filters nothing.
func (*Grid[T]) ValidQuery ¶
ValidQuery validates q against g's spec: paging, then filters (sorted alias order), then sorts (in the order they apply), then search. Filters and sorts report "field is not valid" for an unaddressable alias, then "field is not filterable"/"not sortable"; filter values are checked against the allowlist before any filter func runs, and a filter func's result is checked too. A configuration error (WithMaxLimit, WithTiebreaker) is returned as is, not as a GridError.
func (*Grid[T]) WithCaseInsensitiveSearch ¶ added in v0.12.0
WithCaseInsensitiveSearch makes search match regardless of case: ILIKE on PostgreSQL and ClickHouse, LIKE on SQLite (which folds ASCII letters only). The default is case-sensitive LIKE (except on SQLite, whose LIKE ignores ASCII case either way).
func (*Grid[T]) WithMaxLimit ¶
WithMaxLimit caps GridQuery.Limit: Limit == 0 or Limit > n is treated as Limit == n. A new Grid uses DefaultMaxLimit; n == 0 removes the cap, so Limit == 0 returns every row.
func (*Grid[T]) WithTiebreaker ¶ added in v0.12.0
WithTiebreaker appends cols (T's db column names, which need not be grid-flagged), ascending, to every query's ORDER BY after the client's sort fields, skipping any the client already sorts by. Use a unique key so that rows with equal sort values keep a stable order across LIMIT/OFFSET pages. A column T does not map makes ValidQuery and Build fail; each call replaces the previous one.
type GridError ¶
type GridError struct {
Scope string `json:"scope"`
Field string `json:"field"`
Message string `json:"message"`
}
GridError is returned by ValidQuery/Build/NewGridQuery (and Repository.QueryGridKeyset) for an invalid request. Scope is "query", "filter", "sort", "search" or "cursor" (an invalid pagination cursor, for which errors.Is(err, ErrInvalidCursor) holds); Field is the offending alias, or empty when the error is not about one field.
type GridFilterFunc ¶
GridFilterFunc translates a GridQuery filter value to a db-compatible value. It is called both by ValidQuery and by Build, and must therefore be pure (no side effects, same input always yields the same output): Build re-runs it and uses its result rather than reusing ValidQuery's call.
type GridQuery ¶
type GridQuery struct {
SearchType uint `json:"searchType"`
SearchText string `json:"searchText,omitempty"`
FilterFields map[string]any `json:"filterFields,omitempty"`
SortFields map[string]string `json:"sortFields,omitempty"`
Sort []SortField `json:"sort,omitempty"`
Offset uint `json:"offset,omitempty"`
Limit uint `json:"limit,omitempty"`
}
GridQuery is the client-supplied request a Grid turns into a query: filters, search text, sort and paging. Every field is attacker-controlled input. Limit == 0, or a Limit above the grid's cap (DefaultMaxLimit unless changed with Grid.WithMaxLimit), is treated as the cap. Sort takes the sort fields in precedence order; SortFields is the older map form, applied in alias order, and the two cannot be combined. JSON numbers decode as float64, so {"id": 3.9} matches id 3 on PostgreSQL (pgx truncates); register a filter func on integer id columns to reject non-integer input if that matters.
func NewGridQuery ¶
NewGridQuery builds a GridQuery, rejecting an out-of-range searchType.
type KeysetKey ¶ added in v0.12.0
KeysetKey is one key of a keyset (cursor) pagination order: a column of the record type and its direction.
type KeysetPage ¶ added in v0.12.0
type KeysetPage[T any] struct { Items []*T `json:"items"` NextCursor string `json:"nextCursor"` HasMore bool `json:"hasMore"` }
KeysetPage is one page of a keyset (cursor) paginated listing. Items is never nil. NextCursor fetches the next page and is "" when HasMore is false.
type Querier ¶
type Querier interface {
// Dialect returns the gohan.Dialect statements are built against.
Dialect() gohan.Dialect
// Exec runs query and returns the number of rows affected.
Exec(ctx context.Context, query string, args ...any) (int64, error)
// Get scans the first row of query into dest (a *T). It returns
// ErrNotFound when the query matches no row.
Get(ctx context.Context, dest any, query string, args ...any) error
// Select scans every row of query into dest (a *[]*T).
Select(ctx context.Context, dest any, query string, args ...any) error
// QueryInt64 scans the first column of the first row of query. It
// returns ErrNotFound when the query matches no row.
QueryInt64(ctx context.Context, query string, args ...any) (int64, error)
}
Querier executes gohan-built SQL and scans results back into Go values. It is the sole abstraction dbx.Repository depends on, so a repository can be bound to a plain connection, a transaction, or (via BatchInserter) an adapter with a bulk-insert fast path.
type RecordChecker ¶ added in v0.12.0
type RecordChecker interface {
// CheckRecord returns a non-nil error if any column gohan.RecordColumns
// reports for t (a struct type) would not map to the same field under
// the Querier's own rules.
CheckRecord(t reflect.Type) error
}
RecordChecker is implemented by a Querier that scans rows into (or reads rows from) a record through its own field-mapping rules rather than the `db` tag rules gohan builds column lists from — for example provider/clickhouse's Querier, which goes through clickhouse-go's `ch`-tag struct mapper. NewRepository calls CheckRecord with the record type once its columns are resolved and fails with the returned error, so a record whose columns the Querier would map to different fields is rejected at setup instead of silently scanning into the wrong fields.
type Repository ¶
type Repository[T any] struct { // contains filtered or unexported fields }
Repository is a typed, generic repository over T, backed by a Querier and built entirely through gohan (values always bound, identifiers always escaped). See package doc for the ErrNotFound and no-unfiltered-write semantics, and for caller obligations WithTx cannot enforce.
func NewRepository ¶
func NewRepository[T any](q Querier, table string) (*Repository[T], error)
NewRepository builds a Repository[T] bound to q, for table. T must be a struct type (else ErrNotStruct); its columns are resolved via gohan.RecordColumns, which rejects field shapes db/field and sqlx would disagree on (returned unchanged: gohan.ErrRecordShape, gohan.ErrDuplicateColumn). A record type with zero mapped columns fails with ErrNoColumns. The base "SELECT <columns> FROM <table>" statement is built once against q.Dialect(), so an invalid table name or unknown dialect surfaces here, not at the first query. When q implements RecordChecker, its CheckRecord error for T is returned unchanged.
Example ¶
ExampleNewRepository shows the shape of a dbx repository over a database/sql client: FromClient resolves the dialect from the client's driver, NewRepository binds a table, and every call takes a ctx. This example only compiles (no // Output: line) — it opens a real SQLite file to keep the code exactly what a caller would write, but does not print anything comparable across environments.
package main
import (
"context"
"os"
"path/filepath"
"github.com/oddbit-project/blueprint/dbx"
"github.com/oddbit-project/blueprint/provider/sqlite"
"github.com/oddbit-project/gohan"
)
// exampleUser is the record type used by ExampleNewRepository.
type exampleUser struct {
ID int64 `db:"id,auto"`
Name string `db:"name"`
}
// ExampleNewRepository shows the shape of a dbx repository over a
// database/sql client: FromClient resolves the dialect from the client's
// driver, NewRepository binds a table, and every call takes a ctx. This
// example only compiles (no // Output: line) — it opens a real SQLite
// file to keep the code exactly what a caller would write, but does not
// print anything comparable across environments.
func main() {
dbPath := filepath.Join(os.TempDir(), "blueprint-dbx-example.db")
_ = os.Remove(dbPath)
defer func() { _ = os.Remove(dbPath) }()
cfg := sqlite.NewClientConfig()
cfg.DSN = dbPath
client, err := sqlite.NewClient(cfg)
if err != nil {
panic(err)
}
defer client.Disconnect()
ctx := context.Background()
if _, err := client.Db().ExecContext(ctx, `CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`); err != nil {
panic(err)
}
q, err := dbx.FromClient(client)
if err != nil {
panic(err)
}
repo, err := dbx.NewRepository[exampleUser](q, "users")
if err != nil {
panic(err)
}
if err := repo.Insert(ctx, &exampleUser{Name: "alice"}); err != nil {
panic(err)
}
user, err := repo.GetBy(ctx, map[string]any{"name": "alice"})
if err != nil {
panic(err)
}
_ = user
users, err := repo.List(ctx, repo.Select().Where(gohan.Col("name").HasPrefix("al")))
if err != nil {
panic(err)
}
_ = users
err = dbx.WithTx(ctx, q, nil, func(tx dbx.Querier) error {
txRepo := repo.With(tx)
_, err := txRepo.Delete(ctx, gohan.Col("id").Eq(user.ID))
return err
})
if err != nil {
panic(err)
}
}
Output:
func (*Repository[T]) Count ¶
Count returns "SELECT COUNT(*) FROM <table>[ WHERE ...]"; where == nil counts every row.
func (*Repository[T]) Delete ¶
Delete requires a non-nil where (else gohan.ErrNoWhere, without touching the database). A trivially-true where (e.g. gohan.And() or Col(x).NotIn()) is rejected downstream by gohan's own WHERE-clause check, not by this guard: there is deliberately no "delete all" method, so a caller who means it uses Exec(ctx, gohan.Delete(table).All()).
func (*Repository[T]) Exec ¶
Exec builds st against the repository's dialect and runs it, returning the number of rows affected.
func (*Repository[T]) Exists ¶
Exists reports whether any row matches where (nil matches every row). It is built as "SELECT COUNT(*) FROM (SELECT 1 FROM <table>[ WHERE ...] LIMIT 1) AS "e"" so QueryInt64 always reads a COUNT column: on ClickHouse, COUNT(*) returns UInt64 but a bare SELECT 1 returns UInt8, and QueryInt64's scan target only accepts the former.
func (*Repository[T]) Get ¶
func (r *Repository[T]) Get(ctx context.Context, q *gohan.SelectBuilder) (*T, error)
Get runs q (r.Select() when nil) with an added Limit(1) and returns the first matching row, or (nil, ErrNotFound) when none matches.
func (*Repository[T]) GetBy ¶
GetBy is Get filtered by an equality match on fields, whose keys must all be repository columns (else ErrUnknownColumn, checked before any query).
func (*Repository[T]) Insert ¶
func (r *Repository[T]) Insert(ctx context.Context, records ...*T) error
Insert writes records. Zero records is a no-op. If the bound Querier implements BatchInserter, InsertBatch is used instead of building INSERT statements (also with WithGroupedInserts). Otherwise records are chunked at perChunk = Dialect().MaxArgs() / <T's full column count, auto columns included> (all records in one statement when MaxArgs() == 0); more than one chunk runs atomically through WithTx, which joins an existing transaction, begins a new one, or fails with ErrTxUnsupported — Insert never issues a silent non-atomic multi-statement write. Each chunk's column set is decided independently by its first record (gohan's ErrInconsistentOmit still applies within a chunk, unless the repository was built with WithGroupedInserts).
func (*Repository[T]) InsertIgnore ¶ added in v0.12.0
InsertIgnore inserts rec with "ON CONFLICT [(conflict...)] DO NOTHING" and reports whether a row was inserted (rows affected > 0), so a conflicting row is skipped rather than failing. With no conflict columns the clause is untargeted and any unique or exclusion constraint violation is ignored. conflict names must all be repository columns (else ErrUnknownColumn, checked before any query). Dialects without gohan's FeatureUpsert (ClickHouse) fail with gohan.ErrUnsupported.
func (*Repository[T]) InsertReturning ¶
func (r *Repository[T]) InsertReturning(ctx context.Context, rec *T) (*T, error)
InsertReturning inserts rec and scans every repository column back from RETURNING into a new T. Dialects without gohan's FeatureReturning fail with gohan.ErrUnsupported.
func (*Repository[T]) List ¶
func (r *Repository[T]) List(ctx context.Context, q *gohan.SelectBuilder) ([]*T, error)
List runs q (r.Select() when nil) and returns every matching row. It never returns a nil slice.
func (*Repository[T]) ListBy ¶
ListBy is List filtered by an equality match on fields, whose keys must all be repository columns (else ErrUnknownColumn, checked before any query).
func (*Repository[T]) ListKeyset ¶ added in v0.12.0
func (r *Repository[T]) ListKeyset(ctx context.Context, where gohan.Expr, keys []KeysetKey, pageSize int, cursor string) (*KeysetPage[T], error)
ListKeyset returns one page of the rows matching where (nil or gohan.IsEmpty for every row), ordered by keys, starting after cursor ("" for the first page). keys must identify rows uniquely (end them with a unique column), their columns must be NOT NULL, and each field's Go type must be an integer (not uintptr), a string, a time.Time or a uuid.UUID (named types by their kind; no pointers and no other driver.Valuer or sql.Scanner types), else ErrInvalidKeysetKey. A pageSize below 1 means DefaultPageSize; above DefaultMaxLimit it is clamped to DefaultMaxLimit.
Keys and cursor are checked before any query: an unknown column fails with ErrUnknownColumn, a malformed cursor, or one minted for another table, key list or key type, with ErrInvalidCursor. The cursor is not signed or encrypted: its key values are visible to clients, so never use a secret column as a key. Two fetched rows with the same key values fail with ErrKeysetNotUnique, and a next-page cursor longer than MaxCursorBytes with ErrCursorTooLarge.
func (*Repository[T]) Querier ¶
func (r *Repository[T]) Querier() Querier
Querier returns the repository's bound Querier.
func (*Repository[T]) QueryGrid ¶
QueryGrid builds r.Select() through g.Build(_, q) (which validates q against g's spec) and runs the result through List.
func (*Repository[T]) QueryGridKeyset ¶ added in v0.12.0
func (r *Repository[T]) QueryGridKeyset(ctx context.Context, g *Grid[T], q *GridQuery, cursor string) (*KeysetPage[T], error)
QueryGridKeyset is QueryGrid with keyset (cursor) pagination: it returns one page of the rows q's filters and search match, in QueryGrid's order (q's sort fields, then g's WithTiebreaker columns), starting after cursor ("" for the first page). q.Limit is the page size, capped as QueryGrid caps it (DefaultPageSize when the grid has no cap and q.Limit is 0).
g must have a tiebreaker (WithTiebreaker) whose columns identify rows uniquely, else ErrInvalidKeysetKey; two fetched rows with the same key values fail with ErrKeysetNotUnique. Every key must be of a Go type ListKeyset accepts: a client sort field that is not fails with a GridError (scope "sort"), a tiebreaker that is not with ErrInvalidKeysetKey. q.Offset must be 0, at most MaxKeysetKeys keys may apply, and the page size must be below math.MaxInt64 (reachable only with WithMaxLimit(0)), else a GridError. q is validated as QueryGrid validates it, and a cursor that is malformed or was minted for another sort fails with a GridError of scope "cursor" (errors.Is(err, ErrInvalidCursor)); all before any query. The cursor survives a change of filters or search. A next-page cursor longer than MaxCursorBytes fails the page with ErrCursorTooLarge, and a string key that is not valid UTF-8 with ErrInvalidKeysetKey: the client picks the sort, so bound the length of every sortable string column.
func (*Repository[T]) QueryGridWithCount ¶ added in v0.12.0
func (r *Repository[T]) QueryGridWithCount(ctx context.Context, g *Grid[T], q *GridQuery) ([]*T, int64, error)
QueryGridWithCount returns QueryGrid's rows and the total number of rows q's filters and search match, ignoring its sort and paging: the total is counted with Count over g.Conds(q), so the COUNT carries no ORDER BY, LIMIT or OFFSET. q is validated before any query runs. Rows and total are read by two separate statements, not in one transaction or snapshot, so the total can disagree with the rows under concurrent writes. If that matters, run it through a repository bound (With) to a transaction whose statements share one snapshot: on PostgreSQL that needs REPEATABLE READ or SERIALIZABLE isolation (WithTx(ctx, q, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}, fn)), since READ COMMITTED takes a snapshot per statement; on SQLite any transaction will do. ClickHouse has no transactions, so there the total is always a separate read.
func (*Repository[T]) Select ¶
func (r *Repository[T]) Select() *gohan.SelectBuilder
Select returns "SELECT <T's columns> FROM <table>", the starting point for custom queries passed to Get/List.
func (*Repository[T]) Table ¶
func (r *Repository[T]) Table() string
Table returns the repository's table name.
func (*Repository[T]) Update ¶
func (r *Repository[T]) Update(ctx context.Context, rec *T, where gohan.Expr, opts ...gohan.RecordOption) (int64, error)
Update sets every non-auto field of rec (per opts) and requires a non-nil where (else gohan.ErrNoWhere, without touching the database).
func (*Repository[T]) UpdateFields ¶
func (r *Repository[T]) UpdateFields(ctx context.Context, fields map[string]any, where gohan.Expr) (int64, error)
UpdateFields sets fields (whose keys must all be repository columns, else ErrUnknownColumn) and requires a non-nil where (else gohan.ErrNoWhere). Both guards are checked before any query.
func (*Repository[T]) UpdateReturning ¶ added in v0.12.0
func (r *Repository[T]) UpdateReturning(ctx context.Context, fields map[string]any, where gohan.Expr) ([]*T, error)
UpdateReturning is UpdateFields with "RETURNING <T's columns>": it returns every updated row as a new T, as the database left it (including values set by triggers), and an empty, non-nil slice when nothing matched. The same guards as UpdateFields apply before any query (ErrUnknownColumn, gohan.ErrNoWhere). Dialects without gohan's FeatureUpdate and FeatureReturning (ClickHouse) fail with gohan.ErrUnsupported.
func (*Repository[T]) Upsert ¶
func (r *Repository[T]) Upsert(ctx context.Context, rec *T, conflict []string, update ...string) error
Upsert inserts rec with "ON CONFLICT (conflict...) DO UPDATE SET ...", updating the columns named by update, or (if update is empty) every column rec's INSERT would actually write minus conflict — never a column the INSERT omitted, which would overwrite existing data with its default. If that leaves nothing to update, the statement is "DO NOTHING". conflict and update names must all be repository columns (else ErrUnknownColumn, checked before any query).
func (*Repository[T]) UpsertReturning ¶ added in v0.12.0
func (r *Repository[T]) UpsertReturning(ctx context.Context, rec *T, conflict []string, update ...string) (*T, error)
UpsertReturning is Upsert with "RETURNING <T's columns>", scanned into a new T: the row as inserted or as updated, including values the database filled in (defaults, triggers, ...). rec itself is never written to, so a failed scan cannot leave it half-updated. When the statement resolves to "DO NOTHING" (every written column is a conflict column) and the row already exists, the database returns no row and UpsertReturning fails with ErrNotFound. Dialects without gohan's FeatureUpsert and FeatureReturning (ClickHouse) fail with gohan.ErrUnsupported.
func (*Repository[T]) With ¶
func (r *Repository[T]) With(q Querier) *Repository[T]
With returns a copy of the repository bound to q instead (e.g. a transaction). See the package doc for why this must be used inside WithTx instead of the outer repository.
func (*Repository[T]) WithGroupedInserts ¶ added in v0.12.0
func (r *Repository[T]) WithGroupedInserts() *Repository[T]
WithGroupedInserts returns a copy of the repository whose Insert accepts records that omit different columns (omitnil/omitempty fields): records are grouped by the set of columns their INSERT writes, and each group is chunked and inserted as Insert would; a record that writes no column at all is inserted on its own as "INSERT INTO <table> DEFAULT VALUES". When this takes more than one statement, they all run atomically through WithTx (ErrTxUnsupported if the Querier cannot begin a transaction). Groups are inserted in order of their first record, so rows are not written in argument order across groups (auto-generated ids follow the groups, not the arguments). Without it, Insert fails such a call with gohan.ErrInconsistentOmit (or gohan.ErrNoColumns for a record that writes no column). The option does not apply to a BatchInserter Querier (the ClickHouse adapter), whose InsertBatch still rejects mixed records: several ClickHouse batches cannot be made atomic. The receiver is not modified; With keeps the option.
type RetryClassifier ¶ added in v0.12.0
type RetryClassifier interface {
// IsRetryable reports whether a transaction that failed with err may
// succeed if run again from the start.
IsRetryable(err error) bool
}
RetryClassifier is implemented by Querier adapters that can tell a transient transaction failure (serialization failure, deadlock, lock contention) from a permanent one. WithTxRetry only retries when the Querier it is given implements RetryClassifier and IsRetryable reports true for the attempt's error.
type SQLQuerier ¶
type SQLQuerier struct {
// contains filtered or unexported fields
}
SQLQuerier implements Querier and TxBeginner over a *sqlx.DB.
func FromClient ¶
func FromClient(c *db.SqlClient) (*SQLQuerier, error)
FromClient builds a Querier/TxBeginner from c. The dialect is resolved from c.DriverName via gohan.DialectFor before connecting, so an unknown driver fails with gohan.ErrUnknownDialect without a wasted connection attempt; a ClickHouse dialect fails with ErrDialectDriver (use provider/clickhouse's Querier instead). If c is not yet connected, FromClient connects it. FromClient snapshots c.Conn: call it once at startup, not after a later Disconnect/Connect cycle.
func NewSQL ¶
func NewSQL(conn *sqlx.DB, d gohan.Dialect) *SQLQuerier
NewSQL returns a Querier/TxBeginner over conn, building statements for dialect d.
func (*SQLQuerier) Dialect ¶
func (q *SQLQuerier) Dialect() gohan.Dialect
func (*SQLQuerier) IsRetryable ¶ added in v0.12.0
func (q *SQLQuerier) IsRetryable(err error) bool
IsRetryable implements RetryClassifier, classifying err by q's dialect:
- PostgreSQL: a driver error with SQLSTATE 40001 (serialization_failure) or 40P01 (deadlock_detected), matched through its SQLState() method, which both pgx's *pgconn.PgError and lib/pq's *pq.Error provide;
- SQLite: a driver error whose Code() (modernc.org/sqlite's *Error, as used by provider/sqlite) is SQLITE_BUSY or SQLITE_LOCKED, including their extended codes such as SQLITE_BUSY_SNAPSHOT.
Any other error, or dialect, is not retryable.
func (*SQLQuerier) QueryInt64 ¶
type SortField ¶ added in v0.12.0
SortField is one entry of GridQuery.Sort: a field alias and its order (SortAscending or SortDescending; empty means SortDescending).
type TxBeginner ¶
TxBeginner is a Querier that can start a new transaction.