193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
// Package database provides the database/sql lifecycle used by GoOmni projects.
|
|
// It deliberately does not import a database driver; each application chooses one.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Driver string `json:"driver"`
|
|
DSN string `json:"-"`
|
|
MaxOpenConns int `json:"maxOpenConns"`
|
|
MaxIdleConns int `json:"maxIdleConns"`
|
|
ConnMaxLifetime time.Duration `json:"connMaxLifetime"`
|
|
ConnMaxIdleTime time.Duration `json:"connMaxIdleTime"`
|
|
PingTimeout time.Duration `json:"pingTimeout"`
|
|
}
|
|
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
MaxOpenConns: 10,
|
|
MaxIdleConns: 5,
|
|
ConnMaxLifetime: 30 * time.Minute,
|
|
ConnMaxIdleTime: 5 * time.Minute,
|
|
PingTimeout: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
// ConfigFromEnv reads GO_OMNI_DB_* style settings. DSN is required.
|
|
func ConfigFromEnv(prefix string) (Config, error) {
|
|
return ApplyEnv(DefaultConfig(), prefix)
|
|
}
|
|
|
|
// ApplyEnv overlays nonempty environment settings on file/default configuration.
|
|
// DSN is environment-only when Config is decoded from JSON or YAML.
|
|
func ApplyEnv(cfg Config, prefix string) (Config, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
prefix = "GO_OMNI_DB_"
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv(prefix + "DRIVER")); value != "" {
|
|
cfg.Driver = value
|
|
}
|
|
if cfg.Driver == "" {
|
|
cfg.Driver = "mysql"
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv(prefix + "DSN")); value != "" {
|
|
cfg.DSN = value
|
|
}
|
|
if cfg.DSN == "" {
|
|
return Config{}, fmt.Errorf("%sDSN is required", prefix)
|
|
}
|
|
var err error
|
|
if cfg.MaxOpenConns, err = envInt(prefix+"MAX_OPEN_CONNS", cfg.MaxOpenConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.MaxIdleConns, err = envInt(prefix+"MAX_IDLE_CONNS", cfg.MaxIdleConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.ConnMaxLifetime, err = envDuration(prefix+"CONN_MAX_LIFETIME", cfg.ConnMaxLifetime); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.ConnMaxIdleTime, err = envDuration(prefix+"CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.PingTimeout, err = envDuration(prefix+"PING_TIMEOUT", cfg.PingTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, cfg.Validate()
|
|
}
|
|
|
|
func (c Config) Validate() error {
|
|
if strings.TrimSpace(c.Driver) == "" {
|
|
return errors.New("database driver is required")
|
|
}
|
|
if strings.TrimSpace(c.DSN) == "" {
|
|
return errors.New("database DSN is required")
|
|
}
|
|
if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 {
|
|
return errors.New("database pool limits cannot be negative")
|
|
}
|
|
if c.MaxIdleConns > c.MaxOpenConns && c.MaxOpenConns > 0 {
|
|
return errors.New("max idle connections cannot exceed max open connections")
|
|
}
|
|
if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 {
|
|
return errors.New("database timeouts are invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DB owns a connection pool and exposes the embedded database/sql methods.
|
|
type DB struct {
|
|
*sql.DB
|
|
config Config
|
|
}
|
|
|
|
func Open(ctx context.Context, cfg Config) (*DB, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := sql.Open(cfg.Driver, cfg.DSN)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
if cfg.MaxOpenConns > 0 {
|
|
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
|
}
|
|
// database/sql uses zero to disable idle connections; do not leave its
|
|
// default pool size in place when the caller explicitly chooses zero.
|
|
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
|
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
|
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
|
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
|
defer cancel()
|
|
if err := db.PingContext(checkCtx); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("ping database: %w", err)
|
|
}
|
|
return &DB{DB: db, config: cfg}, nil
|
|
}
|
|
|
|
func (db *DB) Config() Config { return db.config }
|
|
|
|
func (db *DB) Health(ctx context.Context) error {
|
|
if db == nil || db.DB == nil {
|
|
return errors.New("database is not initialized")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
checkCtx, cancel := context.WithTimeout(ctx, db.config.PingTimeout)
|
|
defer cancel()
|
|
return db.PingContext(checkCtx)
|
|
}
|
|
|
|
// InTx commits only when work returns nil. Any work error or panic rolls back.
|
|
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
|
|
if db == nil || db.DB == nil {
|
|
return errors.New("database is not initialized")
|
|
}
|
|
tx, err := db.BeginTx(ctx, options)
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer func() {
|
|
rollbackErr := tx.Rollback()
|
|
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
|
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
|
}
|
|
}()
|
|
if err = work(tx); err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envInt(key string, fallback int) (int, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
|
|
}
|
|
return parsed, nil
|
|
}
|