Help us shape the future of Go! Take the 2026 Go Developer Survey

database

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 21 Imported by: 0

README

Arkoren Database

arkoren.dev/core/service/database defines the SQL Driver contract and the typed Database facade. It uses Arkoren's foundation modules and core services, with no third-party dependencies.

Applications select an independently versioned driver module, then create a facade with database.New(driver) for typed Find, Select, Cursor, Exec, Paginate, CursorPaginate, Each, and transactions.

Installation

From your application's module directory, install core and the selected driver:

go get arkoren.dev/core
go get arkoren.dev/driver/sqlite

Import arkoren.dev/core/service/database, arkoren.dev/foundation/query, and your selected driver package, such as arkoren.dev/driver/sqlite/sqlitedatabase. The database guide shows construction, lifecycle wiring, and your first migrated tables.

Query builder

Exec, Select, Find, and Cursor accept any query.Statementer: a query.Query, or query.Raw(sql, args...) for SQL the builder cannot express. They compile it for the driver's dialect. Drivers report their dialect by implementing Dialect() on the Driver interface. The bundled Postgres, MySQL, and SQLite drivers report theirs.

Raw SQL always uses ? placeholders; the dialect rewrites them ($n on Postgres). Select, Find, and Cursor reject a published query that returns no rows (an INSERT, UPDATE, or DELETE without Returning) with query.ErrInvalidQuery. A RawStatement is opaque and always accepted.

The examples use db := database.New(driver) with your selected SQL driver, your User and Event row types, and an I/O context ctx. To collect active users, call Select:

users, err := db.Select[User](ctx, query.Table("users", func(statement *query.Builder) {
	statement.Select("id", "name")
	statement.Where("active", true)
}))

To fetch one row using hand-written SQL, pass its values separately:

user, err := db.Find[User](ctx, query.Raw("SELECT * FROM users WHERE id = ?", id))

To change a row, call Exec; affected reports the affected-row count:

affected, err := db.Exec(ctx, query.Table("users", func(statement *query.Builder) {
	statement.Where("id", id)
	statement.Update(map[string]any{"name": name})
}))

To traverse results without collecting them all, create a cursor:

events := db.Cursor[Event](ctx, query.Table("events"))

Select collects rows; Cursor returns a lazy sequence whose Iter method yields rows and errors as they are read. The cursor joins iteration/scan failures with close failures before yielding the final error. If you stop iteration early, it still closes rows, but cleanup failure can only reach telemetry: an iterator cannot yield after you stop it. Use db.Driver().Query and explicitly handle Rows.Close when your application must return an early-stop cleanup error; that raw path bypasses facade scopes.

For hand-written SQL with many parameters, query.RawNamed binds :name placeholders from a map, such as one built by query.Values, and runs through the facade like any other statement:

_, err := db.Exec(ctx, query.RawNamed(
	"UPDATE users SET name = :name WHERE id = :id", query.Values(user)))

Pagination and batching

Paginate, CursorPaginate, and Each take a query.Query (not raw SQL text) and read its orders with Inspect. Raw SQL stays available as a source through query.FromRaw, or through db.Driver().

The following examples use your Post row type with id, title, and created_at columns. To show numbered pages, read parameters from an existing HTTP request req, then call Paginate. It counts with Query.Count and fetches the page:

params, err := request.Page(req, pagination.WithMaxPerPage(50))
page, err := db.Paginate[Post](req.Context(), query.Table("posts", func(statement *query.Builder) {
	statement.Where("published", true)
	statement.OrderByDesc("created_at")
	statement.OrderBy("id")
}), params)

For next/previous navigation without a count query, use CursorPaginate. It fetches one extra row to detect another window, and encodes the ordered column values into next_cursor and prev_cursor:

params, err := request.Cursor(req)
feed, err := db.CursorPaginate[Post](req.Context(), query.Table("posts", func(statement *query.Builder) {
	statement.OrderByDesc("created_at")
	statement.OrderByDesc("id")
}), params)

A bad or mismatched cursor returns a 400 problem. To paginate in a job without HTTP, build the parameters directly; Normalize is applied for you:

page, err := db.Paginate[Post](ctx,
	query.Table("posts", func(statement *query.Builder) {
		statement.OrderBy("id")
	}),
	pagination.PageParams{Page: 2, PerPage: 50})

For a grouped report, use FromRaw as the source. It takes ? placeholders on every dialect. Here your Row type has AuthorID and Posts integer fields tagged db:"author_id" and db:"posts", and params is a pagination.PageParams:

report, err := db.Paginate[Row](ctx,
	query.FromRaw(query.Raw(`SELECT author_id, COUNT(*) AS posts
FROM posts WHERE created_at >= ? GROUP BY author_id`, since), func(statement *query.Builder) {
		statement.As("r")
		statement.OrderByDesc("posts")
		statement.OrderBy("author_id")
	}),
	params)

To process a large export in bounded memory, use Each. It reads batches of 500 rows with keyset queries:

posts := db.Each[Post](ctx, query.Table("posts", func(statement *query.Builder) {
	statement.OrderBy("id")
}), 500)

Like Cursor, posts.Iter() yields each row with an error result. Stop on an iteration error before processing that row.

Errors: ErrNoOrder (no OrderBy), ErrCursorColumn (an ordered column has no field in T), ErrCursorValue (a key value is NULL or unusable), and ErrInvalidBatchSize (Each size below one). A malformed cursor, different ordering, or invalid signature fails with pagination.ErrCursorDecode. Unsigned tokens can be edited into another valid position.

Cursors are plain base64 JSON by default, so a client can forge a position (only the position; the query still applies). To sign them, build a codec and set Builder.CursorCodec:

codec, err := pagination.NewCursorCodec(secret, oldSecret) // validated key.Shared values; exactly 32 bytes each
if err != nil {
	return err // Do not silently fall back to unsigned cursors.
}
db := database.New(driver, func(connection *database.Builder) {
	connection.CursorCodec(codec)
})

A database with a codec rejects unsigned cursors, so enabling it invalidates cursors clients hold. Signing protects integrity, not secrecy or authorization. Cursors do not bind a user, tenant, filter, dataset, or expiry; retain authorization filters on every query. Both encoders reject tokens above the decoders' 4096-byte complete-token limit, including the signature when signing is enabled.

Caveats:

  • The last OrderBy column must be unique (usually id), or rows that share the other keys can repeat or vanish between pages. Uniqueness is your responsibility; the facade does not verify it.
  • Ordered columns must be fields of T, matched by db tag (or lowercase field name). A qualified column such as p.id matches id.
  • NULL keys are unsupported: use non-nullable ordered columns or explicitly filter NULL rows out of the selected dataset. Every fetched key is checked, including lookahead and short final batches. These checks cannot discover NULL partitions already excluded by a seek. SQL nullable values retain their driver.Valuer method sets for non-NULL continuation binding.
  • All three methods need a published SELECT query. They derive their own Limit (Paginate also Offset), so the builder must not have either: a preset Limit or Offset fails with database.ErrPaged before any query runs (Each yields it as its first item).
  • FromRaw queries are wrapped as subqueries. Optimizer barriers inside the raw SQL (GROUP BY, DISTINCT, LIMIT) can force the database to materialize the whole result, which slows deep pages and Count.
  • Each runs separate queries, not one snapshot. A consistent view requires a transaction with snapshot-preserving isolation; PostgreSQL read committed still takes a new snapshot per statement. Cancellation is checked between buffered rows, not only before the next query.
  • Paginate and CursorPaginate reapply fixed default limits (100 rows per page, and page 10,000 for offset pages). Higher request-parser limits do not raise those facade limits.

Build errors are returned before the driver is called.

Telemetry privacy

Database spans include bounded operation labels and known table names, not bound arguments or error messages. Query text is disabled by default. If you enable connection.QueryText(true) in a construction callback, spans include compiled SQL verbatim. SQL literals and comments are not redacted, even when bound arguments use placeholders. Enable text only for statements safe to export to your telemetry backend.

Transactions

WithTransaction puts the transaction in the context it hands to fn. Keep using the *Database you already have, and pass that context. Here insertOrder is your insert query and orders.Reserve is your application's reservation operation using the same database:

err := db.WithTransaction(ctx, func(ctx context.Context) error {
	if _, err := db.Exec(ctx, insertOrder); err != nil {
		return err // rolls back
	}

	return orders.Reserve(ctx, items) // joins this transaction
})
  • fn returning nil commits. An error rolls back and is returned. A panic rolls back and is re-raised.
  • Exec, Select, Find, Cursor, Paginate, CursorPaginate, Each, and Lock run in the transaction their context carries, when it belongs to the same driver. A call made with the outer ctx runs outside it.
  • Create one *Database per driver. Two *Database values on one driver share transactions; a *Database on another driver does not join.
  • A WithTransaction inside a transaction is a nested transaction. It rolls back only its own work, and that work is final only when the outermost transaction commits. The SQL drivers implement it with savepoints. ErrNestedTransaction means the driver cannot nest.
  • Using the context after the transaction ends fails with sql.ErrTxDone. A transaction is one connection: do not use its context from several goroutines at once.
  • Synchronous code that receives the context, such as event.Bus listeners, joins the transaction. A goroutine that outlives it gets sql.ErrTxDone.
  • db.AfterCommit(ctx, fn) runs fn after the outermost transaction commits, in registration order, with the context the outermost WithTransaction was called with. An error, panic, or failed commit discards the hooks, and so does a rollback of the nested transaction that registered them. Outside a transaction fn runs immediately. It is best effort: a crash after the commit loses the hooks, so use it for cache invalidation, broadcasts, and notifications, not for work that must not be lost. Every hook runs even when one fails; their errors are joined and returned by the outermost WithTransaction wrapped with database.ErrAfterCommit, which means the transaction committed: don't retry it. A panic in fn propagates out of the outermost WithTransaction and skips later hooks.
  • There are no transaction options (read-only, isolation).

Sessions

db.WithSession(ctx, fn) reserves one physical connection without beginning a transaction. Use its callback context for all protected SQL, locks, and explicit transactions. Loss of the connection fails subsequent SQL rather than silently reconnecting or replaying work. An error does not undo earlier autocommit writes. Nested sessions borrow the connection but expire independently; new locks outside transactions end with their acquiring callback, while transaction locks last until the outermost transaction ends. Close cursors before further SQL.

Use each callback synchronously from one goroutine. After return, session contexts fail with database.ErrSessionDone, and transaction contexts with sql.ErrTxDone. Driver() and native accessors bypass both scopes and facade telemetry. See the session and lock guide and API reference.

Documentation

Overview

Package database defines the SQL database contract and a typed facade that runs published SQL queries into typed rows, with pinned sessions, transactions, and telemetry.

Queries are built with arkoren.dev/foundation/query, either with the structured query.Query or with query.Raw for SQL text, and run through Database.Exec, Database.Select, Database.Find, and Database.Cursor. They compile for the dialect a driver reports through Driver.Dialect. Named parameters use query.RawNamed through the facade; Database.Driver remains available for deliberate raw driver access, which bypasses facade compilation, scope routing, and telemetry.

Database.Paginate returns an offset page with its total, Database.CursorPaginate returns a keyset window with opaque cursors, and Database.Each streams every row in batches. All three take a query.Query with an ORDER BY, for example one started from query.FromRaw for raw SQL, and fail with ErrNoOrder without one. Database.CursorPaginate and Database.Each read the ordered columns from db-tagged fields of the result type (ErrCursorColumn) and reject NULL key values (ErrCursorValue), including lookahead and short final batches. Every ordered value in the selected dataset must be non-NULL; checks cannot detect NULL rows excluded by a SQL seek. Use non-nullable keys or explicit NULL exclusion. The last ORDER BY column must be unique; this is the caller's responsibility, not something the facade verifies. All three own the query's Limit, and offset pagination also sets Offset. A query that already has either fails with ErrPaged. Database.Each rejects a batch size below one with ErrInvalidBatchSize. Offset and cursor Items fields are eager collection.Slice values for one window. Their Map, Filter, and GroupBy operations do not execute SQL or change server totals, page limits, or the original cursor boundaries. Page helpers detach outer item storage; referenced element objects remain shared.

Every Database.Exec, Database.Select, Database.Find, Database.Cursor, Database.Lock, and Database.TryLock emits a client span and a db.client.operation.duration measurement when the selected telemetry (default: telemetry.Default) is started. Spans carry the operation and table, never bound arguments or error text. Query text is disabled by default. Builder.QueryText(true) exports compiled SQL verbatim, including any literals and comments; enable it only when those statements are safe to export.

Sessions

Database.WithSession retains one physical connection without beginning a transaction. Use its callback context for every protected operation, including transactions that must share session-local state or a named lock:

err := db.WithSession(ctx, func(ctx context.Context) error {
	return db.WithTransaction(ctx, func(ctx context.Context) error {
		_, err := db.Exec(ctx, updateCounter)
		return err
	})
})

Each transaction keeps its own commit. A session error does not undo prior autocommit work, and connection loss never reconnects or replays the callback. Nested sessions borrow the connection and preserve an enclosing transaction. New nontransactional locks belong to their session callback and are released when it ends; transaction acquisitions belong to the outermost transaction even when first taken in a nested session or transaction. Outer session locks survive child transactions. Locks are not reentrant, including on one session.

Session drivers, contexts, and cursors expire when their callback returns: later I/O fails with ErrSessionDone. Use the callback synchronously from one goroutine. Cleanup closes cursors and releases owned locks after cancellation or panic, before returning the connection; uncertain cleanup discards it. Arbitrary raw SQL session state is not reset. Database.Driver and native accessors bypass session routing, just as they bypass transactions.

Network raw held locks each consume a connection. Root lock waiters release contended connections between attempts, but already-pinned scopes cannot. Use Database.TryLock and end a contended session when other work needs the capacity. Pool acquisition and network I/O still respect the caller context; no guarantee prevents raw holders from exhausting all pool connections.

Transactions

Database.WithTransaction passes fn a context that carries the transaction. Every operation given that context runs inside it, on any Database over the same driver, so code that receives only the context joins the transaction without knowing about it. A call with a different driver's Database, or with any other context, does not join. Use one Database per driver: Databases join by comparing their driver values, and a driver that cannot be compared joins only through the same *Database.

err := db.WithTransaction(ctx, func(ctx context.Context) error {
	if _, err := db.Exec(ctx, insertOrder); err != nil {
		return err
	}

	return orders.Reserve(ctx, items) // joins; a WithTransaction inside is nested
})

A Database.WithTransaction inside a transaction is a nested transaction, which rolls back only its own work; a driver that cannot nest fails with ErrNestedTransaction. Database.Driver and Database.DriverAs always return the root driver, never a transaction driver.

Database.AfterCommit registers a best-effort callback that runs once the outermost transaction commits, in registration order, and is discarded on rollback. Hooks of a nested transaction wait for the outermost commit and are discarded if the nested transaction rolls back. Without a transaction the callback runs immediately. Hooks are lost if the process dies after the commit, so use them for cache invalidation, broadcasts, and notifications, not for work that must not be lost. Every hook runs; their errors come back from the outermost WithTransaction wrapped with ErrAfterCommit, which means the transaction committed and must not be retried. Drivers use the same marker for cleanup failures after a confirmed commit; committed events and hooks still run. An error returned by the body still requests rollback, even if it bears a marker from another transaction.

The context is valid only while fn runs: using it afterwards fails with database/sql.ErrTxDone. Do not use it from several goroutines at once, because a transaction runs on one connection. Code that runs synchronously with the context, such as listeners that an event bus invokes before returning, joins the transaction too; hand work that must outlive it a fresh context.

Events

With Builder.Events, the outermost Database.WithTransaction publishes TransactionCommitted ("database.transaction.committed") after the commit and before the after-commit hooks, or TransactionRolledBack ("database.transaction.rolled-back") with the error. Listeners receive the context WithTransaction was called with, outside the transaction. Any enclosing session remains active. Cleanup failure after a confirmed commit publishes the committed event, not a rollback. Nested transactions publish nothing.

db := database.New(driver, func(connection *database.Builder) {
	connection.Events(app.Events())
})

New configures the facade with Builder policies, not a Config input. Policies run directly once in order on a fresh draft before system resolution. Nil entries panic; expanded nil/empty slices supply no edits. QueryText starts disabled and requires an explicit opt-in. Publication detaches settings, not the driver, codec, telemetry or events. Construction performs no lifecycle, transaction, session or query I/O.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoRows reports that [Driver.Find] matched no row. Drivers also keep
	// the native error, so errors.Is matches [sql.ErrNoRows] as well.
	ErrNoRows = errors.New("no database rows were found")

	// ErrEmptyLockName reports that [Driver.Lock] received an empty name.
	ErrEmptyLockName = errors.New("lock name cannot be empty")

	// ErrNestedTransaction reports that a driver cannot nest transactions:
	// [Driver.WithTransaction] was called on a transaction driver that has no
	// way to start a transaction inside its own. The driver wraps it with the
	// reason, so match it with errors.Is.
	ErrNestedTransaction = errors.New("nested transactions are not supported")

	// ErrAfterCommit marks failed hooks or driver cleanup after a confirmed
	// commit. The data committed: do not retry that transaction. The original
	// errors remain discoverable with errors.Is. An error returned by a
	// transaction body still requests rollback, even if it wraps this marker
	// from work on another database.
	//
	// Example:
	//
	// 	if errors.Is(err, database.ErrAfterCommit) {
	// 		logger.WarnContext(ctx, "post-commit work failed", "error", err)
	// 	}
	ErrAfterCommit = errors.New("after-commit work failed")

	// ErrSessionDone reports use of a session callback's driver or context
	// after that callback returned. No query is sent and no fresh connection
	// is acquired. Transactions retain their sql.ErrTxDone outcome instead.
	//
	// Example:
	//
	// 	if errors.Is(err, database.ErrSessionDone) {
	// 		logger.WarnContext(ctx, "work outlived its database session")
	// 	}
	ErrSessionDone = errors.New("database session has ended")
)
View Source
var (
	// TransactionCommitted is published after a confirmed outermost commit,
	// before hooks. Post-commit driver cleanup errors do not change this event
	// to a rollback; the caller still receives ErrAfterCommit.
	TransactionCommitted = event.NewTopic[TransactionEvent]("database.transaction.committed")

	// TransactionRolledBack is published when an outermost transaction
	// fails before confirmed commit: its body failed, its context ended, or
	// the commit failed. A callback-origin ErrAfterCommit still counts as a
	// body failure rather than proof this transaction committed.
	TransactionRolledBack = event.NewTopic[TransactionEvent]("database.transaction.rolled-back")
)
View Source
var (
	// ErrNoOrder reports that a paginated or batched query has no ORDER BY.
	// Stable pages need a total order, so add OrderBy with a unique
	// tiebreaker column last.
	ErrNoOrder = errors.New("paginated query requires an order")

	// ErrCursorColumn reports that an ordered column has no matching field
	// in the result type, so its value cannot be read for a cursor.
	ErrCursorColumn = errors.New("order column has no field in result type")

	// ErrCursorValue reports that a key value of a cursor row cannot be
	// used in a keyset comparison, for example because it is NULL.
	ErrCursorValue = errors.New("invalid cursor key value")

	// ErrPaged reports that the query already has a Limit or Offset.
	// Pagination sets both itself, so a preset value would be overwritten
	// or skip rows. Omit them when declaring the query.
	ErrPaged = errors.New("builder already has limit or offset")

	// ErrInvalidBatchSize reports a batch size below one in [Database.Each].
	ErrInvalidBatchSize = errors.New("batch size must be positive")
)

Functions

This section is empty.

Types

type Builder

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

Builder configures New before publication. Callbacks run directly once, synchronously in order on one fresh draft. Nil callback entries panic; expanded nil or empty callback slices supply no edits. Retaining the draft cannot change published settings. Methods return the same draft.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.Namespace("billing")
})

func (*Builder) CursorCodec

func (connection *Builder) CursorCodec(codec *pagination.CursorCodec) *Builder

CursorCodec signs the cursors of Database.CursorPaginate with codec and rejects cursors it did not sign with pagination.ErrCursorDecode. Switching an existing deployment to a codec invalidates cursors clients already hold.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.CursorCodec(nil)
})

func (*Builder) Events

func (connection *Builder) Events(bus *event.Bus) *Builder

Events sets the bus that receives TransactionCommitted and TransactionRolledBack.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.Events(nil)
})

func (*Builder) Namespace

func (connection *Builder) Namespace(namespace string) *Builder

Namespace sets the db.namespace attribute.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.Namespace("billing")
})

func (*Builder) QueryText

func (connection *Builder) QueryText(enabled bool) *Builder

QueryText turns the db.query.text attribute on or off. It is disabled by default. Enabling it records compiled SQL verbatim: bound argument values remain separate, but SQL literals and comments can contain secrets or personal data. Enable it only for statements safe to export. This setting does not change query execution, operation labels, or metrics.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.QueryText(true) // Only for statements audited as safe to export.
})

func (*Builder) System

func (connection *Builder) System(system string) *Builder

System sets the db.system.name attribute, overriding the one derived from the driver's dialect.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.System("custom")
})

func (*Builder) Telemetry

func (connection *Builder) Telemetry(t *telemetry.Telemetry) *Builder

Telemetry sets the telemetry that records operations. Without it the database follows telemetry.Default.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

database.New(driver, func(connection *database.Builder) {
	connection.Telemetry(nil)
})

type BuilderFunc

type BuilderFunc = func(*Builder)

BuilderFunc configures a Builder.

type Database

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

Database provides typed convenience methods over a driver.

func New

func New(driver Driver, configure ...BuilderFunc) *Database

New creates a Database that delegates operations to driver. Policies configure its telemetry: every Exec, Select, Find, Cursor, and Lock emits a client span and a db.client.operation.duration measurement when the telemetry is enabled, and costs nothing otherwise. Query text is disabled by default because literals and SQL comments can contain private data; Builder.QueryText enables deliberate verbatim export.

Example:

db := database.New(driver, func(connection *database.Builder) {
	connection.Telemetry(app.Telemetry())
	connection.Namespace("billing")
})
_ = db.Driver()

Construction callbacks run directly once, synchronously in order on one fresh draft. Nil entries panic; expanded nil or empty slices omit edits. Publication detaches owned settings, not live dependencies or captures.

func (*Database) AfterCommit

func (database *Database) AfterCommit(ctx context.Context, fn func(ctx context.Context) error) error

AfterCommit runs fn once the outermost transaction that ctx carries on this database has committed, or immediately when ctx carries no such transaction. It is a best-effort hook for effects that must not happen unless the data is committed and that tolerate being lost: cache invalidation, broadcasts, notifications, and metrics.

It is not durable. The hooks live in memory, so a crash or kill between the commit and the end of the hooks loses them without any trace. Work that must not be lost, such as sending an email the user is owed, needs a durable mechanism (for example a row written in the same transaction and processed by a queue), not this hook.

A transaction is joined as for any other operation: through a ctx from Database.WithTransaction on this Database or on another one over the same driver. A ctx without a transaction, or with one of a different driver only, runs fn immediately and synchronously with ctx.

Hooks run in registration order, synchronously, before the outermost Database.WithTransaction returns. They run only when that transaction commits: an error from the transaction body, a panic, or a failed commit discards them all. A hook registered inside a nested transaction is discarded when the nested transaction rolls back; when it succeeds the hook is appended to the enclosing level, so it still waits for the outermost commit.

fn receives the ctx the outermost WithTransaction was called with. It does not carry the finished transaction, so database calls in fn run normally outside it, and a transaction of another driver that ctx carried stays. An enclosing session also stays: hook SQL uses its retained connection. A completed matching callback context is rejected instead of registering a hook that can never run.

Every hook runs even when an earlier one fails. Their errors are joined, wrapped with ErrAfterCommit, and returned by the outermost WithTransaction after the commit. Because the data is committed, a caller that sees ErrAfterCommit must not retry the transaction: retrying would duplicate the work. Outside a transaction, AfterCommit returns the error of fn wrapped the same way; inside one it registers fn and returns nil. A panic in fn propagates out of the outermost WithTransaction after the commit, and the hooks after it do not run.

The ctx must not be used from several goroutines at once, so AfterCommit takes no lock.

Example:

Import cache from arkoren.dev/core/service/cache. Here store is a *cache.Cache, id is a string product ID, and updateProduct is the published update query. The raw key preserves the existing product:v1: naming scheme; its any value type is sufficient because deletion never decodes the entry.

productKey := cache.NewKey[any]("product:v1:" + id)
err := db.WithTransaction(ctx, func(ctx context.Context) error {
	if _, err := db.Exec(ctx, updateProduct); err != nil {
		return err
	}

	return db.AfterCommit(ctx, func(ctx context.Context) error {
		return store.Delete(ctx, productKey) // runs only if the update committed
	})
})
if errors.Is(err, database.ErrAfterCommit) {
	logger.WarnContext(ctx, "cache invalidation failed", "error", err) // the update committed
}

func (*Database) Cursor

func (database *Database) Cursor[T any](ctx context.Context, s query.Statementer) collection.TryLazySlice[T]

Cursor compiles s for the driver dialect and returns a lazy cursor over its results. A build error, including a query.Query that returns no rows, is yielded by the cursor. Exhaustion and scan failures close the rows before yielding a final error; errors.Is matches both the original failure and any Close error. Stopping iteration early still closes rows, but Close failures can only be recorded in telemetry because an iterator cannot yield again after the consumer stops. Use Driver.Query and explicitly handle Rows.Close when early-stop cleanup failures must be returned to application code.

Example:

for user, err := range db.Cursor[User](ctx, query.Table("users")).Iter() {
	if err != nil {
		return err
	}
	_ = user
}

func (*Database) CursorPaginate

func (database *Database) CursorPaginate[T any](
	ctx context.Context,
	statement query.Query,
	p pagination.CursorParams,
) (pagination.Cursor[T], error)

CursorPaginate fetches one keyset window of query. The cursor in p, when set, positions the window; p is normalized with the default limits. The ordered columns of query must exist as fields of T (matched by db tag or lowercase field name, as in query.Values). End the order with a unique tiebreaker; uniqueness is the caller's responsibility and is not checked. A malformed cursor or one for other columns or directions fails with pagination.ErrCursorDecode. Without Builder.CursorCodec, positions are unsigned and can be forged. With a codec, unsigned, altered and wrongly signed cursors fail with ErrCursorDecode. A query with a Limit or Offset fails with ErrPaged. Like Paginate, it requires a SELECT: row-returning mutations fail with query.ErrInvalidQuery before result-type reflection or SQL execution. Every ordered value in the selected dataset must be non-NULL; NULL ordering is unsupported. Use non-nullable keys or explicitly filter out NULL rows. All fetched rows, including lookahead, are checked for NULL keys and fail with ErrCursorValue. This cannot detect NULL rows that a SQL seek excludes. SQL nullable values are checked through driver.Valuer; non-NULL values keep that method set for binding while cursor JSON retains its existing shape. Items is an eager collection of this window only. Local transformations do not execute SQL or change tokens, which retain the original row boundaries.

Example:

users := query.Table("users", func(statement *query.Builder) {
	statement.OrderBy("id")
})
page, err := db.CursorPaginate[User](ctx, users, pagination.CursorParams{PerPage: 20})
if err != nil {
	return err
}
_ = page

func (*Database) Dialect

func (database *Database) Dialect() query.Dialect

Dialect returns the dialect reported by Driver.Dialect. The query-query methods on Database compile for it.

func (*Database) Driver

func (database *Database) Driver() Driver

Driver returns the underlying root Driver, not the driver selected by a session or transaction context. Direct calls bypass both scopes and facade telemetry; use the facade with the callback context for protected work.

func (*Database) DriverAs

func (database *Database) DriverAs[T any]() (T, bool)

DriverAs returns the first driver of type T in the chain of the underlying driver. A driver that wraps another exposes it with an Unwrap() Driver method, so wrappers such as prefixing or tracing drivers do not hide the concrete driver.

func (*Database) Each

func (database *Database) Each[T any](
	ctx context.Context,
	statement query.Query,
	size int,
) collection.TryLazySlice[T]

Each lazily iterates every row of query in batches of size rows using keyset pagination, so memory stays bounded and rows inserted behind the position are not revisited. query needs an ORDER BY, otherwise ErrNoOrder is yielded. End it with a unique tiebreaker; uniqueness is not checked. The ordered columns must exist as fields of T. A query with a Limit or Offset yields ErrPaged. Rows are read in separate queries, not one snapshot, so a consistent view requires transaction isolation that preserves a snapshot across statements. A transaction alone, such as PostgreSQL's default read committed transaction, does not provide that guarantee. Row-returning mutations fail with query.ErrInvalidQuery before result-type reflection. Every ordered value in the selected dataset must be non-NULL, as for Database.CursorPaginate. Each checks every fetched row, including a short final batch, and yields ErrCursorValue for NULL keys. Cancellation is checked between buffered rows, not only between database queries.

Example:

users := query.Table("users", func(statement *query.Builder) {
	statement.OrderBy("id")
})
for user, err := range db.Each[User](ctx, users, 100).Iter() {
	if err != nil {
		return err
	}
	_ = user
}

func (*Database) Exec

func (database *Database) Exec(ctx context.Context, s query.Statementer) (int64, error)

Exec compiles s for the driver dialect, executes it, and returns the number of affected rows.

func (*Database) Find

func (database *Database) Find[T any](ctx context.Context, s query.Statementer) (T, error)

Find compiles s for the driver dialect and scans the single expected row. A query.Query must be a SELECT or have RETURNING; otherwise it fails with query.ErrInvalidQuery.

func (*Database) Lock

func (database *Database) Lock(ctx context.Context, name string) (func(context.Context) error, error)

Lock blocks until the named lock is held or ctx ends. The returned unlock function releases the lock.

Acquiring the lock is traced as a LOCK client span; releasing it is not.

func (*Database) Paginate

func (database *Database) Paginate[T any](
	ctx context.Context,
	statement query.Query,
	p pagination.PageParams,
) (pagination.Page[T], error)

Paginate counts the rows query selects and fetches the page p asks for. p is normalized with the default limits. query needs an ORDER BY so that pages are stable; otherwise it fails with ErrNoOrder. A query with a Limit or Offset fails with ErrPaged. The total comes from query.Query.Count, so grouped and raw sources are counted correctly. Items is an eager collection of this page only. Local transformations do not execute SQL or recompute the count or page metadata.

Example:

users := query.Table("users", func(statement *query.Builder) {
	statement.OrderBy("id")
})
page, err := db.Paginate[User](ctx, users, pagination.PageParams{PerPage: 20})
if err != nil {
	return err
}
_ = page

func (*Database) Select

func (database *Database) Select[T any](ctx context.Context, s query.Statementer) (collection.Slice[T], error)

Select compiles s for the driver dialect and scans every result row into a collection.Slice. A query.Query must be a SELECT or have RETURNING; otherwise it fails with query.ErrInvalidQuery.

func (*Database) TryLock

func (database *Database) TryLock(ctx context.Context, name string) (func(context.Context) error, bool, error)

TryLock takes the named lock without waiting. It reports acquired false, with a nil error, when another holder has the lock; otherwise unlock releases the lock it holds.

Taking the lock is traced as a TRY_LOCK client span, whether or not it was acquired; releasing it is not.

Example:

unlock, acquired, err := db.TryLock(ctx, "nightly-report")
if err != nil || !acquired {
	return err // Another instance is running the report.
}
defer unlock(ctx)

func (*Database) WithSession

func (database *Database) WithSession(ctx context.Context, fn func(ctx context.Context) error) error

WithSession runs fn on one retained physical connection without beginning a transaction. Every facade over the same driver joins through the supplied context, using the matching rules of Database.WithTransaction. Transactions started with it use that connection and retain their individual commits. An error does not undo autocommit work; a lost connection never reconnects.

Nested sessions borrow the existing connection but have their own callback lifetime and nontransactional locks. Within a transaction they preserve it, and new locks remain owned by its outermost transaction. The context and cursors expire when fn returns; later operations fail with ErrSessionDone. Use it synchronously from one goroutine, and use facade methods rather than Database.Driver, which bypasses the scope. Cleanup releases outstanding cursors and locks even after cancellation or panic; cleanup errors are joined with the body error. Arbitrary raw SQL session state is not reset.

Example:

err := db.WithSession(ctx, func(ctx context.Context) error {
	return db.WithTransaction(ctx, func(ctx context.Context) error {
		_, err := db.Exec(ctx, updateCounter)
		return err
	})
})

func (*Database) WithTransaction

func (database *Database) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error

WithTransaction executes fn within a database transaction and passes it a context that carries the transaction. fn returning nil commits; an error rolls back and is returned; a panic rolls back and is re-raised.

Every operation of this Database, and of any other Database over the same driver, that is given the context runs inside the transaction: Exec, Select, Find, Cursor, Paginate, CursorPaginate, Each, and Lock. Operations given any other context are not part of it. The context is valid until fn returns; using it afterwards fails with sql.ErrTxDone. Do not use it from several goroutines at once. Canceling ctx rolls back the whole transaction, nested transactions included, and WithTransaction then fails even when fn returns nil.

Calling WithTransaction with a context that is already inside a transaction of the same driver starts a nested transaction through Driver.WithTransaction on the transaction driver, or fails with ErrNestedTransaction when the driver cannot nest. A transaction inside Database.WithSession uses the retained connection; its successful commit does not end that session or its preexisting locks.

The transaction has no span of its own, because the database semantic conventions define none; operations inside it become children of the span in ctx. With Builder.Events, the outermost transaction publishes TransactionCommitted after its commit and before the after-commit hooks, or TransactionRolledBack when its body or commit fails. Post-commit driver cleanup failures wrap ErrAfterCommit: the committed event and hooks still run. A body error bearing this marker still requests rollback. Nested transactions publish nothing.

Example:

err := db.WithTransaction(ctx, func(ctx context.Context) error {
	if _, err := db.Exec(ctx, insertOrder); err != nil {
		return err
	}

	return orders.Reserve(ctx, items) // joins the transaction
})

type Driver

type Driver interface {
	// Dialect returns the placeholder and identifier syntax the database
	// accepts. [Database] compiles published queries for it, and migrations
	// switch on it. A wrapping driver must forward the dialect of the driver
	// it wraps; a transaction driver reports the dialect of its parent.
	Dialect() query.Dialect

	// Exec executes a data-modifying query and returns the number of rows it
	// changed.
	Exec(ctx context.Context, statement string, args ...any) (int64, error)

	// Select scans every result row into dest, a pointer to a slice of
	// structs, of pointers to structs, or of single-column values. No rows is
	// not an error and leaves dest empty.
	Select(ctx context.Context, statement string, dest any, args ...any) error

	// Query opens a cursor for query results.
	Query(ctx context.Context, statement string, args ...any) (Rows, error)

	// Find scans a single query result into dest, a pointer to a struct or
	// to a single-column value. It fails with
	// [ErrNoRows] when no row matches and scans the first row when several
	// match.
	Find(ctx context.Context, statement string, dest any, args ...any) error

	// WithSession reserves one physical connection for fn without beginning
	// a transaction. SQL, locks, and transactions through session use that
	// connection; connection loss never reconnects or replays fn. Previously
	// committed statements are not undone when fn returns an error.
	//
	// A nested session borrows the connection, preserves an active transaction,
	// and gives fn a separate callback lifetime. Its new nontransactional locks
	// are released when it returns; enclosing locks remain held. Locks acquired
	// inside a transaction belong to its outermost transaction instead.
	//
	// Drivers close outstanding cursors and release remaining owned locks before
	// returning the connection. Cleanup ignores caller cancellation and discards
	// a connection whose release cannot be established. Errors preserve fn's
	// error and cleanup errors; a panic is re-raised after cleanup. Cancellation
	// fails the scope even if fn returns nil. Arbitrary SQL session state is not
	// reset automatically. Use the session synchronously, from one goroutine.
	// Its driver and cursors expire when fn returns: later I/O fails with
	// [ErrSessionDone], never by accessing a returned or reused connection.
	//
	// Example:
	//
	// 	err := driver.WithSession(ctx, func(session database.Driver) error {
	// 		return session.WithTransaction(ctx, func(tx database.Driver) error {
	// 			_, err := tx.Exec(ctx, "UPDATE counters SET value = value + 1")
	// 			return err
	// 		})
	// 	})
	WithSession(ctx context.Context, fn func(session Driver) error) error

	// WithTransaction executes fn in a transaction started with the driver
	// defaults. It commits when fn returns nil and rolls back when fn returns
	// an error, which WithTransaction returns (matching it with errors.Is),
	// or panics, which WithTransaction re-raises after the rollback. The tx
	// passed to fn runs every operation inside the transaction.
	//
	// Calling WithTransaction on a tx starts a nested transaction: it rolls
	// back only its own work, and that work is final only when the outermost
	// transaction commits. A driver that cannot nest fails with an error
	// matching [ErrNestedTransaction].
	//
	// The callback driver and its cursors expire when fn returns, including a
	// nested callback: later I/O matches [sql.ErrTxDone]. Within a session the
	// transaction uses that same connection. Drivers retain the connection until
	// the transaction ends and its named locks are released. Cleanup failure
	// after a confirmed commit wraps [ErrAfterCommit]; a body error bearing that
	// marker still requests rollback, not commit.
	WithTransaction(ctx context.Context, fn func(tx Driver) error) error

	// Lock blocks until the named lock is held or ctx ends, and fails with
	// [ErrEmptyLockName] for an empty name. Any other name is valid whatever
	// its length. Locks are exclusive and not reentrant. The returned unlock
	// releases the lock and may be called more than once.
	// Inside a transaction the lock is held until the transaction ends,
	// whether it commits or rolls back, and unlock does nothing.
	// Locks first acquired inside a nested transaction survive its rollback or
	// panic until the outermost transaction ends. Session-level locks acquired
	// before the transaction are not released by it. Native lock names/keys may
	// be derived from name; non-reentrancy applies to that derived identity too.
	//
	// Network drivers retain one pool connection per raw held lock. Root waiters
	// release contended connections between nonblocking attempts; a session or
	// transaction already reserves its connection and cannot release it while
	// waiting. Use TryLock and end a contended session when query capacity is
	// needed elsewhere. Raw holders can still exhaust a bounded pool.
	Lock(ctx context.Context, name string) (unlock func(context.Context) error, err error)

	// TryLock takes the named lock without waiting. It returns acquired true
	// with the unlock when the lock is now held, and acquired false with a nil
	// error and a nil unlock when another holder has it. Anything that
	// prevents asking, such as an ended ctx, is an error with acquired false.
	// It does not wait for lock contention; pool acquisition and network I/O
	// can still wait under ctx. A scoped driver never acquires a second connection.
	// The name rules, exclusivity, unlock behavior, and in-transaction
	// behavior are those of [Driver.Lock].
	//
	// Use TryLock when only one process should do a piece of work and the
	// others should skip it rather than queue behind it.
	TryLock(ctx context.Context, name string) (unlock func(context.Context) error, acquired bool, err error)
}

Driver defines SQL query execution, scanning, and transaction management.

Queries use the placeholder syntax of the database (see Driver.Dialect); named parameters come from query.RawNamed, which the Database compiles before it reaches the driver. Rows are scanned into structs by the column rules of query.Targets. Every method that takes a ctx fails with an error matching ctx.Err() when the context ended before or during the operation. Conformance for every driver is checked by package databasetest.

Example:

err := driver.WithSession(ctx, func(session database.Driver) error {
	var one int
	return session.Find(ctx, "SELECT 1", &one)
})

type JSON

type JSON[T any] struct {
	// V is the decoded value.
	V T
}

JSON stores a Go value of type T in a JSON column. Use it as a struct field type so the same code reads and writes JSON on every database.

It exists because Postgres, MySQL, and SQLite return JSON columns as different Go types (SQLite returns a string from the TEXT column that holds the document, the others a []byte). An encoding/json RawMessage field therefore fails to scan from a string on SQLite, and a string field fails elsewhere. JSON accepts both forms in JSON.Scan and always writes the encoded document as a string in JSON.Value, which every driver accepts for json, jsonb, and TEXT columns.

The zero JSON holds the zero V and uses encoding/json/v2 defaults: a nil pointer becomes JSON null, a nil map {}, a nil ordinary slice [], and nil bytes an empty base64 JSON string. Value returns a string, never SQL NULL. These representations follow T and its JSON methods; custom marshalers can choose a different valid document or return an encoding error.

Example:

type Account struct {
	ID       int                            `db:"id"`
	Settings database.JSON[map[string]bool] `db:"settings"`
}

account := Account{ID: 1, Settings: database.JSON[map[string]bool]{V: map[string]bool{"beta": true}}}
_, err := db.Exec(ctx, query.Raw("INSERT INTO accounts (id, settings) VALUES (?, ?)", account.ID, account.Settings))
if err != nil {
	return err
}

func (*JSON[T]) Scan

func (jsonValue *JSON[T]) Scan(src any) error

Scan implements database/sql.Scanner. It decodes src, which must be a string or a []byte holding a JSON document, into V. A nil src (SQL NULL) resets V to its zero value and succeeds. Any other source type fails with an error, as does a document that does not decode into T; V is left as it was on error.

Example:

var settings database.JSON[map[string]bool]
if err := settings.Scan(`{"beta":true}`); err != nil {
	return err
}
_ = settings.V["beta"]

func (JSON[T]) Value

func (jsonValue JSON[T]) Value() (driver.Value, error)

Value implements driver.Valuer. It returns V encoded as a JSON string, or an encoding error wrapped with database context. Encoding uses JSON v2's nil container defaults, not legacy null-map/null-slice behavior. The returned string is a document, including "null" for a nil pointer, never SQL NULL.

Example:

value, err := (database.JSON[[]int]{}).Value()
if err != nil {
	return err
}
_ = value // The string "[]".

type Rows

type Rows interface {
	// Next advances the cursor to its next row. It returns false when the
	// rows are exhausted, after an error, or after Close.
	Next() bool

	// Scan copies the current row into dest, which must be a non-nil pointer
	// to a struct or to a single-column value. Columns map to struct fields
	// by the rules of [query.Targets].
	Scan(dest any) error

	// Err returns the error that ended iteration, or nil after the rows were
	// exhausted.
	Err() error

	// Close releases cursor resources. It is safe to call more than once.
	// Callers must close a cursor, because it can hold a connection.
	Close() error
}

Rows represents an open database cursor for streaming query results.

type TransactionEvent

type TransactionEvent struct {
	// System is the db.system.name telemetry attribute of the database.
	System string

	// Namespace is [Builder.Namespace]; empty when it is not set.
	Namespace string

	// Err records the body, context, or commit failure. It is nil for a
	// confirmed commit, even when later lock cleanup or after-commit hooks
	// fail and WithTransaction returns ErrAfterCommit.
	Err error
}

TransactionEvent is the payload of TransactionCommitted and TransactionRolledBack.

Directories

Path Synopsis
Package databasetest provides a conformance suite for database.Driver implementations.
Package databasetest provides a conformance suite for database.Driver implementations.
internal/suite
Package suite implements database driver checks and their private fixtures.
Package suite implements database driver checks and their private fixtures.
Package migrate applies ordered schema migrations to a database.Database and records them in a bookkeeping table.
Package migrate applies ordered schema migrations to a database.Database and records them in a bookkeeping table.

Jump to

Keyboard shortcuts

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