sqlr

package module
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Sep 5, 2025 License: MIT Imports: 9 Imported by: 0

README

sqlr — a tiny, SQL-first builder & mapper for Go

License Go Report Card Go Reference Version

sqlr is a minimal SQL builder and result mapper designed to stay very close to the SQL you already write. It focuses on keeping things simple: turn :named placeholders into driver args, expand IN (...) automatically, support bulk VALUES, and scan rows into your structs efficiently — all without a heavy ORM or a fluent DSL.

Features:

  • SQL-first, no DSL: you write the SQL, sqlr doesn’t invent a DSL; it just binds and scans.
  • Multiple dialects: Postgres, MySQL, SQLite, SQL Server.
  • Placeholder rendering per dialect: Postgres → $1,$2…; MySQL/SQLite → ?; SQL Server → @p1,@p2….
  • Minimal API surface: New, Write/Writef, Bind, Preview/Build, Exec, ScanOne, ScanAll.
  • Typed scans, fast: struct mapping via db tags or field names, nested struct flattening, pointer/null handling.
  • Bulk insert made simple: :name{a,b,c} emits VALUES (...),(...),... with bound args.
  • Plays well with handcrafted SQL (CTEs, JSON ops, window functions…).
  • No external dependencies: only the standard library.
  • Performance-minded: single-pass parser, sync.Pool builders, cached struct plans, careful allocation.
  • Safe by design: values are never interpolated into SQL strings; everything is parameterized.
  • Concurrency: share one *SQLR across goroutines; each *Builder is single-use.

Installation:

go get github.com/gandaldf/sqlr@latest

Examples:

Quick start
package main

import (
	"database/sql"
	"log"

	_ "github.com/lib/pq"
	"github.com/gandaldf/sqlr"
)

type User struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

func main() {
	db, _ := sql.Open("postgres", "<dsn>")

	var users []User
	err := sqlr.New(sqlr.Postgres).
		Write("SELECT id, name FROM users WHERE id IN (:ids) AND active=:active").
		Bind("ids", []int{1,2,3}).
		Bind("active", true). // later binds can add/override keys
		ScanAll(db, &users)
	if err != nil {
        log.Fatal(err)
    }
}
Execute a statement
res, err := sqlr.New(sqlr.MySQL).
  Write("UPDATE products SET price=:price WHERE id IN (:ids)").
  Bind("price", 999, "ids", []int{7,8,9}).
  Exec(db)
if err != nil { return err }
rows, _ := res.RowsAffected()
Read a single scalar
var count int
err := sqlr.New(sqlr.Postgres).
  Write("SELECT COUNT(*) FROM orders WHERE customer_id=:c AND status=:s").
  Bind("c", 42, "s", "paid").
  ScanOne(db, &count)
One row exactly
var u User
err := sqlr.New(sqlr.Postgres).
  Write("SELECT id, name FROM users WHERE email=:e").
  Bind("e", email).
  ScanOne(db, &u)
// returns sql.ErrNoRows if none; sqlr.ErrMoreThanOneRow if >1
Struct scans (tags, flattening, NULLs)
type Audit struct {
	CreatedAt time.Time `db:"created_at"`
}
type Row struct {
	ID    int     `db:"id"`
	Name  string  `db:"name"`
	Note  *string `db:"note"` // pointer handles NULL
	Audit Audit
}

var out []Row
err := sqlr.New(sqlr.Postgres).
  Write(`SELECT id, name, note, created_at FROM users WHERE active=:a`).
  Bind("a", true).
  ScanAll(db, &out)
  • created_at maps into Audit.CreatedAt via flattening.
  • Pointers become nil when the DB returns NULL.
Bulk insert
type NewUser struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}
rows := []NewUser{{1,"Anna"},{2,"Luca"},{3,"Mia"}}

_, err := sqlr.New(sqlr.SQLite).
  Write("INSERT INTO users (id,name) VALUES :batch{id,name}").
  Bind("batch", rows).
  Exec(db)

The placeholder is called :batch{...} here but it's arbitrary, It's not a keyword, but just a regular named parameter with curly braces.

Expansion in action

sqlr expands at build time based on your bound values. You write :named params; sqlr turns them into the right placeholders for the dialect, expands slices/rows, and builds the final args in one pass.

IN (...) slice expansion
q, args, _ := sqlr.New(sqlr.Postgres).
  Write("SELECT * FROM t WHERE id IN (:ids) AND active=:a").
  Bind("ids", []int{10,11,12}).
  Bind("a", true).
  Preview()

// q (pretty-printed):
// SELECT * FROM t WHERE id IN ($1,$2,$3) AND active=$4
// args: [10 11 12 true]
VALUES :rows{...} bulk expansion
type NewUser struct{ ID int `db:"id"`; Name string `db:"name"` }
rows := []NewUser{{1,"Anna"},{2,"Luca"},{3,"Mia"}}

q, args, _ := sqlr.New(sqlr.Postgres).
  Write("INSERT INTO users (id,name) VALUES :rows{id,name}").
  Bind("rows", rows).
  Preview()

// q:
// INSERT INTO users (id,name) VALUES ($1,$2),($3,$4),($5,$6)
// args: [1 "Anna" 2 "Luca" 3 "Mia"]
Prevent slice expansion (keep one placeholder)
ids := []int64{1,2,3}

_, _, _ = sqlr.New(sqlr.Postgres).
  Write("SELECT * FROM t WHERE id = ANY(:ids)").
  Bind("ids", sqlr.Scalar(ids)). // keeps a single param
  Build()

Using a driver.Valuer (e.g. pq.Array(ids)) also prevents expansion.

Scalar binding via struct tag
// Bind a slice as a single scalar param using the ",scalar" option.
type Filter struct {
	IDs    []int  `db:"ids,scalar"` // <- prevents expansion of :ids
	Active bool   `db:"active"`
}

var out []int

f := Filter{IDs: []int{1, 2, 3}, Active: true}

err := sqlr.New(sqlr.Postgres).
  Write(`SELECT id FROM users WHERE id = ANY(:ids) AND active = :active`).
  Bind(f). // struct tags control binding behavior
  ScanAll(db, &out)

The ,scalar option on the db tag tells sqlr not to expand the slice; it remains one placeholder whose value is the whole slice (or driver.Valuer).

driver.Valuer (Postgres array)
import "github.com/lib/pq"

ids := []int64{1,2,3}
var out []int64

err := sqlr.New(sqlr.Postgres).
  Write("SELECT id FROM users WHERE id = ANY(:ids)").
  Bind("ids", pq.Array(ids)). // single placeholder; driver handles encoding
  ScanAll(db, &out)
Valuer + Scanner (JSONB round-trip)
type JSONB map[string]any

func (j JSONB) Value() (driver.Value, error) { // driver.Valuer
    b, err := json.Marshal(j)
    return b, err
}
func (j *JSONB) Scan(src any) error { // sql.Scanner
    switch v := src.(type) {
    case []byte:
        return json.Unmarshal(v, j)
    case string:
        return json.Unmarshal([]byte(v), j)
    default:
        return fmt.Errorf("unsupported: %T", src)
    }
}

type Row struct {
    Meta JSONB `db:"meta"`
}

var rows []Row
err := sqlr.New(sqlr.Postgres).
  Write("SELECT meta FROM users WHERE active=:a").
  Bind("a", true).
  ScanAll(db, &rows)

In short: Valuer controls how a value is sent to the driver; Scanner controls how a column is read into your type. sqlr lets database/sql do its job here.

Dynamic composition + Writef()
table := "audit_events" // trusted constant, not user input

b := sqlr.New(sqlr.Postgres).
  Writef("/* tenant=%d */ ", tenantID). // annotate the query
  Writef("SELECT id, ts, kind FROM %s WHERE ts >= :since", table).
  Bind("since", time.Now().Add(-6*time.Hour))

sql, args, _ := b.Preview()
// Use Exec/Scan to run; Preview does not release the builder.

Writef() is for safe, non-user interpolation (comments, known identifiers). Never put untrusted values in Writef().

Conditional composition & many Bind() calls
b := sqlr.New(sqlr.Postgres).
  Write(`SELECT id, name, created_at FROM users WHERE 1=1`)

if namePrefix != "" {
  b.Write(` AND name ILIKE :name_prefix`).
    Bind("name_prefix", namePrefix+"%")
}
if len(ids) > 0 {
  b.Write(` AND id IN (:ids)`).
    Bind("ids", ids) // expands only at build time
}
if since != nil {
  b.Write(` AND created_at >= :since`).
    Bind("since", *since)
}

var users []User
if err := b.ScanAll(db, &users); err != nil { /* ... */ }

Why many Bind() calls are cheap

  • Each Bind(...) simply writes keys into an internal bag (map[string]any) owned by the builder. Later binds with the same key overwrite the previous value (last-write-wins).
  • There’s no SQL re-parse and no args slice churn on every Bind. The heavy work happens once at Build/Exec/Scan:
    • single-pass SQL parse,
    • placeholder numbering per dialect,
    • slice/rows expansion,
    • final []any allocation and fill.
  • Complexity is roughly O(L + H + E) where:
    • L = SQL length scanned once,
    • H = number of placeholders resolved via O(1) map lookups,
    • E = total items produced by expansions (IN (:ids), :rows{...}, etc).
  • Only Bind(struct)/Bind(map) perform reflection or map iteration once per call to materialize/update the bag. Repeated Bind("k", v) pairs are essentially single map writes.

This design lets you compose queries freely with negligible per-bind overhead, while keeping all value interpolation strictly parameterized.

JOIN into two structs with overlapping field names
type User struct {
	ID   int    `db:"u_id"` // note the alias-tag mapping
	Name string `db:"u_name"`
}
type Order struct {
	ID     int     `db:"o_id"` // overlaps on name "id", so we alias
	Total  float64 `db:"total"`
}
type Row struct {
	User  User
	Order Order
}

var rows []Row
err := sqlr.New(sqlr.Postgres).
  Write(`
    SELECT
      u.id   AS u_id,
      u.name AS u_name,
      o.id   AS o_id,
      o.total
    FROM users u
    JOIN orders o ON o.user_id = u.id
    WHERE o.status = :st
  `).
  Bind("st", "paid").
  ScanAll(db, &rows)
Alternatives to Bind("k", v)

When you have many parameters—or they already live in a struct/map—it’s often nicer to bind them in one shot instead of writing multiple Bind("k", v) calls. sqlr accepts a literal param map (P{}), any map[string]any, or a struct (using db tags or field names); all end up in the same internal bag, can be mixed freely, and follow last-write-wins when keys overlap.

Bind a param map with P{}
err := sqlr.New(sqlr.Postgres).
  Write("SELECT * FROM products WHERE brand=:b AND price<=:p").
  Bind(sqlr.P{"b": "Acme", "p": 100}).
  ScanAll(db, &out)
Bind a struct (uses db tags or field names)
type Filter struct {
  Brand string `db:"b"`
  MaxP  int    `db:"p"`
}
f := Filter{"Acme", 100}

err := sqlr.New(sqlr.Postgres).
  Write("SELECT * FROM products WHERE brand=:b AND price<=:p").
  Bind(f).
  ScanAll(db, &out)
Bind a generic map
m := map[string]any{"b": "Acme", "p": 100}

err := sqlr.New(sqlr.Postgres).
  Write("SELECT * FROM products WHERE brand=:b AND price<=:p").
  Bind(m).
  ScanAll(db, &out)
ExecContext with timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

res, err := sqlr.New(sqlr.Postgres).
  Write("UPDATE products SET price=:p WHERE id IN (:ids)").
  Bind("p", 999, "ids", []int{7,8,9}).
  ExecContext(ctx, db)
if err != nil { return err }
ScanAllContext with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var users []User
err := sqlr.New(sqlr.Postgres).
  Write("SELECT id, name FROM users WHERE active=:a").
  Bind("a", true).
  ScanAllContext(ctx, db, &users)
if err != nil { return err }
ScanOneContext with deadline
deadline := time.Now().Add(500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

var count int
err := sqlr.New(sqlr.Postgres).
  Write("SELECT COUNT(*) FROM orders WHERE status=:s").
  Bind("s", "paid").
  ScanOneContext(ctx, db, &count)
if err != nil { return err }
Builder release & safe reuse

Build, Exec and Scan release the builder back to an internal pool. Don’t keep using it after those calls. Use Preview if you need to inspect without releasing.

Don’t reuse after Exec/Build
b := sqlr.New(sqlr.Postgres).
  Write("UPDATE t SET a=:a WHERE id=:id").
  Bind("a", 1, "id", 7)

_, err := b.Exec(db) // releases b
if err != nil { return err }

// b.Write(" AND ...") // DONT'T: b is released
Inspect, then execute (Preview doesn’t release)
b := sqlr.New(sqlr.Postgres).
  Write("SELECT * FROM t WHERE id IN (:ids)").
  Bind("ids", []int{1,2,3})

q, args, _ := b.Preview() // still usable
_ = q; _ = args

var out []int
if err := b.ScanAll(db, &out); err != nil { /* ... */ } // releases here
Start fresh when you need a new query
b := sqlr.New(sqlr.Postgres)

// first query
if _, err := b.Write("DELETE FROM sessions WHERE user_id=:u").
  Bind("u", userID).
  Exec(db); err != nil { return err }

// second query → new builder
var user User
if err := b.Write("SELECT id,name FROM users WHERE id=:u").
  Bind("u", userID).
  ScanOne(db, &user); err != nil { return err }
Transactions
b := sqlr.New(sqlr.Postgres)

ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

// 1) debit
if _, err := b.Write("UPDATE accounts SET balance=balance-:amt WHERE id=:id").
  Bind("amt", 50, "id", 1001).
  ExecContext(ctx, tx); err != nil { return err }

// 2) credit
if _, err := b.Write("UPDATE accounts SET balance=balance+:amt WHERE id=:id").
  Bind("amt", 50, "id", 2002).
  ExecContext(ctx, tx); err != nil { return err }

// 3) read something within the same tx
var total int
if err := b.Write("SELECT COUNT(*) FROM ledger WHERE ok=:ok").
  Bind("ok", true).
  ScanOneContext(ctx, tx, &total); err != nil { return err }

return tx.Commit()

Gotchas & tips:

  • The *SQLR instance is reusable and thread-safe across the app; each Write() spawns a disposable builder that is released by Build, Exec or Scan.
  • Builder lifecycle: Build, Exec, and Scan release the builder to an internal pool. Don’t reuse it afterward. Use Preview to inspect without releasing.
  • Empty inputs:
    • IN (:ids) with an empty slice → error (ErrSliceEmpty). Decide your own fallback (WHERE 1=0, omit the clause, etc.).
    • :name{...} with an empty slice → error (ErrRowsEmpty).
  • Missing binds: referencing :name that isn’t provided yields ErrParamMissing.
  • Ambiguous mapping: two struct fields mapping to the same column name cause ErrFieldAmbiguous. Disambiguate with tags/aliases (as in the JOIN example).
  • NULL into non-pointer: scanning NULL into a non-pointer field triggers a driver scan error. Use T or sql.Null.
  • Quotes/comments are respected: :not_a_param inside string literals, comments, or Postgres dollar-quoted blocks is ignored.
  • Writef() safety: only use with trusted literals (comments, known identifiers). Never pass user input to Writef().

Benchmarks:

BenchmarkBind_Short_AllDialects/postgres-10              2589922               464.7 ns/op           432 B/op          4 allocs/op
BenchmarkBind_Short_AllDialects/mysql-10                 2671608               450.6 ns/op           432 B/op          4 allocs/op
BenchmarkBind_Short_AllDialects/sqlite-10                2670493               448.9 ns/op           432 B/op          4 allocs/op
BenchmarkBind_Short_AllDialects/sqlserver-10             2579859               467.0 ns/op           432 B/op          4 allocs/op
BenchmarkBind_Medium_AllDialects/postgres-10              785796              1487 ns/op            1296 B/op         20 allocs/op
BenchmarkBind_Medium_AllDialects/mysql-10                 793989              1393 ns/op            1280 B/op         20 allocs/op
BenchmarkBind_Medium_AllDialects/sqlite-10                862000              1371 ns/op            1280 B/op         20 allocs/op
BenchmarkBind_Medium_AllDialects/sqlserver-10             797533              1501 ns/op            1296 B/op         20 allocs/op
BenchmarkBind_Long_AllDialects/postgres-10                 17695             67829 ns/op          121531 B/op        534 allocs/op
BenchmarkBind_Long_AllDialects/mysql-10                    23005             52208 ns/op          104049 B/op        532 allocs/op
BenchmarkBind_Long_AllDialects/sqlite-10                   22969             52065 ns/op          104048 B/op        532 allocs/op
BenchmarkBind_Long_AllDialects/sqlserver-10                17173             70306 ns/op          133826 B/op        535 allocs/op
Performance notes
  • Builders are pooled; scanning uses cached plans and reuses holders to minimize allocations.
  • Field-index lookups are cached in a compact two-tier map.
  • Benchmarks and fuzz tests in the repo guard performance and safety.

Contributing:

Issues and PRs are welcome — especially additional tests, micro-benchmarks, and dialect edge-cases.

License:

MIT (see LICENSE).

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrParamMissing     = errors.New("sqlr: missing parameter")
	ErrSliceEmpty       = errors.New("sqlr: empty slice")
	ErrRowsEmpty        = errors.New("sqlr: empty rows")
	ErrRowsMalformed    = errors.New("sqlr: malformed :rows placeholder")
	ErrColumnNotFound   = errors.New("sqlr: column not found")
	ErrTooManyParams    = errors.New("sqlr: too many parameters")
	ErrParamNameTooLong = errors.New("sqlr: parameter name too long")
	ErrFieldAmbiguous   = errors.New("sqlr: ambiguous field name")
	ErrBuilderReleased  = errors.New("sqlr: builder already released; call Write() on *SQLR for a new query")
	ErrMoreThanOneRow   = errors.New("sqlr: more than one row")
)

Functions

func Scalar

func Scalar(v any) any

Scalar wraps a value to force it to be treated as a single scalar argument even if it is a slice/array. Useful for ANY(:ids)-style idioms.

Types

type Builder

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

Builder assembles a single SQL statement and bound parameters. It is NOT safe for concurrent use and is single-use: after Build() it is automatically released back to the pool and must not be used again.

func (*Builder) Bind

func (b *Builder) Bind(args ...any) *Builder

Bind enqueues a parameter source. Supported forms:

  • nil (ignored)
  • struct with `db` tags (flattened through nested structs)
  • map[string]any or any reflect.Map
  • []struct / []map for :rows{...}
  • slices of primitives for :name expansion
  • k/v pairs (even number of args, first is string key)

Multiple Bind() calls are allowed; resolution is "last one wins".

func (*Builder) Build

func (b *Builder) Build() (string, []any, error)

Build concatenates the query, performs binding, and RELEASES the builder back into the pool. After Build(), the builder must not be used again.

func (*Builder) Exec

func (b *Builder) Exec(db Execer) (sql.Result, error)

Exec is a convenience that builds and executes the statement with context.Background().

func (*Builder) ExecContext

func (b *Builder) ExecContext(ctx context.Context, db Execer) (sql.Result, error)

ExecContext builds and executes the statement with the provided context.

func (*Builder) Preview

func (b *Builder) Preview() (string, []any, error)

Preview renders the SQL statement and bound args without releasing the Builder. Safe to call multiple times; identical to Build() except it does NOT Release(). Use this to log/inspect the exact SQL and args that would be produced.

If the builder has already been released, it returns ErrBuilderReleased.

func (*Builder) Release

func (b *Builder) Release()

Release clears the builder and puts it back into the pool. It is safe to call Release multiple times; subsequent calls are no-ops.

func (*Builder) ScanAll

func (b *Builder) ScanAll(db Queryer, dest any) error

ScanAll builds and runs the statement, scanning all rows into dest slice.

func (*Builder) ScanAllContext

func (b *Builder) ScanAllContext(ctx context.Context, db Queryer, dest any) error

ScanAllContext is the context-aware variant of ScanAll.

func (*Builder) ScanOne

func (b *Builder) ScanOne(db Queryer, dest any) error

ScanOne builds and runs the statement, scanning exactly one row into dest. It returns sql.ErrNoRows if no rows are returned. It errors if more than one row.

func (*Builder) ScanOneContext

func (b *Builder) ScanOneContext(ctx context.Context, db Queryer, dest any) error

ScanOneContext is the context-aware variant of ScanOne.

func (*Builder) Write

func (b *Builder) Write(sql string) *Builder

Write appends a raw SQL fragment. No auto-spacing is performed.

func (*Builder) Writef

func (b *Builder) Writef(format string, args ...any) *Builder

Writef appends a formatted SQL fragment. No auto-spacing is performed.

type Config

type Config struct {
	// MaxParams limits the total number of placeholders that can be emitted by
	// a single Build().
	// If = 0 (or omitted), it uses a sensible per-dialect default.
	// If < 0, it's treated as "unlimited".
	MaxParams int
	// MaxNameLen limits the maximum allowed length of a placeholder name,
	// e.g. ":this_is_a_name". Names longer than this cause ErrParamNameTooLong.
	MaxNameLen int
}

Config defines limits and behavior tweaks for the parser/binder.

type Dialect

type Dialect int

Dialect identifies the SQL dialect for placeholder rendering and a few dialect-specific parsing behaviors.

const (
	Postgres Dialect = iota
	MySQL
	SQLite
	SQLServer
)

func (Dialect) String

func (d Dialect) String() string

String returns the string representation of the dialect.

type Execer

type Execer interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

Execer abstracts *sql.DB / *sql.Tx ExecContext for easy testing.

type P

type P = map[string]any

P is a convenient alias for map[string]any to use with Bind().

type Queryer

type Queryer interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

Queryer abstracts *sql.DB / *sql.Tx QueryContext for easy testing.

type SQLR

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

SQLR is the main entry point. It holds the selected dialect, configuration, and a pool of reusable *Builder instances. A single SQLR instance is safe for concurrent use.

func New

func New(dialect Dialect, cfg ...Config) *SQLR

New returns a new SQLR for the given dialect. Optionally provide a Config; unspecified fields fall back to sensible per-dialect defaults.

func (*SQLR) Write

func (s *SQLR) Write(sql string) *Builder

Write starts a new statement and returns a single-use Builder. You can add more chunks via Write/Writef, and bind data via Bind().

Jump to

Keyboard shortcuts

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