concerns

package
v0.32.0 Latest Latest
Warning

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

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package concerns holds the shared halves of a connection and a query builder: ManagesTransactions, BuildsQueries, BuildsWhereDateClauses, CompilesJsonPaths, ExplainsQueries and ParsesSearchPath.

Each takes one of two shapes, chosen by what it does:

  • one that carries state becomes a struct the user embeds, with the calls it makes back into its user written out as an interface -- ManagesTransactions, which Connection embeds;
  • one that carries none becomes functions taking what the receiver was.

An initialism is upper case: ToSQL, ChunkByID, WrapJSONPath.

Every read here carries a Grant

Chunk, Each, First, FirstOrFail, Sole, Lazy and Explain all execute, so they all take an auth.Grant and hand it to the query on every page they fetch. Authorization is not a rule about writes: a chunked export that skipped the Policy is a leak between tenants with a pleasant name, and a chunk loop is exactly where somebody would be tempted to authorize once and then not.

Two of these are declared here for a reason worth reading

DeadlockError and ErrRecordNotFound are declared here rather than in the database package, which imports this one -- Connection embeds ManagesTransactions -- so declaring them there would close an import cycle. The database package re-exports both as aliases, so database.DeadlockException and concerns.DeadlockError are one type, and errors.Is works across the two names because there is only one.

Building a page is not here: hesape/pagination builds a page from rows somebody already fetched, and that is the one way.

Index

Constants

View Source
const DefaultChunkSize = 1000

DefaultChunkSize is the conventional chunk size for ChunkMap, Each, Lazy and LazyByID.

Variables

View Source
var ErrNoTransactionsManager = errors.New("Transactions Manager has not been set.")

ErrNoTransactionsManager is the error AfterCommit and AfterRollBack raise when no manager was set.

View Source
var ErrRecordNotFound = errors.New("No record found for the given query.")

ErrRecordNotFound is what FirstOrFail returns when nothing matched.

It is declared here rather than in the database package because that package imports this one, and Go refuses the cycle. The database package re-exports it, so database.ErrRecordNotFound and concerns.ErrRecordNotFound are one value under two names.

View Source
var ErrRecordsNotFound = errors.New("records not found")

ErrRecordsNotFound is what Sole returns when the query matched no row at all.

Sole is the query that asserts "there is exactly one of these", so both ways of being wrong are errors: none is this one, more than one is MultipleRecordsFoundError. A caller that would rather have a zero value than an error wants First, not Sole.

View Source
var Now = time.Now

Now is where BuildsWhereDateClauses reads the clock.

The clock is a variable a test replaces and restores. Everything below reads it, so freezing it freezes all eighteen clauses at once.

Functions

func Chunk

func Chunk[T any](ctx context.Context, g auth.Grant, query Chunkable[T], count int, callback func(results []T, page int) bool) (bool, error)

Chunk walks the result set a page at a time so a large table does not have to fit in memory.

The callback returns false to stop, and Chunk returns false when it was stopped. An unordered query returns the EnforceOrderBy error instead of running, because a chunk that silently repeats rows is the failure the check exists to prevent.

An existing limit and offset on the query are honoured: the offset shifts the first page and the limit bounds the total.

func ChunkByID

func ChunkByID[T any](ctx context.Context, g auth.Grant, query KeyChunkable[T], count int, callback func(results []T, page int) bool, column, alias string) (bool, error)

ChunkByID chunks the query by comparing ids rather than by offset.

It is the one to reach for when the rows being walked are also being written to. An offset-based chunk over a table that is having rows inserted into it skips rows, because page two of a list that grew by one row starts where page one already ended.

column empty takes the query's own key; alias empty takes column.

func ChunkByIDDesc

func ChunkByIDDesc[T any](ctx context.Context, g auth.Grant, query KeyChunkable[T], count int, callback func(results []T, page int) bool, column, alias string) (bool, error)

ChunkByIDDesc is ChunkByID in descending order.

func ChunkMap

func ChunkMap[T, R any](ctx context.Context, g auth.Grant, query Chunkable[T], count int, callback func(T) R) ([]R, error)

ChunkMap chunks the query, and collects what the callback returns for every row.

Go has no default arguments, so the count is always given -- pass DefaultChunkSize for the conventional default.

func Each

func Each[T any](ctx context.Context, g auth.Grant, query Chunkable[T], count int, callback func(value T, key int) bool) (bool, error)

Each chunks the query, and hands the callback one row at a time with its index.

The index is the position within the chunk, resetting to zero on every page. EachByID numbers across chunks instead, which is a deliberate difference between the two, kept.

func EachByID

func EachByID[T any](ctx context.Context, g auth.Grant, query KeyChunkable[T], callback func(value T, key int) bool, count int, column, alias string) (bool, error)

EachByID chunks the query by id, and hands the callback one row at a time.

The index runs across chunks -- ((page - 1) * count) + key -- which is why it differs from Each.

func Explain

func Explain(ctx context.Context, g auth.Grant, query Explainable) ([]map[string]any, error)

Explain asks the engine what it would do with this query, without running it.

It returns the rows and the error the select can fail with. The statement is 'EXPLAIN ' concatenated with the compiled SQL, with the query's own bindings.

func First

func First[T any](ctx context.Context, g auth.Grant, query Chunkable[T]) (T, bool, error)

First limits the query to one row and returns it.

It returns (zero, false) for an empty result, because a zero-valued struct is indistinguishable from a row of zeroes and a caller that cannot tell them apart writes a bug that only shows up on real data.

func FirstOrFail

func FirstOrFail[T any](ctx context.Context, g auth.Grant, query Chunkable[T], message string) (T, error)

FirstOrFail is First, failing instead of returning false when no row matched.

message empty takes a default sentence. The error wraps ErrRecordNotFound, so errors.Is keeps working when a caller adds context to it and the exception classifier still reports 404.

func Lazy

func Lazy[T any](ctx context.Context, g auth.Grant, query Chunkable[T], chunkSize int) func(yield func(T, error) bool)

Lazy returns an iterator over the whole result set, fetched a chunk at a time.

Go 1.23 has range-over-func, so this returns an iter.Seq2-shaped function: the loop reads

for row, err := range concerns.Lazy(ctx, g, q, 1000) {

and an error ends the iteration after being yielded once, so a caller that forgets to check it still stops rather than looping on a broken query.

func LazyByID

func LazyByID[T any](ctx context.Context, g auth.Grant, query KeyChunkable[T], chunkSize int, column, alias string) func(yield func(T, error) bool)

LazyByID is Lazy, chunked by comparing ids rather than by offset.

func LazyByIDDesc

func LazyByIDDesc[T any](ctx context.Context, g auth.Grant, query KeyChunkable[T], chunkSize int, column, alias string) func(yield func(T, error) bool)

LazyByIDDesc is LazyByID in descending order.

func OrWhereAfterToday

func OrWhereAfterToday(b *query.Builder, columns ...any) *query.Builder

OrWhereAfterToday is WhereAfterToday, combined with or.

func OrWhereBeforeToday

func OrWhereBeforeToday(b *query.Builder, columns ...any) *query.Builder

OrWhereBeforeToday is WhereBeforeToday, combined with or.

func OrWhereFuture

func OrWhereFuture(b *query.Builder, columns ...any) *query.Builder

OrWhereFuture is WhereFuture, combined with or.

func OrWhereNowOrFuture

func OrWhereNowOrFuture(b *query.Builder, columns ...any) *query.Builder

OrWhereNowOrFuture is WhereNowOrFuture, combined with or.

func OrWhereNowOrPast

func OrWhereNowOrPast(b *query.Builder, columns ...any) *query.Builder

OrWhereNowOrPast is WhereNowOrPast, combined with or.

func OrWherePast

func OrWherePast(b *query.Builder, columns ...any) *query.Builder

OrWherePast is WherePast, combined with or.

func OrWhereToday

func OrWhereToday(b *query.Builder, columns ...any) *query.Builder

OrWhereToday is WhereToday, combined with or.

func OrWhereTodayOrAfter

func OrWhereTodayOrAfter(b *query.Builder, columns ...any) *query.Builder

OrWhereTodayOrAfter is WhereTodayOrAfter, combined with or.

func OrWhereTodayOrBefore

func OrWhereTodayOrBefore(b *query.Builder, columns ...any) *query.Builder

OrWhereTodayOrBefore is WhereTodayOrBefore, combined with or.

func OrderedChunkByID

func OrderedChunkByID[T any](ctx context.Context, g auth.Grant, query KeyChunkable[T], count int, callback func(results []T, page int) bool, column, alias string, descending bool) (bool, error)

OrderedChunkByID is the body ChunkByID and ChunkByIDDesc share.

It returns an error when the aliased column is not in the result. It is worth the words it costs: a chunk whose id column was not selected loops on page one forever otherwise.

func ParseSearchPath

func ParseSearchPath(searchPath any) []string

ParseSearchPath reads Postgres's search_path option, which arrives as either a string or a list.

"public,reporting"        ->  ["public", "reporting"]
`"public", 'reporting'`   ->  ["public", "reporting"]
[]string{"public"}        ->  ["public"]

It takes any because the value comes out of a configuration file where either shape is legal. A value of any other type answers an empty list.

func Pipe

func Pipe[Q, R any](query Q, callback func(Q) R) R

Pipe hands the query to callback, and gives back what callback returned.

There is no fallback to the original query when callback returns a zero value: a function returning R returns an R, and Go has no null to fall back from. It is not missed -- Tap already covers wanting the query back.

func Sole

func Sole[T any](ctx context.Context, g auth.Grant, query Chunkable[T]) (T, error)

Sole returns the one row that matched, and an error when there was none or more than one.

It fetches two rows, not one, which is the whole point: a query for "the invoice for this reference" has to be able to say that two of them exist.

func Tap

func Tap[Q any](query Q, callback func(Q)) Q

Tap hands the query to callback, and gives the query back.

func WhereAfterToday

func WhereAfterToday(b *query.Builder, columns ...any) *query.Builder

WhereAfterToday adds a where clause requiring each column to fall after today's date, combined with and.

func WhereBeforeToday

func WhereBeforeToday(b *query.Builder, columns ...any) *query.Builder

WhereBeforeToday adds a where clause requiring each column to fall before today's date, combined with and.

func WhereFuture

func WhereFuture(b *query.Builder, columns ...any) *query.Builder

WhereFuture adds a where clause requiring each column to be after now, combined with and.

func WhereNowOrFuture

func WhereNowOrFuture(b *query.Builder, columns ...any) *query.Builder

WhereNowOrFuture adds a where clause requiring each column to be at or after now, combined with and.

func WhereNowOrPast

func WhereNowOrPast(b *query.Builder, columns ...any) *query.Builder

WhereNowOrPast adds a where clause requiring each column to be at or before now, combined with and.

func WherePast

func WherePast(b *query.Builder, columns ...any) *query.Builder

WherePast adds a where clause requiring each column to be before now, combined with and.

func WhereToday

func WhereToday(b *query.Builder, boolean string, columns ...any) *query.Builder

WhereToday adds a where clause requiring each column to fall on today's date. boolean empty defaults to "and".

func WhereTodayOrAfter

func WhereTodayOrAfter(b *query.Builder, columns ...any) *query.Builder

WhereTodayOrAfter adds a where clause requiring each column to fall on or after today's date, combined with and.

func WhereTodayOrBefore

func WhereTodayOrBefore(b *query.Builder, columns ...any) *query.Builder

WhereTodayOrBefore adds a where clause requiring each column to fall on or before today's date, combined with and.

func WrapJSONFieldAndPath

func WrapJSONFieldAndPath(wrap Wrapper, column string) (field, path string)

WrapJSONFieldAndPath splits `options->language` into the wrapped column and the JSON path, so a grammar can put them either side of whatever its engine spells the extraction function.

The path comes back with a leading ", " already on it, so a grammar concatenating the two gets a ready-made argument list.

func WrapJSONPath

func WrapJSONPath(value, delimiter string) string

WrapJSONPath turns `language->code` into the quoted '$."language"."code"' a JSON function takes.

func WrapJSONPathSegment

func WrapJSONPathSegment(segment string) string

WrapJSONPathSegment quotes the key and leaves the array subscript outside the quotes, because '$."tags"[0]' selects an element and '$."tags[0]"' selects a key that does not exist.

Types

type Chunkable

type Chunkable[T any] interface {
	// GetOffset returns the query's offset, or nil when none is set.
	GetOffset() *int

	// GetLimit returns the query's limit, or nil when none is set.
	GetLimit() *int

	// Offset sets the query's offset.
	Offset(value int)

	// Limit sets the query's limit.
	Limit(value int)

	// ForPage sets the offset and limit for one page of results.
	ForPage(page, perPage int)

	// Get runs the query.
	Get(ctx context.Context, g auth.Grant) ([]T, error)

	// EnforceOrderBy fails when the query has no order. Chunking an
	// unordered result set returns rows twice and skips others, and the
	// database is within its rights.
	EnforceOrderBy() error
}

Chunkable is what the chunking functions ask of the query they walk.

It is an interface rather than a concrete builder, because a function that took *query.Builder would only work for that one builder.

T is the row type. A repository knows what it selects, so the chunk arrives typed.

Get takes an auth.Grant because it executes. Authorization does not bend for a read: List, Find, First, Get, Paginate, an export and a report all pass through a Policy and filter by auth.Tenant(g). The Grant travels from the caller of Chunk down to every page it fetches, so a chunked read is authorized once and stays authorized.

type DeadlockError

type DeadlockError struct {
	// Err is the driver error the engine reported.
	Err error
}

DeadlockError reports that a nested transaction hit a concurrency error, and the whole transaction is gone.

It is declared here because ManagesTransactions is what raises it and the database package imports this one, so declaring it there would close the cycle. database.DeadlockException is an alias of this type -- one type under two names.

It is not retried, and that is the point of having its own type: on a deadlock the engine has already rolled the whole transaction back, so re-running the statement runs it outside the transaction the caller thinks it is in.

func NewDeadlockError

func NewDeadlockError(err error) *DeadlockError

NewDeadlockError wraps the driver error that reported a deadlock.

func (*DeadlockError) Error

func (e *DeadlockError) Error() string

Error carries the driver's own message through unchanged.

func (*DeadlockError) Unwrap

func (e *DeadlockError) Unwrap() error

Unwrap makes errors.Is and errors.As reach the driver error.

type ExplainConnection

type ExplainConnection interface {
	// Select runs a select. The Grant is required because this executes:
	// there is no exception for a query plan, and an EXPLAIN that skipped
	// the Policy would be a way to learn about rows the caller may not read.
	Select(ctx context.Context, g auth.Grant, query string, bindings []any) ([]map[string]any, error)
}

ExplainConnection is the connection Explain runs the EXPLAIN through.

A row is a map because EXPLAIN answers a different shape on every engine -- Postgres one text column, MySQL twelve -- and a struct for it would be a struct for one of the three.

type Explainable

type Explainable interface {
	// ToSQL returns the query compiled to SQL.
	ToSQL() string

	// GetBindings returns the values that go with it.
	GetBindings() []any

	// GetConnection returns the connection to run EXPLAIN on, narrowed to
	// the one call Explain makes on it.
	GetConnection() ExplainConnection
}

Explainable is what ExplainsQueries asks of the query it explains.

A query builder reaches for its own compiled SQL, its bindings and its connection; those three are written out here as an interface, because what is a mixin in a dynamic language is an interface in Go.

type KeyChunkable

type KeyChunkable[T any] interface {
	Chunkable[T]

	// Clone returns a copy of the query, so the where clause added for the
	// last id does not accumulate across pages.
	Clone() KeyChunkable[T]

	// DefaultKeyName returns the query's own key column.
	DefaultKeyName() string

	// ForPageAfterID adds a where clause that reaches perPage rows after
	// lastID, ordered by column.
	ForPageAfterID(perPage int, lastID any, column string)

	// ForPageBeforeID is ForPageAfterID in reverse order.
	ForPageBeforeID(perPage int, lastID any, column string)

	// ValueOf reads the value named by alias off the last row of a chunk. A
	// row here is a T rather than an untyped map, so the builder that
	// produced it is the only thing that can say which field the alias
	// names.
	ValueOf(row T, alias string) (any, bool)
}

KeyChunkable is what ChunkByID adds to Chunkable: paging by a comparison against the last id seen rather than by an offset.

It is a separate interface because the id-based methods are the ones a builder can only offer when it knows its key, and because Chunk works without them.

type ManagesTransactions

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

ManagesTransactions is the transaction half of a connection: the nesting level, the transactions manager, and the hooks that run before one starts.

It is a struct Connection embeds, so the state it owns is the connection's state.

Savepoints exist here and are not the framework's transaction story

The nested transaction below opens a savepoint. That is not the path an Arandu application takes: database.Transaction joins the outer transaction rather than nesting, on the grounds that partial rollback is a second failure mode for one operation. The generated repository uses database.Transaction.

func (*ManagesTransactions) AfterCommit

func (m *ManagesTransactions) AfterCommit(callback func()) error

AfterCommit runs callback once the outermost transaction commits, or now when there is none open.

func (*ManagesTransactions) AfterRollBack

func (m *ManagesTransactions) AfterRollBack(callback func()) error

AfterRollBack runs callback when the current transaction rolls back.

func (*ManagesTransactions) BeforeStartingTransaction

func (m *ManagesTransactions) BeforeStartingTransaction(callback func())

BeforeStartingTransaction registers a hook to run just before a transaction opens.

It lives here rather than on the connection because the slice it appends to is read by BeginTransaction, which is here too.

func (*ManagesTransactions) BeginTransaction

func (m *ManagesTransactions) BeginTransaction() error

BeginTransaction opens a new transaction, or a nested savepoint if one is already open.

func (*ManagesTransactions) Commit

func (m *ManagesTransactions) Commit() error

Commit commits the current transaction level, for a transaction somebody began by hand rather than through Transaction.

func (*ManagesTransactions) ResetTransactionLevel

func (m *ManagesTransactions) ResetTransactionLevel()

ResetTransactionLevel sets the nesting level back to zero.

Replacing the handle throws away whatever transaction was open on the old one, and a level that outlived its connection is a rollback aimed at nothing.

func (*ManagesTransactions) RollBack

func (m *ManagesTransactions) RollBack(toLevel *int) error

RollBack rolls the transaction back to toLevel, or back one level when toLevel is nil. A level outside the open range is ignored rather than refused: rolling back to a level that does not exist is a no-op, not a failure.

func (*ManagesTransactions) SetTransactionManager

func (m *ManagesTransactions) SetTransactionManager(manager TransactionsManager)

SetTransactionManager sets the manager that records transaction lifecycle events.

func (*ManagesTransactions) Transaction

func (m *ManagesTransactions) Transaction(callback func() error, attempts int) error

Transaction runs callback inside a transaction, committing when it returns nil and rolling back when it returns an error.

attempts bounds the retry count, and it retries only what is worth retrying: a concurrency error at the outermost level. Anything else is returned on the first try, because re-running a statement that failed on a constraint just fails again, slower.

A Go method cannot be generic, so the callback returns only an error and carries its result out through the closure -- which is the shape database.Transaction already has.

func (*ManagesTransactions) TransactionLevel

func (m *ManagesTransactions) TransactionLevel() int

TransactionLevel reports how many transactions are open, counting savepoints.

func (*ManagesTransactions) UnsetTransactionManager

func (m *ManagesTransactions) UnsetTransactionManager()

UnsetTransactionManager clears the transactions manager.

func (*ManagesTransactions) UseTransactions

func (m *ManagesTransactions) UseTransactions(driver TransactionDriver)

UseTransactions wires ManagesTransactions to the connection that embeds it.

A Go struct has to be told which driver it belongs to, so the connection calls this once from its constructor.

type MultipleRecordsFoundError

type MultipleRecordsFoundError struct {
	// Count is how many rows the query matched.
	Count int
}

MultipleRecordsFoundError is what Sole returns when the query matched more than one row, and it carries how many it saw.

Sole reads two rows and stops, so Count is the number it actually fetched rather than the size of the whole result set -- enough to say "this is not unique", which is the only thing the caller can act on. The uniqueness the query assumed is missing, which is usually a missing unique index or a filter that lost a column.

func NewMultipleRecordsFoundError

func NewMultipleRecordsFoundError(count int) *MultipleRecordsFoundError

NewMultipleRecordsFoundError wraps the count of rows a Sole query matched.

func (*MultipleRecordsFoundError) Error

func (e *MultipleRecordsFoundError) Error() string

Error reports how many records were found.

func (*MultipleRecordsFoundError) GetCount

func (e *MultipleRecordsFoundError) GetCount() int

GetCount returns how many rows the query matched.

type TransactionDriver

type TransactionDriver interface {
	// GetName returns the connection's name. The transactions manager keys
	// its bookkeeping by it.
	GetName() string

	// ExecuteBeginTransactionStatement issues a BEGIN, pinning a connection
	// for the life of the transaction.
	ExecuteBeginTransactionStatement() error

	// CommitTransactionStatement issues a COMMIT.
	CommitTransactionStatement() error

	// RollBackTransactionStatement issues a ROLLBACK, and reports false when
	// there was no transaction open to roll back.
	RollBackTransactionStatement() (bool, error)

	// ExecuteSavepointStatement runs the statement the savepoint paths build.
	ExecuteSavepointStatement(sql string) error

	// SupportsSavepoints reports whether the query grammar supports
	// savepoints.
	SupportsSavepoints() bool

	// CompileSavepoint returns the statement that creates a savepoint.
	CompileSavepoint(name string) string

	// CompileSavepointRollBack returns the statement that rolls back to a
	// savepoint.
	CompileSavepointRollBack(name string) string

	// FireConnectionEvent dispatches the transaction event named by event:
	// one of "beganTransaction", "committing", "committed" or "rollingBack".
	FireConnectionEvent(event string)

	// CausedByConcurrencyError reports whether err means a deadlock or a
	// serialization failure.
	CausedByConcurrencyError(err error) bool

	// CausedByLostConnection reports whether err means the connection is
	// gone.
	CausedByLostConnection(err error) bool

	// Reconnect replaces the pool.
	Reconnect() error

	// ReconnectIfMissingConnection reconnects when the connection has no
	// pool yet.
	ReconnectIfMissingConnection() error
}

TransactionDriver is what ManagesTransactions drives.

Connection reaches straight for its pool, its query grammar and its own lost-connection detection. Those calls are written out here as an interface, which is the Go spelling of "this embedded type may only be used by a type that has these".

A Connection satisfies it; nothing else is meant to.

type TransactionsManager

type TransactionsManager interface {
	// Begin records that a new transaction level opened on connection.
	Begin(connection string, level int)

	// Commit records that a transaction level committed on connection.
	Commit(connection string, levelBeingCommitted, newTransactionLevel int)

	// Rollback records that connection rolled back to newTransactionLevel.
	Rollback(connection string, newTransactionLevel int)

	// AddCallback registers a callback to run after the outermost commit.
	AddCallback(callback func())

	// AddCallbackForRollback registers a callback to run after a rollback.
	AddCallbackForRollback(callback func())
}

TransactionsManager is what ManagesTransactions reports to.

It is narrowed to the five methods this file calls. It is an interface here and a concrete type in the database package for the usual reason: that package imports this one.

type Wrapper

type Wrapper func(value any) string

Wrapper is the grammar method that wraps a value for its dialect, passed in as a callback.

It is a function rather than an interface because it is one method, and an interface of one method that every grammar would satisfy anyway is ceremony.

Jump to

Keyboard shortcuts

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