// 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 DSN string MaxOpenConns int MaxIdleConns int ConnMaxLifetime time.Duration ConnMaxIdleTime time.Duration PingTimeout time.Duration } 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) { prefix = strings.TrimSpace(prefix) if prefix == "" { prefix = "GO_OMNI_DB_" } cfg := DefaultConfig() cfg.Driver = strings.TrimSpace(os.Getenv(prefix + "DRIVER")) if cfg.Driver == "" { cfg.Driver = "mysql" } cfg.DSN = strings.TrimSpace(os.Getenv(prefix + "DSN")) 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 }