dbx

package
v0.11.1 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 15 Imported by: 2

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 returns (nil, ErrNotFound) when no row matches; 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 and UpdateFields 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, 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

Examples

Constants

View Source
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")
)
View Source
const (
	SortAscending  = "asc"
	SortDescending = "desc"

	SearchNone  = 0
	SearchStart = 1
	SearchEnd   = 2
	SearchAny   = 3

	DefaultPageSize = 100

	// MaxFilterValues caps the number of elements a []any filter value may
	// contain.
	MaxFilterValues = 1000
	// MaxSearchText caps SearchText's length in bytes.
	MaxSearchText = 256
)

Variables

View Source
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 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.

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

func NewGrid[T any]() (*Grid[T], error)

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 — no searchable fields means no search condition at all), sort (sorted alias order, default direction desc), then paging (after WithMaxLimit's cap).

func (*Grid[T]) ValidQuery

func (g *Grid[T]) ValidQuery(q *GridQuery) error

ValidQuery validates q against g's spec: filters (sorted alias order), then sorts (sorted alias order), 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.

func (*Grid[T]) WithMaxLimit

func (g *Grid[T]) WithMaxLimit(n uint) *Grid[T]

WithMaxLimit caps GridQuery.Limit: n == 0 (the default) applies no cap; otherwise Limit == 0 or Limit > n is treated as Limit == n.

type GridError

type GridError struct {
	Scope   string `json:"scope"`
	Field   string `json:"field"`
	Message string `json:"message"`
}

GridError is returned by ValidQuery/Build/NewGridQuery for an invalid request. Scope is "query", "filter", "sort" or "search"; Field is the offending alias (empty for a query- or search-scoped error).

func (GridError) Error

func (err GridError) Error() string

Error implements the error interface.

type GridFilterFunc

type GridFilterFunc func(lookupValue any) (any, error)

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"`
	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 returns every row, unless Grid.WithMaxLimit is set, in which case it is treated like Limit == n. 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

func NewGridQuery(searchType uint, limit uint, offset uint) (*GridQuery, error)

NewGridQuery builds a GridQuery, rejecting an out-of-range searchType.

func (*GridQuery) Page

func (g *GridQuery) Page(page, itemsPerPage int)

Page sets Offset and Limit from a 1-based page number and page size.

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

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

func (*Repository[T]) Count

func (r *Repository[T]) Count(ctx context.Context, where gohan.Expr) (int64, error)

Count returns "SELECT COUNT(*) FROM <table>[ WHERE ...]"; where == nil counts every row.

func (*Repository[T]) Delete

func (r *Repository[T]) Delete(ctx context.Context, where gohan.Expr) (int64, error)

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

func (r *Repository[T]) Exec(ctx context.Context, st gohan.Statement) (int64, error)

Exec builds st against the repository's dialect and runs it, returning the number of rows affected.

func (*Repository[T]) Exists

func (r *Repository[T]) Exists(ctx context.Context, where gohan.Expr) (bool, error)

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

func (r *Repository[T]) GetBy(ctx context.Context, fields map[string]any) (*T, error)

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

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

func (r *Repository[T]) ListBy(ctx context.Context, fields map[string]any) ([]*T, error)

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]) Querier

func (r *Repository[T]) Querier() Querier

Querier returns the repository's bound Querier.

func (*Repository[T]) QueryGrid

func (r *Repository[T]) QueryGrid(ctx context.Context, g *Grid[T], q *GridQuery) ([]*T, error)

QueryGrid builds r.Select() through g.Build(_, q) (which validates q against g's spec) and runs the result through List.

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

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

func (q *SQLQuerier) BeginTx(ctx context.Context, opts *sql.TxOptions) (TxQuerier, error)

func (*SQLQuerier) Dialect

func (q *SQLQuerier) Dialect() gohan.Dialect

func (*SQLQuerier) Exec

func (q *SQLQuerier) Exec(ctx context.Context, query string, args ...any) (int64, error)

func (*SQLQuerier) Get

func (q *SQLQuerier) Get(ctx context.Context, dest any, query string, args ...any) error

func (*SQLQuerier) QueryInt64

func (q *SQLQuerier) QueryInt64(ctx context.Context, query string, args ...any) (int64, error)

func (*SQLQuerier) Select

func (q *SQLQuerier) Select(ctx context.Context, dest any, query string, args ...any) error

type TxBeginner

type TxBeginner interface {
	BeginTx(ctx context.Context, opts *sql.TxOptions) (TxQuerier, error)
}

TxBeginner is a Querier that can start a new transaction.

type TxQuerier

type TxQuerier interface {
	Querier
	Commit() error
	Rollback() error
}

TxQuerier is a Querier already running inside a transaction.

Jump to

Keyboard shortcuts

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