Documentation
¶
Overview ¶
Package database opens supported GORM SQL drivers and exposes driver metadata.
MapLoadError and MapPersistError classify GORM errors into D10 categories (not_found, conflict, internal) so generated handlers, admin, and auth share one unique-violation detector instead of three copies.
Index ¶
- Constants
- Variables
- func Aggregate(ctx context.Context, q *gorm.DB, specs []AggregateSpec) (map[string]types.Decimal, error)
- func ConfigurePool(conn *sql.DB, cfg config.DatabaseConfig)
- func DecimalStorageProblem(d decimal.Decimal, precision, scale int, sqlite bool) string
- func Delete(ctx context.Context, db *gorm.DB, value any, conds ...any) (int64, error)
- func FilterEq(ctx context.Context, q *gorm.DB, column string, kind FilterKind, raw string) (*gorm.DB, error)
- func IsDataException(err error) bool
- func IsForeignKeyViolation(err error) bool
- func IsNotNullViolation(err error) bool
- func IsUniqueViolation(err error) bool
- func KeepStoredZeros(db *gorm.DB, row any, kept, edited []string) *gorm.DBdeprecated
- func MapDeleteError(ctx context.Context, err error, conflict, internal string) error
- func MapLoadError(ctx context.Context, err error, notFound, internal string) error
- func MapPersistError(ctx context.Context, err error, conflict, internal string) error
- func NewLogger(z *zap.Logger) gormlogger.Interface
- func Ordering(ctx context.Context, q *gorm.DB, ordering string, allowed []string, ...) (*gorm.DB, error)
- func ParseOrdering(ordering string) (field string, desc bool)
- func ScopeEdit(db *gorm.DB, row any, stored StoredRow) *gorm.DB
- func Search(ctx context.Context, q *gorm.DB, columns []string, term string) (*gorm.DB, error)
- func StoredZeroColumns(db *gorm.DB, row any) []string
- func TextProblem(s string) string
- type AggregateColumn
- type AggregateFunc
- type AggregateSpec
- type Capabilities
- type DB
- type Driver
- type FilterKind
- type StoredRow
- type ValidationError
- type Validator
Constants ¶
const SQLiteDecimalDigits = 15
SQLiteDecimalDigits is the most digits a decimal value may carry when it is written to SQLite (issue #440).
SQLite has no fixed-point decimal type. A decimal(p,s) column has NUMERIC affinity, so the text a decimal is written as is converted to INTEGER or REAL, and REAL is a float64: 99999999999999.9999 is stored as 100000000000000. Within float64's normal range fifteen significant digits survive that round trip, so Gombit's SQLite contract is "at most 15 digits", counted from the first non-zero digit to the last digit of the value (trailing zeros after the point excluded), and a magnitude inside that range (sqliteDecimalMaxExponent): a value outside it is rejected with a 422 instead of being silently changed. The limit is deliberately conservative, not a per-value representability check, so it is predictable: some longer values (a 16-digit integer) would happen to survive, and they are rejected too. PostgreSQL and MySQL store the declared decimal(p,s) exactly and have no such limit.
const SlowQueryThreshold = 200 * time.Millisecond
SlowQueryThreshold is how long a statement may run before the database logger reports it as slow.
const TextMaxBytes = 65535
TextMaxBytes is what a `text` column holds on every supported driver: MySQL's TEXT, the smallest of the three (PostgreSQL and SQLite take more).
Variables ¶
var ErrReferenced = errors.New("database: the row is still referenced by other rows")
ErrReferenced reports a DELETE the database refused because other rows still reference the row under ON DELETE RESTRICT or NO ACTION.
Functions ¶
func Aggregate ¶ added in v0.1.12
func Aggregate(ctx context.Context, q *gorm.DB, specs []AggregateSpec) (map[string]types.Decimal, error)
Aggregate computes the requested aggregates over q — which already carries the list's filters and search — and returns a map keyed by each spec's Key ("<func>:<field>"). All aggregates are evaluated in a single SELECT with no GROUP BY, so the query returns exactly one row and runs before pagination.
Values are the database's own aggregate, encoded as a types.Decimal (a canonical JSON string). Precision therefore follows the driver: Postgres and MySQL compute SUM/AVG/MIN/MAX over numeric/decimal in fixed point, so a decimal SUM (and integer aggregates on every driver) is exact; SQLite has no native fixed-point aggregate and computes AVG — and SUM of a fractional decimal column — in IEEE double, so those may carry float rounding and can differ from Postgres/MySQL for the same data. AVG is fractional by nature (rounded to each driver's default scale) and is an approximation everywhere. See docs/contract.md "List query: numeric aggregates" for the per-driver contract. A NULL result — an aggregate over an empty matching set (SUM/AVG/ MIN/MAX all yield NULL) — becomes decimal zero, so a card renders 0 not null.
An empty specs list is a no-op (nil map, no query). Column names in specs are generator-controlled; nothing here is derived from user input.
func ConfigurePool ¶ added in v0.6.0
func ConfigurePool(conn *sql.DB, cfg config.DatabaseConfig)
ConfigurePool applies Open's pool settings to conn: cfg's MaxOpenConns, MaxIdleConns, and ConnMaxLifetime, and the driver-aware defaults for cfg.Driver where they are zero. OpenConn leaves a caller's handle alone; call this to give it what Open would have.
func DecimalStorageProblem ¶ added in v0.7.1
DecimalStorageProblem describes why d would not be stored exactly in a decimal(precision,scale) column, or returns "" when it would (issue #440). precision <= 0 means the column has no limit of its own (PostgreSQL's unbounded numeric), so only the size rule and the SQLite limit apply. Trailing zeros after the point are not digits that need storing: 1.5000 fits decimal(5,1).
d is measured by types.DecimalShapeOf, the one size rule types.Decimal's parsing and Scan also apply, so a value accepted here can be read back. It bounds the exponent and the coefficient before formatting anything, so a hostile value ("0e1000000000") is refused without materialising it.
func Delete ¶ added in v0.4.0
Delete removes the rows value (and conds) select, physically. That is Gombit's deletion semantics (ADR-019): what deletion does is what the database's foreign keys say. RESTRICT and NO ACTION refuse the delete, and the error wraps ErrReferenced; CASCADE removes the referencing rows; SET NULL clears their key. It deletes even a model that embeds gorm.DeletedAt (gorm.Model): GORM would otherwise only set deleted_at, no foreign key would fire, and a live row would go on referencing one the API reports as gone. It returns the number of rows deleted.
func FilterEq ¶ added in v0.1.11
func FilterEq(ctx context.Context, q *gorm.DB, column string, kind FilterKind, raw string) (*gorm.DB, error)
FilterEq adds an exact-match `column = <raw coerced to kind>` predicate. An empty (or whitespace-only) raw value is a no-op — the filter was not supplied — so callers can chain one FilterEq per declared filterable field. A raw value that does not parse as kind, or a string one no text column can hold (a NUL byte, invalid UTF-8: TextProblem), returns a D10 validation error (422) keyed on column. The column name is generator-controlled (a declared field's DB column); only raw is user input.
func IsDataException ¶ added in v0.8.0
IsDataException reports a value the database refused as data that a client sends and can fix by sending another (issue #444): PostgreSQL's string too long (22001), numeric out of range (22003), invalid datetime format or field overflow (22007, 22008), and a NUL byte or invalid UTF-8 (22021, 22P05); MySQL's out-of-range (1264), truncated (1265), too-long (1406) and incorrect-string (1366) errors, and an incorrect date or time literal (1292). MapPersistError and MapLoadError answer it with a 422.
It is an allowlist of the codes a value the client sends can cause. A code does not say what caused it, so the same code raised by server-side SQL (an expression overflowing its column) is a 422 too. The rest of PostgreSQL's class 22 (division by zero, an invalid cast) and MySQL's "Truncated incorrect ... value" on an expression stay 500s.
func IsForeignKeyViolation ¶ added in v0.1.5
IsForeignKeyViolation reports foreign-key constraint violations, such as a belongs_to reference to a row that does not exist. Same primary/fallback shape as IsUniqueViolation: all three supported dialectors translate their foreign-key error code to gorm.ErrForeignKeyViolated, with the driver error string as a fallback.
func IsNotNullViolation ¶ added in v0.1.5
IsNotNullViolation reports NOT NULL constraint violations. Unlike unique and foreign-key violations, none of the SQLite/Postgres/MySQL GORM dialectors translate this case to a gorm sentinel error (it is a gap in gorm itself, not something Open configures around), so detection is driver-message-only. The three drivers phrase it differently: SQLite ("NOT NULL constraint failed"), Postgres ("violates not-null constraint"), MySQL ("cannot be null").
func IsUniqueViolation ¶ added in v0.1.3
IsUniqueViolation reports duplicate-key errors. Open enables gorm.Config.TranslateError, so ErrDuplicatedKey is the primary signal on SQLite, Postgres, and MySQL (all three dialectors translate their unique- violation error code to it). The driver error string stays as a fallback for any dialect that does not implement translation.
func KeepStoredZeros
deprecated
added in
v0.7.1
KeepStoredZeros scopes an edit of row that sets the edited columns, leaving out each kept column (StoredZeroColumns) that still holds the zero instant.
Deprecated: since v0.8.0, use ScopeEdit with StoredValues, which also keeps the row's stored text editable and judges columns by whether their value changed.
func MapDeleteError ¶ added in v0.1.5
MapDeleteError maps a GORM delete error to a D10 category error. A foreign-key violation here means another row still references the one being deleted, which is a state conflict caused by other data, not an invalid value in the request (a delete has no body to validate) — the opposite meaning a foreign-key violation has on create/update, so this is deliberately not MapPersistError. Unique and NOT NULL violations cannot occur on delete, so there is no equivalent branch for them here.
func MapLoadError ¶ added in v0.1.3
MapLoadError maps a GORM read/load error to a D10 category error: record-not-found becomes not_found; a data exception (a filter value the column type cannot hold) becomes validation; any other driver failure becomes internal. Unique/duplicate is not treated as conflict on load.
func MapPersistError ¶ added in v0.1.3
MapPersistError maps a GORM write error to a D10 category error: unique/duplicate becomes conflict; a foreign-key or NOT NULL violation, or a data exception (a value the column cannot hold), becomes validation, since each means the client submitted a value that references, omits or spells something invalid, not that the server failed; any other failure becomes internal.
func NewLogger ¶ added in v0.7.1
func NewLogger(z *zap.Logger) gormlogger.Interface
NewLogger returns a GORM logger that writes through z, so database logging follows the app's sink, level and format (issue #439):
- a statement that succeeds, finds nothing (gorm.ErrRecordNotFound), or fails with a client error the API answers with a 4xx, identified by the driver's code (a unique, foreign-key or NOT NULL violation) or a model Validate error, is logged at debug: it is normal traffic;
- a slow statement (SlowQueryThreshold) at warn;
- any other failed statement at error.
The logged SQL keeps its placeholders and never carries parameter values, which may be password hashes, tokens or personal data. A failed statement's driver error text is logged as the driver reports it.
GORM's Debug() (LogMode(Info)) raises a session's statements to info, so they show at the default log level.
framework.New installs it on an attached database that still has the logger Open installed.
func Ordering ¶ added in v0.1.11
func Ordering(ctx context.Context, q *gorm.DB, ordering string, allowed []string, fallback string) (*gorm.DB, error)
Ordering applies `ORDER BY` for a Django-style `?ordering=` value (optional leading '-' for DESC), validated against the allowed columns:
- empty ordering: order by fallback (the stable default, e.g. "id") when fallback is non-empty; otherwise no ORDER BY is added.
- ordering field not in allowed: a 422 validation error on "ordering".
allowed and fallback are generator-controlled (a resource's declared sortable columns); ordering is user input, matched by exact string equality so only a declared column can reach the query.
func ParseOrdering ¶ added in v0.1.11
ParseOrdering splits a Django-style `?ordering=` token into its field name and direction: a leading '-' means descending, e.g. "-created_at" → ("created_at", true). The admin data plane and generated list handlers share this spelling so the two contracts cannot drift.
func ScopeEdit ¶ added in v0.8.0
ScopeEdit returns db scoped for an edit of row, a model loaded from the database whose stored values StoredValues recorded before the edit. An update that writes row back with Select("*") (Save, or Model(row).Select("*").Updates(row)) writes only the columns whose value the edit, a model hook or a GORM callback (Statement.SetColumn) changed, as values (NaN equals NaN; a time, a decimal with Equal), judged just before the SQL runs (and an auto-update timestamp, as GORM stamps it): the others keep what the row holds now, so a concurrent change to a column the edit did not touch is not reverted (issue #450). An update that changes nothing writes no column (but an auto-update timestamp, which GORM stamps). The write checks judge only what is written:
- text the row stores, unchanged, is not written, even if it predates the text check (a NUL byte, more than the column now allows); changed text is checked;
- a timestamp or date that held the zero instant, and still does, is not written; one the edit or a hook gave a real value is written, and checked. A zero instant written over a real value is refused, even in a column Save would otherwise leave out unset (an auto timestamp, a default).
Use Updates. Save keeps GORM's fallback for an update that affected no row, an insert that writes (and checks) every column: it fires for a row deleted meanwhile, which comes back; for an edit that changed nothing on a model without an auto-update timestamp; and on MySQL for an edit whose columns already held the new values, each of which writes the whole row. With Updates, RowsAffected 0 means the row is gone, or, as MySQL counts, matched but left unchanged. Any other write on the returned DB, another row included, is written and checked as usual. The admin data plane uses it for its PATCH.
func Search ¶ added in v0.1.11
Search adds a case-insensitive OR of `LOWER(col) LIKE LOWER(%term%)` across the given columns. An empty (or whitespace-only) term, or an empty column list, is a no-op. The term is treated as a literal: LIKE wildcards in it are escaped, so a user searching for "50%" matches the literal text, not a prefix. A term no text column can hold (a NUL byte, invalid UTF-8: TextProblem), which PostgreSQL refuses to compare, is a D10 validation error (422) on "search" (issue #444). Columns are generator-controlled; only the term is user input.
func StoredZeroColumns ¶ added in v0.7.1
StoredZeroColumns returns the timestamp and date columns of row, a model as loaded from db, that hold the zero instant 0001-01-01T00:00:00Z: a value stored before the time range check refused it (what a non-pointer field left unset became on SQLite and PostgreSQL, and MySQL's '0000-00-00'). StoredValues records them, with the row's text, for ScopeEdit.
func TextProblem ¶ added in v0.8.0
TextProblem describes why s cannot be stored in, or compared against, a text column on every supported driver, or returns "" when it can: PostgreSQL refuses a NUL byte and invalid UTF-8 in text (SQLSTATE 22021), and MySQL refuses invalid UTF-8 in a utf8mb4 column. A filter or search term with either reaches the same refusal on PostgreSQL.
Types ¶
type AggregateColumn ¶ added in v0.1.12
type AggregateColumn struct {
Column string
}
AggregateColumn declares one field a resource exposes to aggregates: the DB column the SQL function is applied to. It is generator-controlled — built from a field declared `aggregatable` — so only a declared numeric column can reach the query.
type AggregateFunc ¶ added in v0.1.12
type AggregateFunc string
AggregateFunc is a supported SQL aggregate function.
const ( AggSum AggregateFunc = "sum" AggAvg AggregateFunc = "avg" AggMin AggregateFunc = "min" AggMax AggregateFunc = "max" )
type AggregateSpec ¶ added in v0.1.12
type AggregateSpec struct {
Func AggregateFunc
Column string
Key string
}
AggregateSpec is one resolved aggregate request: Func over Column, returned in the response keyed as "<func>:<field>" (Key).
func ParseAggregates ¶ added in v0.1.12
func ParseAggregates(ctx context.Context, raw string, allowed map[string]AggregateColumn) ([]AggregateSpec, error)
ParseAggregates turns the raw `aggregate` query param — a comma-separated list of "<func>:<field>" pairs, e.g. "sum:total,avg:total" — into resolved specs.
- An empty (or whitespace-only) raw value returns nil (no aggregates asked for), so a plain list request is unchanged.
- func is matched case-insensitively against sum / avg / min / max.
- field must be a key in allowed (a declared aggregatable field); the value supplies the DB column. allowed is generator-controlled; only raw is user input, so an undeclared field cannot reach the query.
- A malformed pair, unknown func, or undeclared field returns a D10 validation error (422) keyed on "aggregate".
- Duplicate pairs collapse to one spec (same key), so the response map has one entry per requested aggregate.
type Capabilities ¶
type Capabilities struct {
Transactions bool
Savepoints bool
ForeignKeyConstraints bool
Returning bool
Upsert bool
AdvisoryLocks bool
ConcurrentIndexBuilds bool
}
Capabilities names behavior that differs across supported database drivers.
func CapabilitiesFor ¶
func CapabilitiesFor(driver Driver) Capabilities
CapabilitiesFor returns the capability model for driver.
type DB ¶
DB is an opened GORM database with Gombit driver metadata.
func Open ¶
func Open(cfg config.DatabaseConfig) (*DB, error)
Open opens a GORM database for a supported config.DatabaseConfig.
func OpenConn ¶ added in v0.6.0
OpenConn opens a GORM database for driver over conn, a database/sql handle the caller built: one whose driver is wrapped for instrumentation or, in tests, for fault injection. The DB behaves as one from Open (the same error translation and model validation). The caller owns conn's pool settings (ConfigurePool gives it Open's); Close closes conn.
func (*DB) Capabilities ¶
func (db *DB) Capabilities() Capabilities
Capabilities returns the configured driver's capability flags.
func (*DB) ReplaceDefaultLogger ¶ added in v0.7.1
func (db *DB) ReplaceDefaultLogger(l gormlogger.Interface) bool
ReplaceDefaultLogger installs l as the database's GORM logger if it still has the exact logger Open or OpenConn installed, and reports whether it did. Any logger the caller set, including one derived from the default with LogMode (db.Logger = db.Logger.LogMode(gormlogger.Silent)), is kept. Sessions and transactions derived from db afterwards use l; ones derived before keep the logger they copied.
type Driver ¶
type Driver string
Driver names a supported database driver.
const ( // DriverSQLite identifies SQLite. DriverSQLite Driver = Driver(config.DatabaseDriverSQLite) // DriverPostgres identifies PostgreSQL. DriverPostgres Driver = Driver(config.DatabaseDriverPostgres) // DriverMySQL identifies MySQL. DriverMySQL Driver = Driver(config.DatabaseDriverMySQL) )
type FilterKind ¶ added in v0.1.11
type FilterKind int
FilterKind is the column type an exact-match filter coerces its raw query string to before comparing. Filters arrive as strings (Huma does not support optional/pointer query params, so an empty string is the "absent" signal — the same convention the admin data plane uses), and the kind decides how the string is parsed and bound.
const ( FilterString FilterKind = iota FilterInt FilterInt64 FilterUint FilterBool FilterUUID )
type StoredRow ¶ added in v0.8.0
type StoredRow struct {
// contains filtered or unexported fields
}
StoredRow is what a row loaded from the database stores in the columns the write checks judge: which timestamp and date columns hold the zero instant, and each text column's value. StoredValues takes it; ScopeEdit uses it so an edit of the row is judged on what it changes, not on what the row already held.
type ValidationError ¶ added in v0.1.7
ValidationError is a domain validation failure raised by a model's Validate hook. MapPersistError maps it to a D10 422 validation_error, preserving the per-field messages.
func NewValidationError ¶ added in v0.1.7
func NewValidationError(message string, fields map[string][]string) *ValidationError
NewValidationError builds a domain ValidationError. Pass nil fields for a message-only error.
func (*ValidationError) Error ¶ added in v0.1.7
func (e *ValidationError) Error() string
Error satisfies the error interface.
type Validator ¶ added in v0.1.7
Validator is implemented by models that enforce domain invariants — rules that cross fields, rows, or models, such as "ownership windows must not overlap" or "confirm a rental only if every child resource is available".
The framework runs Validate inside the write transaction on BOTH the API (generated handler Create/Save) and the admin data-plane write paths, via a GORM callback registered in Open. An invariant therefore has exactly one home and neither write surface can bypass it. tx is the transaction the write runs in, so Validate can query committed-but-in-flight state consistently.
Return a *ValidationError (see NewValidationError) to surface field-level D10 422 responses; any other error aborts the write and maps to 500.