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 ¶
- Variables
- type Builder
- func (connection *Builder) CursorCodec(codec *pagination.CursorCodec) *Builder
- func (connection *Builder) Events(bus *event.Bus) *Builder
- func (connection *Builder) Namespace(namespace string) *Builder
- func (connection *Builder) QueryText(enabled bool) *Builder
- func (connection *Builder) System(system string) *Builder
- func (connection *Builder) Telemetry(t *telemetry.Telemetry) *Builder
- type BuilderFunc
- type Database
- func (database *Database) AfterCommit(ctx context.Context, fn func(ctx context.Context) error) error
- func (database *Database) Cursor[T any](ctx context.Context, s query.Statementer) collection.TryLazySlice[T]
- func (database *Database) CursorPaginate[T any](ctx context.Context, statement query.Query, p pagination.CursorParams) (pagination.Cursor[T], error)
- func (database *Database) Dialect() query.Dialect
- func (database *Database) Driver() Driver
- func (database *Database) DriverAs[T any]() (T, bool)
- func (database *Database) Each[T any](ctx context.Context, statement query.Query, size int) collection.TryLazySlice[T]
- func (database *Database) Exec(ctx context.Context, s query.Statementer) (int64, error)
- func (database *Database) Find[T any](ctx context.Context, s query.Statementer) (T, error)
- func (database *Database) Lock(ctx context.Context, name string) (func(context.Context) error, error)
- func (database *Database) Paginate[T any](ctx context.Context, statement query.Query, p pagination.PageParams) (pagination.Page[T], error)
- func (database *Database) Select[T any](ctx context.Context, s query.Statementer) (collection.Slice[T], error)
- func (database *Database) TryLock(ctx context.Context, name string) (func(context.Context) error, bool, error)
- func (database *Database) WithSession(ctx context.Context, fn func(ctx context.Context) error) error
- func (database *Database) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
- type Driver
- type JSON
- type Rows
- type TransactionEvent
Constants ¶
This section is empty.
Variables ¶
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") )
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") )
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 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 ¶
Dialect returns the dialect reported by Driver.Dialect. The query-query methods on Database compile for it.
func (*Database) 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 ¶
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 ¶
Exec compiles s for the driver dialect, executes it, and returns the number of affected rows.
func (*Database) Find ¶
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 ¶
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 ¶
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.
Source Files
¶
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. |