sqlpro

package module
v0.0.0-...-766b728 Latest Latest
Warning

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

Go to latest
Published: Jun 12, 2026 License: MIT Imports: 23 Imported by: 1

README

sqlpro

sqlpro is a small, reflection-based convenience layer over Go's database/sql. It maps Go structs to rows, rewrites placeholders, expands IN clauses, runs transactions with lifecycle hooks, and provides bulk insert/update helpers — while staying out of your way: you always write the SQL.

It supports PostgreSQL (via pgx) and SQLite (via modernc.org/sqlite).

import "github.com/programmfabrik/sqlpro"

db, err := sqlpro.Open("postgres", "host=localhost dbname=app sslmode=disable")
// or: sqlpro.Open("sqlite", "/path/to/app.db?_pragma=journal_mode(WAL)")

type Author struct {
    ID   int64  `db:"id,pk,omitempty"`
    Name string `db:"name"`
}

a := &Author{Name: "Ada"}
db.Insert("author", a)          // INSERT; a.ID is filled in

var authors []*Author
db.Query(&authors, "SELECT * FROM author WHERE name LIKE ?", "A%")

Contents

Install

go get github.com/programmfabrik/sqlpro

Import a driver somewhere in your program (sqlpro selects it by name):

import (
    _ "modernc.org/sqlite"       // for "sqlite"
    // pgx is pulled in by sqlpro itself for "postgres"
)

Connecting

Open(driver, dsn) returns a DB. driver is "postgres", "sqlite" or "sqlite3".

db, err := sqlpro.Open("sqlite", "/tmp/app.db")
defer db.Close()

Open pings the connection and applies driver-appropriate defaults: PostgreSQL uses $1 placeholders and RETURNING for generated keys; SQLite uses ? placeholders and the RFC3339 time format. Everything below works the same on both drivers.

db.DB() returns the underlying *sql.DB if you need something sqlpro does not wrap.

Mapping structs to rows

Columns are matched to struct fields by the db tag. Only exported fields with a db tag participate. Embedded structs are flattened, so their columns are promoted as if declared inline.

type Row struct {
    ID   int64  `db:"id,pk,omitempty"`
    Name string `db:"name"`
}

Tag options (comma-separated after the column name):

option effect
pk primary key — used as the WHERE for Update, and written back after Insert
omitempty skip the column on write when the Go value is zero (lets the DB apply its default / auto-increment)
readonly never written by sqlpro (server-generated/computed columns); still read back
json the field is JSON-marshaled on write and unmarshaled on read
json_ignore_error ignore JSON marshal/unmarshal errors for this field
null for json fields: write SQL NULL (not the string "null") when the value is zero
notnull for json fields: write the literal "null" rather than SQL NULL
- ignore the field entirely (never read or written)

db:"name" with no options just maps the column.

Reading

Query / QueryContext pick how to scan from the type of the target:

target result
*int64, *string, *time.Time, … first column of the first row
*struct the first row mapped by db tags
*[]Struct, *[]*Struct all rows
*[]int64, *[]*string, … the first column of all rows
**sql.Rows the raw rows handle for manual iteration
var n int64
db.Query(&n, "SELECT count(*) FROM author")

var a Author
db.Query(&a, "SELECT * FROM author WHERE id = ?", 1)

var all []*Author
db.Query(&all, "SELECT * FROM author ORDER BY name")

var names []string
db.Query(&names, "SELECT name FROM author")

A single-row target that matches no row returns ErrQueryReturnedZeroRows; a slice target simply comes back empty.

Writing

db.Insert("author", &a)                  // pk written back into a
db.InsertContext(ctx, "author", &a)

db.Update("author", &a)                  // by pk
db.Save("author", &a)                    // upsert: insert if pk==0, else update

db.Exec("DELETE FROM author WHERE id = ?", 1)
affected, lastID, err := db.ExecContextRowsAffected(ctx, "UPDATE author SET name = ?", "x")

Bulk helpers operate on a slice of structs in one round-trip. On PostgreSQL, InsertBulk uses COPY FROM for large batches; on SQLite it builds multi-row INSERTs (chunked under the placeholder limit):

db.InsertBulk("author", []*Author{{Name: "a"}, {Name: "b"}})
db.InsertBulkOnConflictDoNothingContext(ctx, "author", rows, "name") // skip conflicts on "name"
db.UpdateBulkContext(ctx, "author", rows)                            // update many by pk

Bulk inserts do not read generated keys back into each struct; SELECT the rows afterwards if you need their ids.

NULL, JSON and custom column types

  • NULL: use a pointer field. nil ⇄ SQL NULL.
  • JSON: tag the field db:"col,json"; it is stored as JSON text.
  • json.RawMessage: stored/loaded verbatim.
  • Custom types: implement driver.Valuer (write) and sql.Scanner (read).
type Place struct {
    Name   *string         `db:"name"`        // nullable
    Config Settings        `db:"config,json"` // JSON column
    Raw    json.RawMessage `db:"raw"`         // stored as-is
    At     Geo             `db:"at"`          // Valuer + Scanner
}

A custom column type implements both interfaces:

func (g Geo) Value() (driver.Value, error) { return fmt.Sprintf("%g,%g", g.Lat, g.Lng), nil }
func (g *Geo) Scan(v any) error            { /* parse v into *g */ }

The helper scanners NullTime, NullJson and NullRawMessage are used internally and are exported for direct use with *sql.Rows.Scan.

Placeholders & escaping

Write portable ? placeholders; sqlpro rewrites them to $1, $2, … on PostgreSQL. Special placeholders:

  • IN ? — pass a slice and it expands to the right number of placeholders:

    db.Query(&names, "SELECT name FROM author WHERE id IN ?", []int64{1, 2, 3})
    
  • @ — the next argument is quoted as a SQL identifier (table/column):

    db.Query(&n, "SELECT count(*) FROM @", "author")
    

Escaping helpers for the rare cases a value can't be a bound argument:

db.EscValue("O'Hara")                 // 'O''Hara'
sqlpro.IlikeSql(db.Driver(), "berg")  // driver-correct case-insensitive LIKE snippet

Transactions

ExecTX is the recommended entry point. It opens a dedicated connection, hands a transaction-carrying context to your job, commits on success and rolls back on error or panic. Inside the job, get the transaction with CtxTX(ctx) and use it exactly like a DB:

err := db.ExecTX(ctx, func(ctx context.Context) error {
    tx := sqlpro.CtxTX(ctx)
    if err := tx.Insert("account", &acc); err != nil {
        return err // -> rollback
    }
    return tx.Exec("UPDATE ledger SET balance = balance - ? WHERE id = ?", amt, id)
}, nil) // *sql.TxOptions, or nil

Transactions cannot be nested (ExecTX inside ExecTX errors). Lifecycle hooks can be registered on the transaction:

hook when
BeforeCommit(func() error) inside Commit, before the underlying commit; an error rolls back
AfterCommit(func()) after a successful commit
AfterRollback(func()) after a rollback
AfterTransaction(func()) after commit or rollback

Use BeforeCommit when a side effect must be atomic with the transaction (e.g. bumping a cache version); use the After* hooks for non-transactional effects (logging, cache invalidation).

For explicit control there are also Begin(), BeginRead() (read-only) and BeginContext(), each returning a TX you Commit() / Rollback() yourself.

Introspection

v, _ := db.Version()  // e.g. "Sqlite 3.45.0" / PostgreSQL version string
n, _ := db.Name()     // current database name / sqlite file
db.Log()              // returns a copy with debug logging enabled

Errors

  • ErrQueryReturnedZeroRows — a single-row Query found nothing. Test with errors.Is.
  • ErrMismatchedRowsAffected — an operation affected an unexpected number of rows.

Examples

A runnable, end-to-end tour lives in examples/. It exercises every feature against a throwaway SQLite database:

go run ./examples
file covers
crud.go Insert / Query / Update / Save / Delete, query target shapes
bulk_tags.go InsertBulk, on-conflict, UpdateBulk; pk/omitempty/readonly/-/embedding
null_json.go nullable pointers, JSON columns, json.RawMessage, custom Valuer/Scanner, and the ,null/,notnull/,json_ignore_error null-handling options
placeholders.go ?, IN ?, @, EscValue, IlikeSql
transactions.go ExecTX, hooks, rollback, BeginRead, introspection

Testing & benchmarks

Most tests run against SQLite and need no setup:

go test ./...                 # everything except the PostgreSQL test
go test -run TestCopyFrom .   # needs a local PostgreSQL "apitest" database

feature_test.go is a from-scratch, feature-by-feature suite covering the full public surface. The scan path has a benchmark:

go test -run='^$' -bench=BenchmarkScanRows -benchmem .

The slice-of-struct read path is optimized to build its column plan, scan buffer and null-scanners once per query and reuse them across rows, so only the row struct itself is allocated per row.

Documentation

Index

Constants

View Source
const POSTGRES = "postgres"

The driver strings must match the driver from the stdlib

View Source
const SQLITE3 = "sqlite3"

Variables

View Source
var ErrMismatchedRowsAffected error = errors.New("Mismatched rows affected")
View Source
var ErrQueryReturnedZeroRows error = errors.New("Query returned 0 rows")

Functions

func CtxWithTX

func CtxWithTX(ctx context.Context, tx TX) context.Context

CtxWithTX returns ctx with TX stored

func IlikeSql

func IlikeSql(driver dbDriver, match string) string

IlikeSql returns driver compatible ILIKE where clause snippet. match is escaped using %...%. This panics for unknown driver. E.g. "schule" and driver "postgres" this will return "ILIKE 'schule'"

func Scan

func Scan(target interface{}, rows *sql.Rows) error

Scan reads data from the given rows into the target.

*int64, *string, etc: First column of first row *struct: First row []int64, []*int64, []string, []*string: First column, all rows []struct, []*struct: All columns, all rows

The mapping into structs is done by analyzing the struct's tag names and using the given "db" key for the mapping. The mapping works on exported fields only. Use "-" as mapping name to ignore the field.

Types

type DB

type DB interface {
	Query
	Exec
	Begin() (TX, error)
	BeginRead() (TX, error)
	BeginContext(context.Context, *sql.TxOptions) (TX, error)
	Close() error
	IsClosed() bool
	Name() (string, error)
	DB() *sql.DB
	Log() DB
	Version() (string, error)
	ExecTX(context.Context, func(context.Context) error, *sql.TxOptions) error
}

func Open

func Open(driverS, dsn string) (DB, error)

Open opens a database connection and returns an sqlpro wrap handle

type DebugLevel

type DebugLevel int
const (
	PANIC      DebugLevel = 1
	ERROR      DebugLevel = 2
	UPDATE     DebugLevel = 4
	INSERT     DebugLevel = 8
	EXEC       DebugLevel = 16
	QUERY      DebugLevel = 32
	QUERY_DUMP DebugLevel = 64
)

type Exec

type Exec interface {
	Query

	ExecContext(context.Context, string, ...any) error
	Exec(string, ...any) error
	ExecContextRowsAffected(context.Context, string, ...any) (int64, int64, error)

	Insert(string, any) error
	InsertBulk(string, any) error
	InsertBulkContext(context.Context, string, any) error
	InsertBulkOnConflictDoNothingContext(context.Context, string, any, ...string) error
	InsertContext(context.Context, string, any) error

	Save(string, any) error
	SaveContext(context.Context, string, any) error

	Update(string, any) error
	UpdateContext(context.Context, string, any) error
	UpdateBulkContext(context.Context, string, any) error
}

type NullJson

type NullJson struct {
	Data  []byte
	Valid bool
}

func (*NullJson) Scan

func (nj *NullJson) Scan(value any) error

type NullRawMessage

type NullRawMessage struct {
	Data  json.RawMessage
	Valid bool
}

func (*NullRawMessage) Scan

func (nj *NullRawMessage) Scan(value any) error

type NullTime

type NullTime struct {
	Time  time.Time
	Valid bool
}

func (*NullTime) Scan

func (ni *NullTime) Scan(value any) error

Scan implements the Scanner interface.

type PlaceholderMode

type PlaceholderMode int
const (
	DOLLAR   PlaceholderMode = 1
	QUESTION PlaceholderMode = 2
)

type Query

type Query interface {
	QueryContext(context.Context, any, string, ...any) error
	Query(any, string, ...any) error
	Driver() dbDriver
	EscValue(string) string
}

type TX

type TX interface {
	Query
	Exec

	BeforeCommit(func() error)
	AfterCommit(func())
	AfterRollback(func())
	AfterTransaction(func())

	ActiveTX() bool
	IsWriteMode() bool

	Commit() error
	Rollback() error

	EscValue(string) string
}

func CtxTX

func CtxTX(ctx context.Context) TX

CtxTX returns the TX stored in ctx

Directories

Path Synopsis
Command examples is a runnable tour of the sqlpro API.
Command examples is a runnable tour of the sqlpro API.

Jump to

Keyboard shortcuts

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