sqlbatch

package
v1.1.4 Latest Latest
Warning

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

Go to latest
Published: Aug 3, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

README

sqlbatch

Send several SQL statements per network round trip through a database/sql handle.

database/sql has no batch verb, so N statements normally cost N round trips. That is worst under TinyGo, where each round trip is a blocking native socket read. This package queues the statements, hands them to whichever transport the driver actually offers, and delivers the results in queue order.

var n int
b := &sqlbatch.Batch{}
b.Queue("INSERT INTO t(a) VALUES ($1)", 1)
b.Queue("SELECT count(*) FROM t").QueryRow(func(r sqlbatch.Row) error {
	return r.Scan(&n)
})
err := sqlbatch.Send(ctx, db, b)

Results arrive through callbacks registered on the queued statement. That is not decoration: the raw driver connection is only valid inside sql.Conn.Raw, so anything holding it has to be finished before the batch returns.

Placeholders stay the driver's own — $1 for PostgreSQL, ? for MySQL. This package does not translate between them.

Semantics

A batch stops at the first failing statement, and nothing it did survives. Later statements do not run, even independent ones.

That is PostgreSQL's native pipeline behaviour, and the other adapters reproduce it with an explicit transaction. WithoutTransaction() gives it up deliberately, in exchange for one less round trip where a transaction had to be added.

How many round trips a batch costs depends on the driver and is allowed to differ. What a caller observes — results and errors — is not.

Driver support

Driver Package Exec Query
PostgreSQL pgxstdlib yes yes
MySQL / MariaDB mysql yes no
SQLite sqlite no no

A driver package registers its own adapter, so importing the package you already open the database with is enough.

MySQL needs multiStatements=true in the DSN, and interpolateParams=true whenever a statement carries arguments. Both are negotiated when the connection is made, so a batch cannot turn them on.

Unsupported drivers

Send never falls back to running the statements one at a time. A driver that cannot serve the batch makes it fail.

The alternative is worse. A silent fallback would report success while costing N round trips instead of one, and would quietly drop the all-or-nothing guarantee that a batch on a working driver provides. Refusing keeps the cost of a batch something you can reason about.

Every refusal is an *UnsupportedError naming the driver, what was missing, and where possible what would fix it:

  • No adapter registered — today SQLite and any third-party driver. Capability is "batch". SQLite would gain nothing anyway: a local file has no round trip to save.
  • A capability the adapter lacks — a queued Query on MySQL. The batch is refused as a whole rather than running the statements it could.
  • A DSN the adapter cannot work with — MySQL without interpolateParams. Hint names the setting to change.

In all of these the batch executes nothing: the refusal happens before any statement reaches the server, so there is no partial effect to undo.

err := sqlbatch.Send(ctx, db, b)

var unsupported *sqlbatch.UnsupportedError
if errors.As(err, &unsupported) {
	// Run them one at a time instead, accepting the round trips.
}

Writing that fallback by hand is deliberate. Only the caller knows whether N round trips are acceptable, and whether the statements need a transaction around them once the batch is no longer providing one.

Errors

A failure carries a *StatementError saying which queued statement it was:

var se *sqlbatch.StatementError
if errors.As(err, &se) {
	log.Printf("statement %d failed: %v", se.Index, se.Err)
}

Index is -1 when the position is unknown, which is not the same as statement 0. PostgreSQL always knows; MySQL usually does not, because the server reports one error for the whole batch.

Adding an adapter

A driver package registers in init, keyed on its own driver type:

func init() { sqlbatch.Register(&MyDriver{}, sendBatch) }

func sendBatch(ctx context.Context, dc any, b *sqlbatch.Batch, o sqlbatch.Options) (sqlbatch.Results, error)

dc is what sql.Conn.Raw yields. An adapter reaching it directly bypasses database/sql's own argument conversion, so use sqlbatch.ConvertArgs, which honours the driver's NamedValueChecker first.

Documentation

Overview

Package sqlbatch sends several SQL statements per network round trip through a database/sql handle.

database/sql has no batch verb, so N statements normally cost N round trips. That is expensive everywhere and worst under TinyGo, where each round trip is a blocking native socket read. This package queues the statements, hands them to whichever transport the driver actually offers, and delivers the results in queue order.

b := &sqlbatch.Batch{}
b.Queue("INSERT INTO t(a) VALUES ($1)", 1)
b.Queue("INSERT INTO t(a) VALUES ($1)", 2)
if err := sqlbatch.Send(ctx, db, b); err != nil { ... }

Results are read through callbacks registered on the queued statement, so nothing derived from the connection escapes the batch:

var n int
b.Queue("SELECT count(*) FROM t").QueryRow(func(r sqlbatch.Row) error {
	return r.Scan(&n)
})

Semantics

A batch stops at the first failing statement, and nothing it did survives. Later statements do not run, even independent ones. That is PostgreSQL's native pipeline behavior, and the other adapters reproduce it with an explicit transaction. WithoutTransaction gives it up deliberately.

The number of round trips a batch costs depends on the driver and is allowed to differ. The results and errors a caller observes are not.

Drivers

A driver package registers its own adapter, so importing the driver you already open the database with is enough.

postgres  pgxstdlib  exec and query, pipelined
mysql     mysql      exec only, through multiStatements
sqlite    sqlite     not supported

The MySQL path needs multiStatements=true, and interpolateParams=true whenever a statement carries arguments. Both are DSN settings negotiated when the connection is made, so they cannot be turned on per batch.

Unsupported drivers

Send never falls back to running the statements one at a time. A driver that cannot serve the batch makes it fail, because the alternative is worse: a silent fallback would report success while costing N round trips instead of one, and would quietly drop the all-or-nothing guarantee that a batch on a working driver provides. Refusing keeps the cost of a batch something a caller can reason about.

Every refusal is an *UnsupportedError naming the driver, what was missing, and where possible what would fix it:

  • No adapter registered, which today means SQLite and any third-party driver. Capability is "batch". SQLite would gain nothing anyway: a local file has no round trip to save.
  • A capability the adapter lacks, such as a queued Query on MySQL. Capability names it, and the batch is refused as a whole rather than running the statements it could.
  • A DSN the adapter cannot work with, such as MySQL without interpolateParams. Hint names the setting to change.

In all of these the batch executes nothing at all: the refusal happens before any statement reaches the server, so there is no partial effect to undo.

Detect it with errors.As, and fall back explicitly if that suits the caller better than failing:

err := sqlbatch.Send(ctx, db, b)
var unsupported *sqlbatch.UnsupportedError
if errors.As(err, &unsupported) {
	// Run them one at a time instead, accepting the round trips.
}

Writing that fallback by hand is deliberate. It is the caller who knows whether N round trips are acceptable, and whether the statements need a transaction around them once the batch is no longer providing one.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ConvertArgs

func ConvertArgs(dc any, args []any, ordinalBase int) ([]driver.NamedValue, error)

ConvertArgs turns a queued statement's arguments into driver values, for adapters that reach a driver.Conn directly and therefore do not get database/sql's own conversion.

It honors the driver's driver.NamedValueChecker when there is one, since a driver may accept types the default converter rejects.

func Register

func Register(drv driver.Driver, a Adapter)

Register associates an adapter with a driver, and is meant to be called from a driver package's init. The key is the driver's type, so every handle opened through that driver finds the adapter.

Registering the same driver twice panics, matching sql.Register.

func Send

func Send(ctx context.Context, db *sql.DB, b *Batch, opts ...Option) error

Send runs the batch on a pooled connection and reports the first error.

The connection is leased for the duration and returned afterwards. Every callback registered on a queued statement runs before Send returns; a statement with no callback still participates, and its result is discarded.

An empty batch is a no-op.

Types

type Adapter

type Adapter func(ctx context.Context, dc any, b *Batch, o Options) (Results, error)

An Adapter runs a batch on one driver's raw connection. dc is the value sql.Conn.Raw yields, valid only until the returned Results is closed.

type Batch

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

Batch is a set of statements to send together. Queue them, then pass the batch to Send. A Batch may be sent once.

func (*Batch) Len

func (b *Batch) Len() int

Len reports how many statements are queued.

func (*Batch) Queue

func (b *Batch) Queue(sql string, args ...any) *QueuedQuery

Queue appends a statement and returns it, so a result callback can be attached. Placeholder syntax is the driver's own: $1 for PostgreSQL, ? for MySQL. This package does not translate between them.

func (*Batch) Queued

func (b *Batch) Queued() []*QueuedQuery

Queued returns the queued statements, for adapters.

type CommandTag

type CommandTag struct {
	// RowsAffected is the number of rows the statement changed. It is 0 for a
	// statement that changes nothing, including a SELECT.
	RowsAffected int64

	// LastInsertID is meaningful only when HasLastInsertID is set. PostgreSQL
	// never reports one; use a RETURNING clause instead.
	LastInsertID    int64
	HasLastInsertID bool
}

CommandTag is what one statement reports about its effect.

type Option

type Option func(*Options)

An Option adjusts how a batch is sent.

func WithoutTransaction

func WithoutTransaction() Option

WithoutTransaction lets the statements commit as they go, giving up the all-or-nothing guarantee in exchange for one less round trip on drivers that need an explicit transaction.

It has no effect on PostgreSQL, whose pipeline is atomic by construction: there is no way to ask for less.

type Options

type Options struct {
	// Transaction asks the adapter to make the batch atomic. It is true unless
	// the caller passed WithoutTransaction.
	Transaction bool
}

Options are the settings Send resolved, passed to the adapter.

type QueuedQuery

type QueuedQuery struct {
	SQL  string
	Args []any
	// contains filtered or unexported fields
}

QueuedQuery is one statement in a Batch.

func (*QueuedQuery) Exec

func (q *QueuedQuery) Exec(fn func(CommandTag) error) *QueuedQuery

Exec registers fn to receive this statement's result. Leaving it unset is fine for a write-only batch: Send reports any error either way.

func (*QueuedQuery) Query

func (q *QueuedQuery) Query(fn func(Rows) error) *QueuedQuery

Query registers fn to receive this statement's rows. The rows are closed after fn returns and must not outlive it.

func (*QueuedQuery) QueryRow

func (q *QueuedQuery) QueryRow(fn func(Row) error) *QueuedQuery

QueryRow registers fn to receive this statement's first row. As with database/sql, Scan reports sql.ErrNoRows when there is none.

func (*QueuedQuery) WantsRows

func (q *QueuedQuery) WantsRows() bool

Kind reports whether the statement was queued for its rows, for adapters that need to know before sending.

type Results

type Results interface {
	// Exec reads the next statement's result.
	Exec() (CommandTag, error)

	// Query reads the next statement's rows.
	Query() (Rows, error)

	// QueryRow reads the next statement's first row.
	QueryRow() Row

	// Close reads and discards anything unread, releasing the connection. It
	// reports the batch's error.
	Close() error
}

Results delivers one batch's results in queue order. It is implemented by adapters and driven by Send; a caller reads results through the callbacks on QueuedQuery instead.

type Row

type Row interface {
	Scan(dest ...any) error
}

Row is the first row of a result set.

type Rows

type Rows interface {
	Next() bool
	Scan(dest ...any) error
	Columns() ([]string, error)
	Err() error
	Close() error
}

Rows is one statement's result set. It is the portable subset of sql.Rows and pgx.Rows.

type StatementError

type StatementError struct {
	Index int
	SQL   string
	Err   error
}

StatementError identifies the queued statement a batch failed on.

Index is -1 when the driver cannot attribute the failure, which happens on MySQL: the server reports one error for the whole batch. An index of -1 means the position is unknown, never that the first statement failed.

func (*StatementError) Error

func (e *StatementError) Error() string

func (*StatementError) Unwrap

func (e *StatementError) Unwrap() error

type UnsupportedError

type UnsupportedError struct {
	// Driver names the driver that could not serve the request.
	Driver string

	// Capability is what was missing, such as "batch" or "query".
	Capability string

	// Hint, when set, says what would make it work.
	Hint string
}

UnsupportedError says a driver cannot serve part of a batch. It is returned rather than silently falling back, so a caller never mistakes N round trips for one, or an unbatched read for a batched one.

func (*UnsupportedError) Error

func (e *UnsupportedError) Error() string

Jump to

Keyboard shortcuts

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