Documentation
¶
Overview ¶
Package dbx provides the unified, batteries-included database API for Aisphere Kernel.
dbx is the ONLY database abstraction that business code (handler / service / repository / worker) should depend on. Direct usage of `database/sql`, `gorm.io/gorm` (the *gorm.DB type), or driver-specific packages in business code is forbidden and will fail CI.
Design principle ¶
dbx exposes a stable DB / Tx interface backed by GORM, with eight "pre-actions" built in so business code does not have to repeat the same boilerplate in every repository method:
- Error normalization — gorm.ErrRecordNotFound, pgconn.PgError 23505, mysql 1062 are automatically wrapped into dbx.ErrNoRows / dbx.ErrDuplicateKey so business code can use errors.Is without importing driver packages.
- Soft-delete safety — Unscoped queries require an explicit WithUnscoped(ctx) opt-in; accidental "select deleted rows" is blocked.
- OnConflict whitelist — SafeUpsert(model, row, allowedColumns) refuses to overwrite sensitive columns (owner_id / visibility / status / ...).
- Context propagation — DBFromContext(ctx) returns the request-scoped *gorm.DB (with tx / trace / request_id); InjectDB injects it. Repository methods stop repeating the same db(ctx) helper.
- Audit hook — BeforeCreate / AfterUpdate / BeforeDelete callbacks automatically record audit events via auditx; no per-method plumbing.
- Global QueryTimeout — Config.QueryTimeout applies to every query whose context lacks a deadline; no per-query WithTimeout boilerplate.
- Slow-query log — queries exceeding SlowQueryThreshold (default 200ms) are logged via logx with SQL text, duration, and caller.
- Metrics — every query is recorded as a Prometheus histogram with labels driver / operation / status; no manual instrumentation.
dbx does NOT depend on errorx (avoids cyclic dependency); business code converts dbx errors to errorx at the repository layer boundary.
30-second quickstart ¶
import (
"github.com/aisphereio/kernel/configx"
"github.com/aisphereio/kernel/configx/file"
"github.com/aisphereio/kernel/dbx"
_ "github.com/aisphereio/kernel/dbx/postgres" // register "postgres" driver
)
var dbCfg dbx.Config
_ = cfg.Value("database").Scan(&dbCfg)
db, err := dbx.New(dbCfg)
if err != nil { return err }
defer db.Close()
// Create with auto audit + soft-delete + error normalization.
skill := &Skill{Name: "demo", OwnerID: "user_123"}
if err := db.Create(ctx, skill); err != nil {
if errors.Is(err, dbx.ErrDuplicateKey) {
return errorx.Conflict("AIHUB_SKILL_ALREADY_EXISTS", "技能已存在")
}
return errorx.Wrap(err, "AIHUB_SKILL_CREATE_FAILED", ...)
}
// Find one (auto filters soft-deleted rows).
var skill Skill
if err := db.FindOne(ctx, &skill, "name = ?", "demo"); err != nil {
if errors.Is(err, dbx.ErrNoRows) {
return errorx.NotFound("AIHUB_SKILL_NOT_FOUND", "技能不存在")
}
return errorx.Wrap(err, "AIHUB_SKILL_QUERY_FAILED", ...)
}
// Safe upsert: whitelist allowed columns; owner_id never overwritten.
if err := db.SafeUpsert(ctx, &skill, []string{"display_name", "version"}); err != nil { ... }
// Transaction with auto audit.
err = db.InTx(ctx, func(tx dbx.Tx) error {
if err := tx.Create(ctx, skillRow); err != nil { return err }
if err := tx.Create(ctx, versionRow); err != nil { return err }
return nil // commit
})
Drivers ¶
dbx does not import any driver by default. Import the driver subpackage to register it:
import _ "github.com/aisphereio/kernel/dbx/postgres" // registers "postgres" import _ "github.com/aisphereio/kernel/dbx/mysql" // registers "mysql"
After import, set Config.Driver = "postgres" (or "mysql") and dbx picks the registered driver automatically.
Safe helpers ¶
dbx provides semantic helpers that encode aisphere-hub's hard-won security lessons so they cannot be re-broken by a careless copy-paste:
db.SafeUpsert(ctx, row, allowedColumns) // OnConflict + whitelist db.WithUnscoped(ctx) // opt-in to read soft-deleted db.AssertAffected(res, err) // 0 rows → ErrNoRows db.Increment(ctx, model, where, column, delta) // atomic counter db.Paginate(ctx, model, page, size, &out) // limit+1 + hasMore + total
Forbidden patterns ¶
Do not import `database/sql` in business code. Do not import driver packages (`gorm.io/driver/postgres`, `gorm.io/driver/mysql`) in business code. Do not import `gorm.io/gorm` directly in business code (use dbx.DB and dbx.Tx interfaces). Do not call Unscoped without WithUnscoped.
Documentation ¶
See dbx/README.md for the user guide, docs/ai/dbx.md for the AI coding recipe, docs/contracts/dbx.md for behaviors that cannot change without a major version bump.
Index ¶
- Constants
- Variables
- func AssertAffected(res *gorm.DB) error
- func InjectDB(ctx context.Context, gormDB *gorm.DB) context.Context
- func InjectTx(ctx context.Context, tx Tx) context.Context
- func IsDriverRegistered(name string) bool
- func NormalizeError(err error) error
- func RegisterDriver(name string, fn DriverOpener)
- func RegisterErrorMapper(fn func(error) error)
- func RegisteredDrivers() []string
- func WithUnscoped(ctx context.Context) context.Context
- type Config
- type DB
- type DBStats
- type DriverOpener
- type PageResult
- type Tx
Constants ¶
const ( CodeNoRows = errorx.Code("DBX_NO_ROWS") CodeDuplicateKey = errorx.Code("DBX_DUPLICATE_KEY") CodeTimeout = errorx.Code("DBX_TIMEOUT") CodeSchemaNotReady = errorx.Code("DBX_SCHEMA_NOT_READY") CodeDatabaseNotExist = errorx.Code("DBX_DATABASE_NOT_EXIST") CodeForeignKeyViolation = errorx.Code("DBX_FOREIGN_KEY_VIOLATION") CodeClosed = errorx.Code("DBX_CLOSED") CodeInvalidConfig = errorx.Code("DBX_INVALID_CONFIG") CodeUnknownDriver = errorx.Code("DBX_UNKNOWN_DRIVER") CodeTxStateInvalid = errorx.Code("DBX_TX_STATE_INVALID") CodeUnsafeUpsert = errorx.Code("DBX_UNSAFE_UPSERT") CodeNoEffect = errorx.Code("DBX_NO_EFFECT") CodeOperationFailed = errorx.Code("DBX_OPERATION_FAILED") )
Variables ¶
var ( // ErrNoRows is returned by FindOne / FindOneByPK when no row matches. // It is a dbx-specific sentinel; GORM's gorm.ErrRecordNotFound is // automatically translated to this on every Find* call. ErrNoRows = newSentinel("dbx: no rows in result set") // ErrDuplicateKey is returned by Create / SafeUpsert when a unique // constraint is violated. Driver-specific detection: // - postgres: pgconn.PgError Code == "23505" // - mysql: mysql.MySQLError Number == 1062 ErrDuplicateKey = newSentinel("dbx: duplicate key") // ErrTimeout is returned when a query exceeds its context deadline or // the global QueryTimeout. ErrTimeout = newSentinel("dbx: query timed out") // ErrSchemaNotReady is returned when the underlying table does not // exist. This typically means migrations have not been applied. // Driver-specific detection: // - postgres: pgconn.PgError Code == "42P01" // - mysql: mysql.MySQLError Number == 1146 ErrSchemaNotReady = newSentinel("dbx: schema not ready (run migrations)") // ErrDatabaseNotExist is returned when the target database named in // the DSN does not exist on the server. // Driver-specific detection: // - postgres: pgconn.PgError Code == "3D000" (invalid_catalog_name) // - mysql: mysql.MySQLError Number == 1049 (ER_BAD_DB_ERROR) // // When Config.AutoCreateDatabase is true, dbx.New traps this error // and auto-creates the database before retrying the connection. ErrDatabaseNotExist = newSentinel("dbx: target database does not exist") // ErrForeignKeyViolation is returned when a foreign-key constraint // is violated. // - postgres: pgconn.PgError Code == "23503" // - mysql: mysql.MySQLError Number == 1452 ErrForeignKeyViolation = newSentinel("dbx: foreign key violation") // ErrClosed is returned when a closed DB is used. ErrClosed = newSentinel("dbx: database is closed") // ErrNilConfig is returned by New when Config is missing required fields. ErrNilConfig = newSentinel("dbx: config is missing required fields") // ErrUnknownDriver is returned when the driver name has not been registered. ErrUnknownDriver = newSentinel("dbx: unknown driver (did you import dbx/postgres or dbx/mysql?)") // ErrTxRolledBack is returned when an operation is attempted on a rolled-back Tx. ErrTxRolledBack = newSentinel("dbx: transaction already rolled back") // ErrTxCommitted is returned when Commit is called twice on the same Tx. ErrTxCommitted = newSentinel("dbx: transaction already committed") // ErrUnscopedRequired is returned when a soft-delete-protected query // is attempted without WithUnscoped. ErrUnscopedRequired = newSentinel("dbx: query touches soft-deleted rows; use WithUnscoped to opt in") // ErrUnsafeUpsert is returned by SafeUpsert when the row contains a // protected column that is not in the allowedColumns whitelist. ErrUnsafeUpsert = newSentinel("dbx: SafeUpsert blocked a protected column") // ErrNoEffect is returned by AssertAffected when RowsAffected == 0. ErrNoEffect = newSentinel("dbx: operation affected 0 rows") )
Functions ¶
func AssertAffected ¶
AssertAffected returns ErrNoEffect if res.RowsAffected == 0, or wrapDriverErr(res.Error) otherwise. This is the "no row was changed" pattern from aisphere-hub skill.go (10+ occurrences of `if res.RowsAffected == 0 { return ErrXxxNotFound }`).
Usage:
res := tx.Model(&Skill{}).Where("name = ?", name).Updates(map[string]any{...})
if err := dbx.AssertAffected(res); err != nil {
if errors.Is(err, dbx.ErrNoEffect) {
return errorx.NotFound("AIHUB_SKILL_NOT_FOUND", ...)
}
return err
}
func InjectDB ¶
InjectDB attaches a *gorm.DB (typically a transaction-wrapped one) to ctx so that downstream DB.GORM(ctx) / FindOne(ctx, ...) / etc. pick it up automatically. This is the aisphere-hub Runtime.DBFromContext pattern encoded as a public API.
func InjectTx ¶
InjectTx attaches a Tx to ctx so that downstream code can pick up the same transaction via DB.GORM(ctx) without explicit threading.
func IsDriverRegistered ¶
IsDriverRegistered returns true if the named driver has been registered.
func NormalizeError ¶ added in v0.0.5
NormalizeError converts dbx sentinel/driver errors into Kernel errorx values while preserving the original error chain for errors.Is/errors.As callers.
func RegisterDriver ¶
func RegisterDriver(name string, fn DriverOpener)
RegisterDriver registers a driver opener under the given name. Called by dbx/postgres and dbx/mysql in their init() functions. Panics if the same name is registered twice.
func RegisterErrorMapper ¶
RegisterErrorMapper adds a function that inspects an error and returns the matching dbx sentinel (or nil if not recognized). Called by driver subpackages via init().
func RegisteredDrivers ¶
func RegisteredDrivers() []string
RegisteredDrivers returns the names of all registered drivers.
func WithUnscoped ¶
WithUnscoped returns a ctx that allows queries to read soft-deleted rows. Without this, any query on a model with DeletedAt returns an error if the caller tries to use Unscoped. This is the safety gate that prevents accidental "select deleted rows" leaks.
Types ¶
type Config ¶
type Config struct {
// Driver selects the registered driver: "postgres" or "mysql".
Driver string `json:"driver"`
// DSN is the data source name (driver-specific connection string).
DSN string `json:"dsn"`
// MaxOpenConns limits the number of open connections. 0 means unlimited.
MaxOpenConns int `json:"max_open_conns"`
// MaxIdleConns limits the number of idle connections. 0 means default (2).
MaxIdleConns int `json:"max_idle_conns"`
// ConnMaxLifetime is how long a connection may live before being recycled.
// Zero means no limit.
ConnMaxLifetime time.Duration `json:"conn_max_lifetime_ns"`
// ConnMaxIdleTime is how long an idle connection may live before being closed.
// Zero means no limit.
ConnMaxIdleTime time.Duration `json:"conn_max_idle_time_ns"`
// QueryTimeout is the default timeout applied to queries when the context
// has no deadline. Zero means no timeout (rely on context).
QueryTimeout time.Duration `json:"query_timeout_ns"`
// SlowQueryThreshold is the duration above which a query is logged as
// slow via logx. Zero disables slow-query logging. Default 200ms.
SlowQueryThreshold time.Duration `json:"slow_query_threshold_ns"`
// AuditEnabled controls whether the auto audit hook is registered.
// When true, BeforeCreate / AfterUpdate / BeforeDelete callbacks emit
// audit events. Default false (opt-in to avoid surprises).
AuditEnabled bool `json:"audit_enabled"`
// MetricsEnabled controls whether per-query metrics are recorded.
// Default false (opt-in).
MetricsEnabled bool `json:"metrics_enabled"`
// Logger is the component logger used for startup, slow-query and error logs.
// If nil, dbx uses logx.DefaultLogger(), which should be initialized by kernel.New.
Logger logx.Logger `json:"-" yaml:"-"`
// Metrics is the optional metrics manager used when MetricsEnabled is true.
Metrics metricsx.Manager `json:"-" yaml:"-"`
// DryRun, when true, causes GORM to log SQL without executing it.
// Useful for staging environment tests. Default false.
DryRun bool `json:"dry_run"`
// Debug, when true, logs every SQL statement via logx at Debug level.
// Default false.
Debug bool `json:"debug"`
// AutoCreateDatabase, when true, causes dbx.New to create the target
// database if it does not exist. This is useful for dev/test
// environments where the operator does not want to pre-provision
// databases manually.
//
// Default false. NEVER enable in production — automated database
// creation bypasses standard provisioning workflows (IAM, backup
// policies, schema migrations, monitoring) and should be handled by
// infrastructure code (Terraform, Ansible, k8s Job, etc.).
//
// Implementation:
// - When gorm.Open fails with a "database does not exist" error
// (PG: SQLSTATE 3D000; MySQL: error 1049), the driver parses
// the DSN, connects to an admin DSN (PG: same host with
// dbname=postgres; MySQL: same DSN without the dbname segment),
// issues CREATE DATABASE <name>, then retries the original
// gorm.Open.
// - The CREATE DATABASE uses the cluster's default encoding/ctype.
// For custom encoding, set up the database out-of-band and leave
// this field false.
// - The database name is quoted to prevent SQL injection via the
// DSN, but the DSN itself is trusted (it comes from config, not
// user input).
// - Errors during admin connect or CREATE DATABASE are returned
// wrapped, so the caller sees the original "database does not
// exist" error plus the auto-create failure context.
AutoCreateDatabase bool `json:"auto_create_database"`
}
Config holds the configuration for a DB connection pool.
Fields use json tags so the struct can be loaded directly from configx via `cfg.Value("database").Scan(&dbCfg)`.
type DB ¶
type DB interface {
// GORM exposes the underlying *gorm.DB for advanced cases (Preload,
// raw SQL, complex Where chains). Business code SHOULD prefer the
// typed helpers below; GORM is the escape hatch.
GORM(ctx context.Context) *gorm.DB
// FindOne executes a query that returns at most one row, scanning
// the result into dest (a pointer to a struct). Returns ErrNoRows
// if no row matches.
FindOne(ctx context.Context, dest any, query any, args ...any) error
// FindOneByPK finds a row by primary key. pk may be a single value
// or a map for composite keys.
FindOneByPK(ctx context.Context, dest any, pk any) error
// FindMany executes a query that returns multiple rows, scanning
// each into dest (a pointer to a slice of structs).
FindMany(ctx context.Context, dest any, query any, args ...any) error
// Count returns the number of rows matching the query.
Count(ctx context.Context, model any, query any, args ...any) (int64, error)
// Create inserts one or more rows. If dest is a struct, it is updated
// with the auto-generated primary key (if any).
Create(ctx context.Context, dest any) error
// Save updates the row by primary key, creating it if it does not exist.
// All fields are written; use Update for partial updates.
Save(ctx context.Context, dest any) error
// Update updates specific columns by query.
// db.Update(ctx, &Skill{}, "name = ?", "demo", map[string]any{"status":"online"})
Update(ctx context.Context, model any, query any, args []any, columns map[string]any) error
// UpdateColumns is like Update but uses GORM's UpdateColumns (skips
// autoUpdateTime hooks). Use for atomic increments via gorm.Expr.
UpdateColumns(ctx context.Context, model any, query any, args []any, columns map[string]any) error
// Delete soft-deletes rows matching the query (or hard-deletes if the
// model has no DeletedAt field).
Delete(ctx context.Context, model any, query any, args ...any) error
// DeleteByPK soft-deletes (or hard-deletes) a row by primary key.
DeleteByPK(ctx context.Context, model any, pk any) error
// SafeUpsert performs an INSERT ... ON CONFLICT DO UPDATE (postgres) or
// INSERT ... ON DUPLICATE KEY UPDATE (mysql). Only columns listed in
// allowedColumns are updated on conflict; other columns (including
// owner_id / visibility / status and other sensitive fields) are
// never overwritten. This is the SECURITY-annotated pattern from
// aisphere-hub skill.go.
SafeUpsert(ctx context.Context, dest any, allowedColumns []string) error
// Increment atomically increments a column by delta. Implemented as
// UpdateColumns(col = col + delta), server-side, race-free.
Increment(ctx context.Context, model any, query any, args []any, column string, delta int64) error
// Paginate returns a page of rows + total count + hasMore.
// page is 1-indexed; size is capped at 100.
Paginate(ctx context.Context, dest any, model any, query any, args []any, page, size int) (*PageResult, error)
// InTx runs fn inside a transaction. If fn returns nil, the
// transaction is committed. If fn returns a non-nil error or panics,
// the transaction is rolled back and the error (or recovered panic)
// is returned.
InTx(ctx context.Context, fn func(Tx) error) error
// BeginTx starts a transaction. Manual commit/rollback; prefer InTx
// unless you need finer control.
BeginTx(ctx context.Context) (Tx, error)
// PingContext verifies the database is reachable.
PingContext(ctx context.Context) error
// AutoMigrate creates or updates tables for the given models. Safe to
// call repeatedly (idempotent). NOT recommended for production — use
// SQL migrations for schema changes in prod.
AutoMigrate(ctx context.Context, models ...any) error
// Tables returns the list of tables in the current schema.
Tables(ctx context.Context) ([]string, error)
// Stats returns connection pool statistics.
Stats() DBStats
// DriverName returns the registered driver name ("postgres" / "mysql").
DriverName() string
// Close closes the database, releasing all connections. Idempotent.
Close() error
}
DB is the runtime database interface used by kernel modules and apps.
All methods accept context.Context. Methods that take a model pointer (FindOne / Create / Save / SafeUpsert / Delete) operate on GORM models with the same struct tag conventions as GORM (`gorm:"column:..."`).
func New ¶
New opens a database connection using the supplied Config.
The driver must have been registered by importing dbx/postgres or dbx/mysql. Returns ErrUnknownDriver if not registered, ErrNilConfig if Driver or DSN is missing.
The returned DB has all eight pre-actions wired up: error normalization, soft-delete safety, OnConflict whitelist, context propagation, audit hook (if Config.AuditEnabled), global QueryTimeout, slow-query log, and metrics (if Config.MetricsEnabled).
type DBStats ¶
DBStats mirrors gorm's underlying stats. We use sql.DBStats to avoid exporting gorm internals in the public API.
type DriverOpener ¶
DriverOpener opens a *gorm.DB for the given DSN. Driver subpackages register their opener via RegisterDriver.
type PageResult ¶
PageResult holds a paginated query result.
type Tx ¶
type Tx interface {
GORM(ctx context.Context) *gorm.DB
FindOne(ctx context.Context, dest any, query any, args ...any) error
FindOneByPK(ctx context.Context, dest any, pk any) error
FindMany(ctx context.Context, dest any, query any, args ...any) error
Count(ctx context.Context, model any, query any, args ...any) (int64, error)
Create(ctx context.Context, dest any) error
Save(ctx context.Context, dest any) error
Update(ctx context.Context, model any, query any, args []any, columns map[string]any) error
UpdateColumns(ctx context.Context, model any, query any, args []any, columns map[string]any) error
Delete(ctx context.Context, model any, query any, args ...any) error
DeleteByPK(ctx context.Context, model any, pk any) error
SafeUpsert(ctx context.Context, dest any, allowedColumns []string) error
Increment(ctx context.Context, model any, query any, args []any, column string, delta int64) error
Commit() error
Rollback() error
}
Tx is a database transaction. All methods behave like the corresponding DB methods but operate within the transaction.