dbx

package module
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Mar 21, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

README

DBX - Opinionated database utilities for Go

Go Reference

Convenience layer on top of sqlx. No ORM — just prepared statements, migrations, and pagination.

Table of contents

Opening a database

import "github.com/borud/dbx"

//go:embed migrations/*.sql
var migrationsFS embed.FS

// Returns *sqlx.DB
db, err := dbx.OpenSQLX(
    dbx.WithDSN(":memory:"),
    dbx.WithDriver("sqlite"),
    dbx.WithMigrations(migrationsFS, "migrations"),
)

// Returns *sql.DB
db, err := dbx.Open(
    dbx.WithDSN(":memory:"),
    dbx.WithDriver("sqlite"),
    dbx.WithMigrations(migrationsFS, "migrations"),
)

Filesystem migrations (instead of embedded):

dbx.WithMigrations(os.DirFS("migrations"), ".")

Pragmas:

dbx.WithPragmas(
    "PRAGMA foreign_keys = ON",
    "PRAGMA synchronous = NORMAL",
)

Custom migration driver (e.g. MySQL):

import mysql "github.com/golang-migrate/migrate/v4/database/mysql"

dbx.WithMigrationDriver("mysql", "mysql",
    func(db *sql.DB) (database.Driver, error) {
        return mysql.WithInstance(db, &mysql.Config{})
    }),
Migration files

Numbered SQL files in a directory:

migrations/0001_init.up.sql
migrations/0002_add_foo_field.up.sql
migrations/0003_add_bar_table.up.sql
```text
migrations/0001_init.up.sql
migrations/0002_add_foo_field.up.sql
migrations/0003_add_bar_table.up.sql

Only up migrations are supported.

Prepared statements

Function types that wrap prepared statements with generics:

Type Use case Constructor
ExecFunc Simple ops (DELETE, etc.) with positional args NewExecFunc
NamedExecFunc[T] INSERT/UPDATE with a struct, returns Result NewNamedExecFunc[T]
QueryRowxFunc[T] Single-row query with positional args NewQueryRowxFunc[T]
EntityQueryRowxFunc[T] Struct in, struct out (e.g. RETURNING *) NewEntityQueryRowxFunc[T]
SelectFunc[T] Bounded result sets NewSelectFunc[T]
QueryxIteratorFunc[T] Streaming large result sets via range iterator NewQueryxIteratorFunc[T]
PaginatedSelectFunc[T] Cursor-based paginated SELECT (see Pagination) NewPaginatedSelectFunc[T]

Note: NewPaginatedSelectFunc constructs SQL from its table, orderCol, and baseWhere arguments. The table and column names are interpolated directly into queries — they are validated as safe SQL identifiers (ASCII letters, digits, underscores only) and the constructor panics on invalid input. The baseWhere clause is passed through as-is, so use ? placeholders for any user-supplied values.

Example: CRUD operations
type Record struct {
    Name string `db:"name"`
    TS   int64  `db:"ts"`
}

// Define your storage interface as function fields
type Storage struct {
    Add    dbx.NamedExecFunc[Record]
    AddRet dbx.EntityQueryRowxFunc[Record]
    Get    dbx.QueryRowxFunc[Record]
    Update dbx.NamedExecFunc[Record]
    Delete dbx.ExecFunc
    List   dbx.SelectFunc[Record]
}

// Wire up
ops := Storage{
    Add:    dbx.NewNamedExecFunc[Record](db, "INSERT INTO foo (name,ts) VALUES (:name,:ts)"),
    AddRet: dbx.NewEntityQueryRowxFunc[Record](db, "INSERT INTO foo (name,ts) VALUES (:name,:ts) RETURNING *"),
    Get:    dbx.NewQueryRowxFunc[Record](db, "SELECT * FROM foo WHERE name = ?"),
    Update: dbx.NewNamedExecFunc[Record](db, "UPDATE foo SET ts = :ts WHERE name = :name"),
    Delete: dbx.NewExecFunc(db, "DELETE FROM foo WHERE name = ?"),
    List:   dbx.NewSelectFunc[Record](db, "SELECT * FROM foo ORDER BY ts ASC"),
}

// Insert
res, err := ops.Add(ctx, Record{Name: "alice", TS: 1})
lastID, _ := res.LastInsertID()

// Insert with RETURNING
rec, err := ops.AddRet(ctx, Record{Name: "bob", TS: 2})

// Get
rec, err := ops.Get(ctx, "alice")

// Update
res, err := ops.Update(ctx, Record{Name: "alice", TS: 99})

// Delete
err := ops.Delete(ctx, "alice")

// List all
records, err := ops.List(ctx)
Streaming with QueryxIteratorFunc

For large result sets that shouldn't be loaded into memory at once:

listIter := dbx.NewQueryxIteratorFunc[Record](db, "SELECT * FROM foo ORDER BY ts ASC")

ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

for rec, err := range listIter(ctx) {
    // context cancellation terminates the loop with an error
    if err != nil {
        break
    }
    fmt.Println(rec)
}
Result type

NamedExecFunc returns dbx.Result which wraps sql.Result:

res, err := ops.Add(ctx, record)
if id, ok := res.LastInsertID(); ok { ... }
if n, ok := res.RowsAffected(); ok { ... }

Transactions

The prepared-statement functions above operate on *sqlx.DB directly. When you need multiple operations to succeed or fail atomically, use the transaction variants.

RunTx helper

RunTx handles begin, commit, and rollback. If the callback returns an error (or panics), the transaction is rolled back; otherwise it is committed.

err := dbx.RunTx(ctx, db, func(tx *sqlx.Tx) error {
    // all operations here run in a single transaction
    return nil
})
Transaction function types

Each prepared-statement type has a Tx counterpart that takes a *sqlx.Tx instead of being bound to a *sqlx.DB. Since the transaction does not exist at construction time, these functions are not pre-prepared — the SQL is executed directly on each call.

Type Mirrors Constructor
TxExecFunc ExecFunc NewTxExecFunc
TxNamedExecFunc[T] NamedExecFunc[T] NewTxNamedExecFunc[T]
TxQueryRowxFunc[T] QueryRowxFunc[T] NewTxQueryRowxFunc[T]
TxEntityQueryRowxFunc[T] EntityQueryRowxFunc[T] NewTxEntityQueryRowxFunc[T]
Example: atomic multi-table insert

A common pattern is inserting related rows across tables atomically. The Tx function types can be wired up alongside their non-transactional counterparts:

type DeviceStore struct {
    Add    dbx.NamedExecFunc[Device]        // normal, non-tx insert
    AddTx  dbx.TxNamedExecFunc[Device]      // same SQL, but for use in transactions
    Get    dbx.QueryRowxFunc[Device]
    GetTx  dbx.TxQueryRowxFunc[Device]
}

type PSKStore struct {
    AddTx dbx.TxNamedExecFunc[PSK]
}

// Wire up (typically at init time)
devices := DeviceStore{
    Add:   dbx.NewNamedExecFunc[Device](db, "INSERT INTO devices (id,name) VALUES(:id,:name)"),
    AddTx: dbx.NewTxNamedExecFunc[Device]("INSERT INTO devices (id,name) VALUES(:id,:name)"),
    Get:   dbx.NewQueryRowxFunc[Device](db, "SELECT * FROM devices WHERE id = ?"),
    GetTx: dbx.NewTxQueryRowxFunc[Device]("SELECT * FROM devices WHERE id = ?"),
}

psks := PSKStore{
    AddTx: dbx.NewTxNamedExecFunc[PSK]("INSERT INTO psk (identity,device_id,key) VALUES(:identity,:device_id,:key)"),
}

// Atomic insert: device + initial PSK
err := dbx.RunTx(ctx, db, func(tx *sqlx.Tx) error {
    if _, err := devices.AddTx(ctx, tx, device); err != nil {
        return err
    }
    _, err := psks.AddTx(ctx, tx, psk)
    return err
})

Pagination

Cursor-based pagination via PaginatedSelectFunc[T].

Setup
list := dbx.NewPaginatedSelectFunc[Record](
    db,
    "foo",                                              // table name
    "ts",                                               // ORDER BY column (used as cursor)
    "",                                                 // base WHERE clause ("" for none)
    func(r Record) string { return strconv.FormatInt(r.TS, 10) }, // cursor extractor
)

With a WHERE clause:

list := dbx.NewPaginatedSelectFunc[Record](
    db, "foo", "ts", "WHERE name = ?",
    func(r Record) string { return strconv.FormatInt(r.TS, 10) },
)

// Pass WHERE args after PageRequest
rows, page, err := list(ctx, dbx.PageRequest{PageSize: 10}, "alice")
Fetching pages
// All rows (no pagination)
rows, page, err := list(ctx, dbx.PageRequest{})

// First page
rows, page, err := list(ctx, dbx.PageRequest{PageSize: 10})

// Next page (forward)
rows, page, err := list(ctx, dbx.PageRequest{PageSize: 10, After: page.LastCursor})

// Previous page (backward)
rows, page, err := list(ctx, dbx.PageRequest{PageSize: 10, Before: page.FirstCursor})
PageRequest / PageResponse
type PageRequest struct {
    PageSize int    // 0 = unlimited, clamped to MaxPageSize (1000)
    After    string // forward cursor (mutually exclusive with Before)
    Before   string // backward cursor (mutually exclusive with After)
}

type PageResponse struct {
    HasMore     bool   // true if more pages exist
    FirstCursor string // cursor of first item in page
    LastCursor  string // cursor of last item in page
}
  • After and Before are mutually exclusive (setting both returns an error)
  • PageSize > MaxPageSize (1000) is silently clamped
  • Backward pagination (Before) returns results in ascending order
  • Table and column names are validated as safe SQL identifiers (panics at init on invalid input)
Full pagination loop
var allRows []Record
req := dbx.PageRequest{PageSize: 20}

for {
    rows, page, err := list(ctx, req)
    if err != nil { return err }

    allRows = append(allRows, rows...)

    if !page.HasMore {
        break
    }
    req.After = page.LastCursor
}

Breaking changes in v1.0.0

WithPragmas now takes variadic arguments

The signature changed from WithPragmas([]string{...}) to WithPragmas(...).

Before:

dbx.WithPragmas([]string{
    "PRAGMA foreign_keys = ON",
    "PRAGMA synchronous = NORMAL",
})

After:

dbx.WithPragmas(
    "PRAGMA foreign_keys = ON",
    "PRAGMA synchronous = NORMAL",
)
ValidSQLIdentifier rejects identifiers starting with a digit

ValidSQLIdentifier("123table") now returns false. This aligns with standard SQL identifier rules. Table and column names passed to NewPaginatedSelectFunc must not start with a digit.

NewNamedExecFunc no longer returns sql.ErrNoRows on zero affected rows

Previously, NewNamedExecFunc returned both a valid Result and sql.ErrNoRows when RowsAffected == 0. This was misleading because the operation succeeded — zero rows affected is a valid outcome for many statements. The Result is still returned and callers should check result.RowsAffected() to detect zero affected rows when needed.

Note: NewExecFunc retains the sql.ErrNoRows behavior since it does not return a Result.

Documentation

Overview

Package dbx provides opinionated database utilities built on top of sqlx. It covers three areas: opening databases with migrations, type-safe prepared statement wrappers using generics, and cursor-based pagination.

Opening a database

Open and OpenSQLX open a database connection and optionally apply SQLite pragmas and schema migrations. Migrations are read from an fs.FS (typically an embedded filesystem) and applied via golang-migrate. Configuration is done through functional options:

db, err := dbx.OpenSQLX(
    dbx.WithDSN(":memory:"),
    dbx.WithDriver("sqlite"),
    dbx.WithPragmas(
        "PRAGMA foreign_keys = ON",
        "PRAGMA synchronous = NORMAL",
    ),
    dbx.WithMigrations(migrationsFS, "migrations"),
)

Prepared statements

The package provides generic function types that wrap prepared statements. Constructors panic on invalid SQL so that wiring errors are caught at init time rather than at runtime.

The underlying prepared statements are captured in closures and cannot be closed individually — they are released when the database connection is closed. This is by design for the recommended init-time wiring pattern:

Transactions

The Tx* function types mirror the prepared-statement types above but execute against a *sqlx.Tx instead of *sqlx.DB. Because the transaction does not exist at construction time, these functions are not pre-prepared — the SQL string is executed directly on each call.

RunTx handles the begin/commit/rollback boilerplate:

addTx := dbx.NewTxNamedExecFunc[Device]("INSERT INTO devices ... VALUES ...")
pskTx := dbx.NewTxNamedExecFunc[PSK]("INSERT INTO psk ... VALUES ...")

err := dbx.RunTx(ctx, db, func(tx *sqlx.Tx) error {
    if _, err := addTx(ctx, tx, device); err != nil {
        return err
    }
    _, err := pskTx(ctx, tx, psk)
    return err
})

Available Tx function types:

Pagination

NewPaginatedSelectFunc builds cursor-based paginated SELECT queries. Table and column names are validated as safe SQL identifiers at construction time. Queries are rebound via sqlx.Rebind for cross-database placeholder compatibility.

Breaking changes in v1.0.0

WithPragmas now accepts variadic string arguments instead of a string slice. Change WithPragmas([]string{...}) to WithPragmas(...).

ValidSQLIdentifier now rejects identifiers that start with a digit, aligning with standard SQL identifier rules.

Index

Constants

View Source
const MaxPageSize = 1000

MaxPageSize is the upper bound on PageRequest.PageSize. Requests exceeding this value are silently clamped.

Variables

View Source
var (
	ErrDatabaseTooNew = errors.New("database may be newer than migrations available in this binary")
	ErrNoDSN          = errors.New("no data source name given")
)

errors

View Source
var ErrMutuallyExclusive = errors.New("after and before are mutually exclusive")

ErrMutuallyExclusive is returned when both After and Before are set in a PageRequest.

Functions

func Open

func Open(opts ...Option) (*sql.DB, error)

Open is a helper for opening a database and possibly applying pragmas, migrations etc.

func OpenSQLX

func OpenSQLX(opts ...Option) (*sqlx.DB, error)

OpenSQLX is a wrapper for Open that returns an *sqlx.DB rather than sql.DB

func RunTx added in v1.1.0

func RunTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error

RunTx executes fn inside a transaction. If fn returns an error or panics the transaction is rolled back; otherwise it is committed.

func ValidSQLIdentifier

func ValidSQLIdentifier(s string) bool

ValidSQLIdentifier reports whether s is safe to interpolate as a SQL identifier. Only ASCII letters, digits, and underscores are allowed, and the identifier must not start with a digit.

Types

type DriverForFunc

type DriverForFunc func(*sql.DB) (database.Driver, string, error)

DriverForFunc returns (driver, migrateDBName, error).

type EntityQueryRowxFunc

type EntityQueryRowxFunc[T any] func(ctx context.Context, entity T) (T, error)

EntityQueryRowxFunc is useful for queries that take some entity and return an entity of the same type. For instance when using RETURNING in SQL statement.

func NewEntityQueryRowxFunc

func NewEntityQueryRowxFunc[T any](db *sqlx.DB, stmt string) EntityQueryRowxFunc[T]

NewEntityQueryRowxFunc creates a new EntityQueryRowxFunc

type ExecFunc

type ExecFunc func(ctx context.Context, args ...any) error

ExecFunc is useful for very simple operations like DELETE with positional arguments.

func NewExecFunc

func NewExecFunc(db *sqlx.DB, stmt string) ExecFunc

NewExecFunc creates an ExecFunc

type NamedExecFunc

type NamedExecFunc[T any] func(ctx context.Context, entity T) (Result, error)

NamedExecFunc is useful for create, update etc where you send an entity in and just want a Result and error back.

func NewNamedExecFunc

func NewNamedExecFunc[T any](db *sqlx.DB, stmt string) NamedExecFunc[T]

NewNamedExecFunc creates a new NamedExecFunc

type Option

type Option func(*config) error

Option is a configuration option callback type

func WithDSN

func WithDSN(dsn string) Option

WithDSN sets the data source name

func WithDriver

func WithDriver(driverName string) Option

WithDriver sets the driver name.

func WithMigrationDriver

func WithMigrationDriver(sqlDriverName string, migrateName string, create func(*sql.DB) (database.Driver, error)) Option

WithMigrationDriver is provided in case you want to use SQL databases beyond those provided in the default config (sqlite, postgres).

func WithMigrations

func WithMigrations(fileSystem fs.FS, path string) Option

WithMigrations sets the migrations filesystem and path within that filesystem. You can either pass an embed.FS or a fs.FS for a OS filesystem path using os.DirFS(path).

func WithPragmas

func WithPragmas(pragmas ...string) Option

WithPragmas appends pragmas to the config. Pragmas are executed as raw SQL statements during database opening, so they must be trusted inputs — never pass user-controlled strings.

type PageRequest

type PageRequest struct {
	// PageSize is the maximum number of rows to return. Zero means unlimited.
	PageSize int
	// After is the cursor for forward pagination (exclusive lower bound).
	// Mutually exclusive with Before. Only applied when PageSize > 0.
	After string
	// Before is the cursor for backward pagination (exclusive upper bound).
	// Mutually exclusive with After. Only applied when PageSize > 0.
	Before string
}

PageRequest carries cursor-based pagination parameters for list operations. A zero PageSize means "return all results" (backward compatible).

After and Before are mutually exclusive: setting both is an error. After requests results after the given cursor (forward pagination), while Before requests results before the given cursor (backward pagination).

Note: After and Before are only applied when PageSize > 0 (i.e. when IsPaginated returns true). Setting a cursor without a page size will return all rows and silently ignore the cursor value.

func (PageRequest) IsPaginated

func (p PageRequest) IsPaginated() bool

IsPaginated returns true when the caller has requested a bounded page.

type PageResponse

type PageResponse struct {
	// HasMore is true when additional pages exist beyond this result set.
	HasMore bool
	// FirstCursor is the cursor of the first item in the returned page.
	FirstCursor string
	// LastCursor is the cursor of the last item in the returned page.
	LastCursor string
}

PageResponse carries pagination metadata returned from list operations.

type PaginatedSelectFunc

type PaginatedSelectFunc[T any] func(ctx context.Context, page PageRequest, args ...any) ([]T, PageResponse, error)

PaginatedSelectFunc performs a cursor-based paginated SELECT query. The args are positional parameters for the base WHERE clause provided at construction time.

func NewPaginatedSelectFunc

func NewPaginatedSelectFunc[T any](db *sqlx.DB, table, orderCol, baseWhere string, cursorFn func(T) string) PaginatedSelectFunc[T]

NewPaginatedSelectFunc creates a PaginatedSelectFunc.

table and orderCol must be valid SQL identifiers (the constructor panics otherwise, consistent with other dbx constructors that panic on invalid input detected at init time).

baseWhere is an optional WHERE clause including the WHERE keyword (e.g. "WHERE tenant_id = ?"). Pass "" to select all rows. baseWhere is interpolated directly into the query; it must be a trusted string — never pass user-controlled input.

cursorFn extracts a cursor string from a result row for populating PageResponse.FirstCursor and LastCursor.

type QueryRowxFunc

type QueryRowxFunc[T any] func(ctx context.Context, args ...any) (T, error)

QueryRowxFunc is useful for queries that return one row and takes positional arguments. For instance get operations on a single row.

func NewQueryRowxFunc

func NewQueryRowxFunc[T any](db *sqlx.DB, stmt string) QueryRowxFunc[T]

NewQueryRowxFunc creates a new QueryRowxFunc

type QueryxIteratorFunc

type QueryxIteratorFunc[T any] func(ctx context.Context, args ...any) func(func(T, error) bool)

QueryxIteratorFunc is useful for queries that return (potentially) large result sets and you want to be able to stream the result.

func NewQueryxIteratorFunc

func NewQueryxIteratorFunc[T any](db *sqlx.DB, stmt string) QueryxIteratorFunc[T]

NewQueryxIteratorFunc creates a new QueryxIteratorFunc

type Result

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

Result holds the outcome of a mutating operation

func NewResult

func NewResult(sqlResult sql.Result) Result

NewResult wraps a sql.Result into a dbx.Result, extracting LastInsertId and RowsAffected if available.

func (Result) LastInsertID

func (r Result) LastInsertID() (int64, bool)

LastInsertID returns the last insert ID if available

func (Result) RowsAffected

func (r Result) RowsAffected() (int64, bool)

RowsAffected returns the number of rows affected if available

type SelectFunc

type SelectFunc[T any] func(ctx context.Context, args ...any) ([]T, error)

SelectFunc is useful for when you perform selects and you know the result set will be small or at least bounded to acceptable size.

func NewSelectFunc

func NewSelectFunc[T any](db *sqlx.DB, stmt string) SelectFunc[T]

NewSelectFunc creates a new SelectFunc

type TxEntityQueryRowxFunc added in v1.1.0

type TxEntityQueryRowxFunc[T any] func(ctx context.Context, tx *sqlx.Tx, entity T) (T, error)

TxEntityQueryRowxFunc executes a named statement inside a transaction that takes an entity and returns an entity of the same type. It mirrors EntityQueryRowxFunc but operates on a *sqlx.Tx.

func NewTxEntityQueryRowxFunc added in v1.1.0

func NewTxEntityQueryRowxFunc[T any](stmt string) TxEntityQueryRowxFunc[T]

NewTxEntityQueryRowxFunc creates a TxEntityQueryRowxFunc for the given SQL statement.

type TxExecFunc added in v1.1.0

type TxExecFunc func(ctx context.Context, tx *sqlx.Tx, args ...any) error

TxExecFunc executes a statement with positional arguments inside a transaction. It mirrors ExecFunc but operates on a *sqlx.Tx.

func NewTxExecFunc added in v1.1.0

func NewTxExecFunc(stmt string) TxExecFunc

NewTxExecFunc creates a TxExecFunc for the given SQL statement. Unlike NewExecFunc the statement is not prepared at construction time since the transaction does not exist yet.

type TxNamedExecFunc added in v1.1.0

type TxNamedExecFunc[T any] func(ctx context.Context, tx *sqlx.Tx, entity T) (Result, error)

TxNamedExecFunc executes a named statement inside a transaction. It mirrors NamedExecFunc but operates on a *sqlx.Tx.

func NewTxNamedExecFunc added in v1.1.0

func NewTxNamedExecFunc[T any](stmt string) TxNamedExecFunc[T]

NewTxNamedExecFunc creates a TxNamedExecFunc for the given SQL statement.

type TxQueryRowxFunc added in v1.1.0

type TxQueryRowxFunc[T any] func(ctx context.Context, tx *sqlx.Tx, args ...any) (T, error)

TxQueryRowxFunc queries a single row inside a transaction with positional arguments. It mirrors QueryRowxFunc but operates on a *sqlx.Tx.

func NewTxQueryRowxFunc added in v1.1.0

func NewTxQueryRowxFunc[T any](stmt string) TxQueryRowxFunc[T]

NewTxQueryRowxFunc creates a TxQueryRowxFunc for the given SQL statement.

Directories

Path Synopsis
examples
open command
Example: opening a database with migrations and pragmas.
Example: opening a database with migrations and pragmas.
paginate command
Example: cursor-based pagination.
Example: cursor-based pagination.
prepared command
Example: prepared statement functions for CRUD operations.
Example: prepared statement functions for CRUD operations.

Jump to

Keyboard shortcuts

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