Documentation
¶
Overview ¶
Package database provides the framework database facade built on top of GORM.
The package exposes a model-scoped Database handle for CRUD operations, query options, dry-run SQL generation, SQL capture, cleanup, health checks, and transactions. Each independent operation must start from Database[M](ctx); reusing a handle after a terminal operation can retain GORM clauses from the previous chain.
Tables used by Database[M](ctx) are expected to exist before an operation chain runs. Framework startup prepares registered tables through the internal database runtime.
A non-default database instance is a plain *gorm.DB the application builds once, typically with a dialect New function such as clickhouse.New, and holds itself. Chains reach it through DatabaseOn, AggregateOn, and TransactionOn; the entry point that opens a chain decides the instance, never a mid-chain option. Transactions never cross instances, and the application owns the instance's schema: the framework does not create tables on it.
Dialect support ¶
MySQL, PostgreSQL, and SQLite carry the full surface described above with identical behavior; the test suite runs against all three. The one stored time base is the UTC wall clock, and the two documented per-dialect splits are Upsert's conflict target (see Upsert) and row locks on SQLite (see WithLock).
Error-stack contract ¶
GORM and the SQL drivers hand back errors that carry no run-time stack trace, and the framework sentinels (ErrRecordNotFound, ErrDuplicatedKey) only carry the useless init-time stack of their package-level definition. This package therefore embeds the run-time stack at every first-hand exit of such an error — the first line where the error enters framework code — via errors.WithStack. The stack captured there still holds every caller frame, so the error_stack log field written by the logging layer (which reports the deepest run-time stack in the unwrap chain) locates the exact call site in any caller: service code, model hooks, DAOs, cron jobs — with no logging or wrapping required at the call sites themselves.
The rules, so a change keeps exactly one capture point per error chain:
- a stack-less GORM/driver/sentinel error is wrapped once, at its first-hand exit;
- errors this package builds itself (errors.New / errors.Wrap of a sentinel) already capture the stack at construction and are left alone;
- forwarding sites return errors unchanged: re-wrapping adds a shallower stack the deepest-stack rule ignores, at pure cost.
Wrapping preserves the unwrap chain, so errors.Is/As checks against ErrRecordNotFound, ErrDuplicatedKey, and friends behave exactly as before.
ClickHouse is an analytical instance (see clickhouse.New), never the default database. Supported on it:
- the read path: List, Get, Count, First, Last, Take, WithQuery, the filter operators, ordering, paging, and cursor pagination;
- the whole aggregate path: grouping, measures, conditional measures, time buckets, HAVING, ordering, paging, and CountGroups;
- a write path with a deliberately weaker contract — no model hooks, no transaction boundary: Create is plain batch INSERTs (no ErrDuplicatedKey; ClickHouse has no unique constraints), Delete is a lightweight DELETE by primary key and always physical (no soft delete), Update and UpdateByID are asynchronous ALTER TABLE ... UPDATE mutations for low-frequency data correction (accepted, not awaited; no ErrRecordNotFound). Each entry point's doc states the details.
Not carried by ClickHouse, failing closed to an empty result: correlated EXISTS subqueries (FilterExists) and JSON containment (jsoncontains). Not carried, answering ErrUnsupportedOnDialect: Upsert (no conflict semantics), Cleanup (no soft-delete regime), Transaction/TransactionOn, and WithLock. The instance's schema — engine, ORDER BY, partitioning — is hand-written DDL owned by the application: neither bootstrap nor "gg migrate" creates or alters ClickHouse tables, bootstrap only verifies that a registered model's table exists.
Index ¶
- Constants
- Variables
- func AfterCommit(ctx context.Context, fn func(context.Context) error) error
- func Aggregate[M types.Model, R any](ctx context.Context) types.Aggregator[M, R]
- func AggregateOn[M types.Model, R any](ctx context.Context, instance *gorm.DB) types.Aggregator[M, R]
- func Cleanup[M types.Model](ctx context.Context) error
- func CleanupOn[M types.Model](ctx context.Context, instance *gorm.DB) error
- func DB() *gorm.DB
- func Database[M types.Model](ctx context.Context) types.Database[M]
- func DatabaseOn[M types.Model](ctx context.Context, instance *gorm.DB) types.Database[M]
- func Health(ctx context.Context) error
- func HealthOn(ctx context.Context, instance *gorm.DB) error
- func Transaction(ctx context.Context, fn func(ctx context.Context) error) error
- func TransactionOn(ctx context.Context, instance *gorm.DB, fn func(ctx context.Context) error) error
- type NodeStats
Constants ¶
const ( RolePrimary = dbruntime.RolePrimary RoleReplica = dbruntime.RoleReplica )
Node roles reported in NodeStats: the writable primary, and the read replicas attached through the mysql.replicas / postgres.replicas configuration.
Variables ¶
var ( ErrEmptyProjection = errors.New("aggregate projection is empty") ErrNoAggregateFn = errors.New("aggregate projection declares no aggregate function, use List for a plain read") ErrInvalidAlias = errors.New("aggregate alias is not a valid identifier") ErrDuplicateAlias = errors.New("aggregate alias is declared twice") ErrAggregateType = errors.New("aggregate function does not accept this column type") ErrResultFieldMissing = errors.New("result row has no field for aggregate alias") ErrAliasMissing = errors.New("aggregate projection has no alias for result row field") ErrGroupedScanOne = errors.New("ScanOne cannot run a grouped aggregation, use Scan") ErrUnknownAggregateFn = errors.New("aggregate function is not one the framework defines") ErrUnknownTimeBucket = errors.New("time bucket is not one the framework defines") ErrUnknownHavingOp = errors.New("having comparison is not one the framework defines") ErrConditionOnGroupKey = errors.New("a group key cannot carry conditions, they only restrict a measure") ErrBucketOnMeasure = errors.New("a measure cannot carry a time bucket, it only truncates a group key") ErrHavingTermNotSelected = errors.New("having references a measure the projection does not declare") ErrOrderTermNotSelected = errors.New("order by references a term the projection does not declare") ErrNullableResultField = errors.New("result row field must be a pointer for an aggregate that yields NULL") ErrScanOnePaged = errors.New("ScanOne cannot use Having, Limit or Offset, it always reads one row") ErrOffsetWithoutLimit = errors.New("Offset needs a Limit") ErrAggregatorUnusable = errors.New("aggregate could not attach to the database chain") ErrHavingValue = errors.New("having compares against a value SQL cannot order") ErrUnknownOrderDirection = errors.New("order direction is not one the framework defines") )
Errors reported while an aggregate query is built. They all fail fast: an aggregate projection is written by service code, not parsed from a request, so a mistake in it is a programming error. Answering it with an empty result the way the filter layer answers a malformed client filter would disguise the bug as "no data today", which is the hardest reporting failure to trace.
var ( // ErrInvalidDB reports an operation chain running on a database handle // that was never initialized. ErrInvalidDB = errors.New("invalid database, maybe not initialized") // ErrNilCount is returned when Count or CountGroups is handed a nil // destination. ErrNilCount = errors.New("count parameter cannot be nil") // ErrNilDest is returned when a read operation is handed a nil // destination. ErrNilDest = errors.New("dest parameter cannot be nil") // ErrEmptyFieldName is returned when UpdateByID is handed an empty column // name. ErrEmptyFieldName = errors.New("field name cannot be empty") // ErrNilValue is returned when UpdateByID is handed a nil value. ErrNilValue = errors.New("value cannot be nil") // ErrNoAssignments is returned when UpdateByID is called without any // assignment. ErrNoAssignments = errors.New("update requires at least one assignment") // ErrDuplicateColumn is returned when one UpdateByID call assigns the // same column twice. ErrDuplicateColumn = errors.New("column is assigned twice in one update") // ErrIDRequired is returned when an operation that addresses records by // primary key is handed a record or an id argument without one. ErrIDRequired = errors.New("id is required") // ErrVersionRequired is returned when Update is handed a versioned record // (a model declaring model.Version) whose version is zero. A full-row // Update writes every column, so an object that was never read from the // database would both wipe columns with zero values and dodge the // optimistic lock; failing fast at the entry keeps the lock meaningful. // UpdateByID is the sanctioned way to write specific columns without // carrying a version. ErrVersionRequired = errors.New("version is required: a versioned record must carry the version it was read with") // ErrStaleObject is returned when a versioned write matched no row: the // record was modified — or deleted — by someone else after this caller // read it. The two cases are deliberately not distinguished (doing so // would take an extra query and still race); the handling is the same: // reload the record, let the caller re-decide over current data. Service // layers typically map it to HTTP 409. ErrStaleObject = errors.New("record was modified or deleted by another operation: reload and retry") // ErrRecordNotFound is the gorm sentinel for a read that matches no live // row; Get and Update also answer it for a missing or soft-deleted record. ErrRecordNotFound = gorm.ErrRecordNotFound // ErrDuplicatedKey is the gorm sentinel for a write colliding with a // primary or unique key, translated from the dialect's own error. ErrDuplicatedKey = gorm.ErrDuplicatedKey // ErrNilSQLBuilder is returned when WithDryRun is handed an explicitly nil // statement collector: the caller asked for statements it could never // receive. ErrNilSQLBuilder = errors.New("sql statement collector cannot be nil") // ErrNilTransaction is returned when Transaction or TransactionOn is // handed a nil closure. ErrNilTransaction = errors.New("transaction function cannot be nil") // ErrUnusableFilter reports a filter the renderer cannot apply. Client // query paths fail closed to an empty result and only log it; server-built // readers such as the aggregate builder surface it instead, because there // an unusable predicate would disguise a bug as "no data". ErrUnusableFilter = errors.New("filter cannot be applied") // ErrTransactionInstance is returned when the instance handed to DatabaseOn // or TransactionOn is itself an open transaction rather than the connection // it was opened on. ErrTransactionInstance = errors.New("database instance is an open transaction: pass the instance it was opened on") // ErrUnsupportedOnDialect is returned when an operation is invoked on a // dialect that does not carry it, per the capability-miss rule: the entry // fails instead of silently degrading. Today that is Upsert, Cleanup, the // transaction boundary, and row locks on a ClickHouse instance; each entry // point states its own reason. ErrUnsupportedOnDialect = errors.New("operation is not supported on this dialect") // ErrLockOutsideTransaction is returned when WithLock is used on a chain // that is not inside a transaction, where the lock it asks for would be // released as soon as the statement finished. ErrLockOutsideTransaction = errors.New("WithLock requires a transaction: wrap the operation in database.Transaction") // ErrWithDeletedOnWrite is returned when WithDeleted is combined with a // write operation. The option only widens what a read can see; // soft-deleted rows cannot be written, and removing them for good is what // Cleanup and WithPurge are for. ErrWithDeletedOnWrite = errors.New("WithDeleted applies only to read operations") // ErrWithReplicaOnWrite is returned when WithReplica is combined with a // write operation, in either direction. Writes are never routable — they // run on the primary unconditionally — so the option on a write is a // misunderstanding worth failing loudly rather than a no-op worth // tolerating. ErrWithReplicaOnWrite = errors.New("WithReplica applies only to read operations") // ErrAfterCommit marks a failure that happened after the transaction // committed. Callers distinguish it with errors.Is because the two outcomes // call for opposite handling: an ordinary error means the write was rolled // back and nothing happened, while this one means the write is durable and // only a follow-up effect failed. ErrAfterCommit = errors.New("after-commit action failed") // ErrUnknownColumn is returned when an explicit column reference — a // WithSelect argument or an aggregate term — names a column that does not // exist on the model. A mistyped column must fail the chain: silently // dropping it would turn a narrowed update into a no-op write. ErrUnknownColumn = errors.New("column does not exist on the model") // ErrNoModelColumnSelected is returned when WithSelect names only // framework-managed columns. The caller asked for a narrowed operation, so // silently widening back to a full-row write is not an acceptable answer. ErrNoModelColumnSelected = errors.New("no model column selected") )
Functions ¶
func AfterCommit ¶
AfterCommit registers fn to run after the transaction ctx is inside commits, and runs it immediately when ctx is inside no transaction.
It exists for effects that must not become visible before the data they describe is durable, and that a rollback cannot take back: process-local state, cache invalidation, an outbound notification. Doing that work inside the transaction leaves it applied after a rollback; doing it after the transaction returns, without this, means doing it after a rollback too.
fn receives the context from before the transaction opened. That context carries neither the transaction nor this boundary, so fn cannot write through a connection already returned to the pool, and cannot register a further action on a boundary that has already run.
Registration order is run order, and the first failure stops the rest. A failure is returned to whoever called Transaction or the write method that owns the boundary, marked with ErrAfterCommit: the transaction itself has committed, so the caller must not treat that error as a rollback.
A nil action registers nothing and reports no error. The returned error therefore always comes from running an action, never from the arguments, which is what lets a caller read a non-nil result as "the effect failed" without first ruling out its own call.
func Aggregate ¶
Aggregate creates an analytical read over the table of M whose result rows scan into R. See types.Aggregator for the contract and an example.
func AggregateOn ¶
func AggregateOn[M types.Model, R any](ctx context.Context, instance *gorm.DB) types.Aggregator[M, R]
AggregateOn is Aggregate on an application-held database instance. See DatabaseOn for the instance semantics, including the panic on nil.
func Cleanup ¶
Cleanup permanently deletes all soft-deleted records of M from the default database. It removes every row whose deleted_at column is not null. WARNING: This is a destructive operation that cannot be undone.
It is a package-level maintenance function rather than a chain method on purpose: it ignores query conditions, touches the whole table, and shares nothing with the CRUD contract. Panics if the database is not initialized, consistent with Database[M].
On a ClickHouse instance it answers ErrUnsupportedOnDialect: the soft-delete regime does not exist there.
func CleanupOn ¶
CleanupOn is Cleanup on an application-held database instance. See DatabaseOn for the instance semantics, including the panic on nil.
func DB ¶
DB returns the framework-managed default GORM database handle.
The returned handle exposes the current runtime connection for advanced integrations, but framework initialization owns the underlying pointer. Callers should use Database[M](ctx) for normal CRUD operations.
Running SQL directly on this raw handle bypasses everything Database[M](ctx) wires up per request, so statements issued here are invisible to the tools used for troubleshooting:
- Log correlation: the GORM SQL logger reads trace_id, user_id, and username from the statement context. The raw handle carries no request context, so its SQL log entries have empty trace/user fields and can never be joined with access/controller/service logs when tracing a request by trace_id.
- Tracing: otelgorm parents each SQL span on the statement context. Statements on the raw handle produce orphan root spans outside the request trace, and with parent-based sampling they may not be recorded at all.
- Transaction propagation: Database[M](ctx) joins the transaction carried by ctx (database.Transaction or a model-hook write). The raw handle always talks to the root connection, so its writes silently escape the surrounding transaction and break its all-or-nothing guarantee.
When the raw handle is unavoidable (maintenance SQL, schema tweaks in tests), pass the request context along: DB().WithContext(ctx) restores log correlation and span parenting. Transaction propagation still requires Database[M](ctx) — a context-carried transaction is never visible to the raw handle.
func Database ¶
Database creates and returns a generic database manipulator implementing types.Database interface. Provides comprehensive CRUD capabilities with advanced features like hooks and query building. Automatically enables debug mode when log level is set to debug. Required tables must exist before executing operations with the returned manipulator.
Type Parameters:
- M: Model type that implements types.Model interface
Parameters:
- ctx: Required context for cancellation, tracing, and request metadata. In service layer operations, pass the ServiceContext directly. For non-service layer operations, pass nil.
Returns a database manipulator with full CRUD and query capabilities.
Features:
- Generic type safety for model operations
- Automatic debug mode based on configuration
- Context-aware operations for tracing
- Default query limit protection
- Panic protection for uninitialized database
- Transaction inheritance when ctx was produced by database.Transaction or a model-hook write
Transaction propagation:
Database[M](ctx) checks whether ctx carries an internal GORM transaction. When present, the returned operation chain uses that transaction instead of the package-level DB. This is how model hooks remain atomic without changing their public signature: Create/Update/Delete create a transaction, place it in the hook context, and hook code keeps calling Database[*OtherModel](ctx). This inheritance is strictly context-scoped. Passing context.Background() or any unrelated context starts a normal non-transactional operation chain.
Required usage:
You must call Database[M](ctx) again for each separate operation chain. Assigning the return value to a variable and running another independent operation on it afterward (e.g. WithQuery(...).List(...) then Get(...) or Update(...) on the same variable) is incorrect: after each method, reset() clears this wrapper's options but the underlying GORM session keeps prior clauses, so later calls can combine wrong WHERE conditions, return empty models, or corrupt data.
Example:
var users []*User
// Service layer: one Database() call per operation chain (required; anything else is wrong).
_ = Database[*User](ctx).WithQuery(&User{Name: "John"}).List(&users)
u := new(User)
_ = Database[*User](ctx).Get(u, id)
// Non-service layer
_ = Database[*User](context.Background()).WithQuery(&User{Name: "John"}).List(&users)
func DatabaseOn ¶
DatabaseOn is Database on an application-held database instance, typically built once with a dialect New function such as clickhouse.New and kept by the application. The chain only joins transactions opened on the same instance by TransactionOn. Panics on a nil instance, consistent with Database on an uninitialized default database.
func Health ¶
Health checks connectivity of the default database: a round-trip statement, a connection pool capacity warning, and a ping for response time.
It is a package-level function because health is a property of the connection, not of any model: the former chain form borrowed a model type it never used. Today it checks the single default handle; once the database grows read replicas this is the entry that will cover every node.
Returns nil if all checks pass. Panics if the database is not initialized, consistent with Database[M].
func HealthOn ¶
HealthOn is Health on an application-held database instance. See DatabaseOn for the instance semantics, including the panic on nil. Handing it an open transaction reports ErrTransactionInstance: health describes a connection pool, which a transaction is not.
func Transaction ¶
Transaction executes fn within a database transaction and injects the transaction into the context passed to fn. Every database.Database[M](ctx) chain started from that context automatically joins the transaction; there is no manual binding step.
If the provided ctx already carries a transaction, fn joins the outer transaction directly: no new transaction, span, or savepoint is created. This matches the boundary rule the write methods follow: the first explicit transaction owns the boundary, and everything inside shares it.
Operations that must NOT join the transaction belong outside the closure: the closure body is the begin/commit block, so run them before calling Transaction or after it returns (for example, compensation writes on error). Work that must happen only once the transaction is durable belongs in AfterCommit instead, which runs it after the commit and skips it on rollback.
Returns ErrNilTransaction if fn is nil, and an error marked with ErrAfterCommit when the transaction committed but a registered after-commit action failed. Returns ErrUnsupportedOnDialect on a ClickHouse instance, which has no transactions. Panics if the database is not initialized, consistent with Database[M].
func TransactionOn ¶
func TransactionOn(ctx context.Context, instance *gorm.DB, fn func(ctx context.Context) error) error
TransactionOn is Transaction on an application-held database instance: fn runs inside a transaction opened on that instance, and only DatabaseOn chains for the same instance join it — a default-database chain inside fn keeps its own connection. Cross-instance atomicity is not provided. Panics on a nil instance, consistent with DatabaseOn.
Types ¶
type NodeStats ¶
type NodeStats struct {
// Role names the node's place in the topology, RolePrimary for the
// writable node.
Role string
// DBStats is the standard library pool snapshot: open, in-use and idle
// connections, wait counts and durations.
DBStats sql.DBStats
}
NodeStats is the connection pool snapshot of one database node.
func Stats ¶
func Stats() []NodeStats
Stats reports the connection pool snapshot of every node of the default database, for callers that surface pool state themselves: a custom health endpoint, a debug page, a startup print. The Prometheus collector the framework registers at initialization reads the same source; this is the programmatic view of it.
With read replicas configured it reports one snapshot per node, the primary first in registration order; without them, the single primary.
It returns nil when the database is not initialized: a snapshot of nothing is empty rather than an error, unlike Health, which is asked for an authoritative answer and panics there like Database[M].