sqlbtest

package
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Aug 3, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package sqlbtest is a database-free Executor for testing an application built on sqlb.

The engine's own suite runs without Docker — `mise run test` compiles and runs in seconds against an in-memory pgx double — and until now consumers got none of that. An application wanting to test a hook, a handler or a hand-written query had to either put Docker in its unit loop or write its own pgx.Rows fake, which is nine methods with subtle semantics and is exactly the work internal/pgfake had already done. That was the single biggest week-one friction in a simulated adoption (issue #77).

So this is that double, with a deliberately small surface. The internal package stays free to move; what is frozen here is only what a consumer's test needs.

What it is not

Not a Postgres. It does not parse SQL, it does not evaluate a predicate, and it does not know that the WHERE clause your hook added would have excluded the row it is about to hand back. It answers whatever the script says, and its value is in what it *records*: the statements your code produced and the values it bound.

That makes it right for the questions unit tests actually ask —

  • did the hook's predicate reach the statement?
  • did the handler bind the tenant id from the request rather than the body?
  • does the generated handler keep the hidden column out of its projection?
  • did the write run inside a transaction, and did a failure roll it back?

— and wrong for "does this query return the right rows", which needs a real database. Both are worth having; sqlb keeps the split by running its own round-trip suite against containers in a separate module, and an application adopting sqlb should expect the same shape.

Using it

db := sqlbtest.New(
    sqlbtest.Reply{Cols: []string{"id", "title"}, Rows: [][]any{{"p1", "Hello"}}},
)
handle := sqlb.New(db).WithHooks(hooks)

if _, err := myHandler(ctx, handle, req); err != nil {
    t.Fatal(err)
}
if !strings.Contains(db.LastStatement(), `"tenant_id" = $1`) {
    t.Errorf("the scoping hook did not reach the statement:\n%s", db.LastStatement())
}

A DB is safe for concurrent use, because the code under test may not be sequential.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type DB

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

DB is a scripted, database-free [sqlb.Executor]. It also satisfies the transaction-capable interface sqlb.New looks for, so generated writes — which wrap themselves in a transaction by default — run against it unchanged, and BEGIN, COMMIT and ROLLBACK land in the statement log where a test can assert on them.

The zero DB is usable and refuses every statement, since it has no script.

func New

func New(replies ...Reply) *DB

New returns a DB answering from the given script.

func (*DB) Args

func (d *DB) Args() [][]any

Args is the bind parameters of every statement, aligned with DB.Statements. A transaction marker has none.

func (*DB) BeginTx

func (d *DB) BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error)

BeginTx opens a scripted transaction. Its statements go through this same DB, and its boundary is recorded in the statement log — which is what lets a test assert that a unit of work was wrapped, and that a failing one rolled back rather than committing.

func (*DB) Exec

func (d *DB) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error)

Exec answers a write, reporting a command tag whose row count is the number of rows the matching reply carries.

func (*DB) LastArgs

func (d *DB) LastArgs() []any

LastArgs is the bind parameters of the most recent statement, skipping the transaction markers for the same reason DB.LastStatement does.

This is where a scoping test belongs. The statement text says a predicate was added; the args say what value it was given, which is the half that catches a hook reading the tenant from the request body.

func (*DB) LastStatement

func (d *DB) LastStatement() string

LastStatement is the most recent statement, for asserting on compiled SQL.

Transaction markers are skipped. A generated write is wrapped by default, so the raw last entry is COMMIT and no assertion about SQL has ever been about that; a test asking whether a write was wrapped reads DB.Statements.

func (*DB) Query

func (d *DB) Query(_ context.Context, query string, args ...any) (pgx.Rows, error)

Query answers a read.

func (*DB) QueryRow

func (d *DB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row

QueryRow answers a single-row read. It is here because pgx.Tx requires it; sqlb itself reads through Query.

func (*DB) Reset

func (d *DB) Reset()

Reset clears the statement log, leaving the script in place.

func (*DB) Script

func (d *DB) Script(replies ...Reply)

Script replaces the reply set, for a test that changes what the database says partway through. It does not clear the statement log.

func (*DB) Statements

func (d *DB) Statements() []string

Statements returns every statement issued, in order, including the transaction markers.

type Reply

type Reply struct {
	Match string
	Cols  []string
	Rows  [][]any

	// Err fails the statement when it is sent, which is what a syntax error or
	// a connection failure looks like.
	Err error

	// RowsErr fails the statement while its result is being read, which is what
	// a constraint violation looks like on pgx's extended protocol. The
	// distinction is not academic: code that only checks what Query returned
	// misses a constraint violation entirely, and a wrapped
	// *pgconn.PgError here is how a test reaches sqlb's constraint
	// classification.
	RowsErr error

	// Tag overrides the command tag an Exec reports. The default is derived
	// from len(Rows), which is what the row-count paths read.
	Tag string
}

Reply is one scripted answer.

Match is a substring of the statement it answers, so a test can tell the page query from the count query without parsing SQL. An empty Match answers anything, which is what a script with a single reply wants. Replies are tried in order and the first match wins, so put the specific ones first.

A statement no reply matches fails, with an error naming the statement. That is deliberate and it is the one place this package is stricter than it has to be: a double that answered an unscripted read with an empty result set would hand back zero columns, and the scan would fail several frames later with a message about the model's db tags rather than about the missing reply. Add a Reply with an empty Match for a catch-all.

Jump to

Keyboard shortcuts

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