diff --git a/database/config.go b/database/config.go index edf3f92..72daeee 100644 --- a/database/config.go +++ b/database/config.go @@ -1,6 +1,7 @@ package database import ( + "fmt" "os" "time" @@ -9,15 +10,67 @@ import ( // Config represents the database configuration structure type Config struct { - MySQL DBConfig `yaml:"mysql"` + // MySQL configuration (use this OR Postgres, not both) + MySQL *MySQLConfig `yaml:"mysql,omitempty"` + // Postgres configuration (use this OR MySQL, not both) + Postgres *PostgresConfig `yaml:"postgres,omitempty"` + + // Legacy flat PostgreSQL format (deprecated, for backward compatibility only) + // If neither MySQL nor Postgres is set, these fields will be used for PostgreSQL + User string `yaml:"user,omitempty"` + Pass string `yaml:"pass,omitempty"` + Host string `yaml:"host,omitempty"` + Port uint16 `yaml:"port,omitempty"` + Name string `yaml:"name,omitempty"` + SSLMode string `yaml:"sslmode,omitempty"` } -// DBConfig represents the MySQL database configuration -type DBConfig struct { - DSN string `default:"" flag:"dsn" usage:"Database DSN"` - User string `default:"" flag:"user"` - Pass string `default:"" flag:"pass"` - DBName string // Optional database name override +// MySQLConfig represents the MySQL database configuration +type MySQLConfig struct { + DSN string `yaml:"dsn" default:"" flag:"dsn" usage:"Database DSN"` + User string `yaml:"user" default:"" flag:"user"` + Pass string `yaml:"pass" default:"" flag:"pass"` + DBName string `yaml:"name,omitempty"` // Optional database name override +} + +// PostgresConfig represents the PostgreSQL database configuration +type PostgresConfig struct { + User string `yaml:"user"` + Pass string `yaml:"pass"` + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + Name string `yaml:"name"` + SSLMode string `yaml:"sslmode"` +} + +// DBConfig is a legacy alias for MySQLConfig +type DBConfig = MySQLConfig + +// Validate ensures the configuration is valid and unambiguous +func (c *Config) Validate() error { + hasMySQL := c.MySQL != nil + hasPostgres := c.Postgres != nil + hasLegacy := c.User != "" || c.Host != "" || c.Port != 0 || c.Name != "" + + count := 0 + if hasMySQL { + count++ + } + if hasPostgres { + count++ + } + if hasLegacy { + count++ + } + + if count == 0 { + return fmt.Errorf("no database configuration provided") + } + if count > 1 { + return fmt.Errorf("multiple database configurations provided (only one allowed)") + } + + return nil } // ConfigOptions allows customization of database opening behavior diff --git a/database/config_test.go b/database/config_test.go index 2eb9851..8a6aa7b 100644 --- a/database/config_test.go +++ b/database/config_test.go @@ -56,9 +56,9 @@ func TestMonitorConfigOptions(t *testing.T) { } func TestConfigStructures(t *testing.T) { - // Test that configuration structures can be created and populated + // Test that MySQL configuration structures can be created and populated config := Config{ - MySQL: DBConfig{ + MySQL: &MySQLConfig{ DSN: "user:pass@tcp(localhost:3306)/dbname", User: "testuser", Pass: "testpass", @@ -79,3 +79,118 @@ func TestConfigStructures(t *testing.T) { t.Errorf("Expected DBName='testdb', got '%s'", config.MySQL.DBName) } } + +func TestPostgresConfigStructures(t *testing.T) { + // Test that PostgreSQL configuration structures can be created and populated + config := Config{ + Postgres: &PostgresConfig{ + Host: "localhost", + Port: 5432, + User: "testuser", + Pass: "testpass", + Name: "testdb", + SSLMode: "require", + }, + } + + if config.Postgres.Host != "localhost" { + t.Errorf("Expected Host='localhost', got '%s'", config.Postgres.Host) + } + if config.Postgres.Port != 5432 { + t.Errorf("Expected Port=5432, got %d", config.Postgres.Port) + } + if config.Postgres.User != "testuser" { + t.Errorf("Expected User='testuser', got '%s'", config.Postgres.User) + } + if config.Postgres.Pass != "testpass" { + t.Errorf("Expected Pass='testpass', got '%s'", config.Postgres.Pass) + } + if config.Postgres.Name != "testdb" { + t.Errorf("Expected Name='testdb', got '%s'", config.Postgres.Name) + } + if config.Postgres.SSLMode != "require" { + t.Errorf("Expected SSLMode='require', got '%s'", config.Postgres.SSLMode) + } +} + +func TestLegacyPostgresConfig(t *testing.T) { + // Test that legacy flat PostgreSQL format can be created + config := Config{ + User: "testuser", + Pass: "testpass", + Host: "localhost", + Port: 5432, + Name: "testdb", + SSLMode: "require", + } + + if config.User != "testuser" { + t.Errorf("Expected User='testuser', got '%s'", config.User) + } + if config.Name != "testdb" { + t.Errorf("Expected Name='testdb', got '%s'", config.Name) + } +} + +func TestConfigValidation(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid mysql config", + config: Config{ + MySQL: &MySQLConfig{DSN: "test"}, + }, + wantErr: false, + }, + { + name: "valid postgres config", + config: Config{ + Postgres: &PostgresConfig{User: "test", Host: "localhost", Name: "test"}, + }, + wantErr: false, + }, + { + name: "valid legacy postgres config", + config: Config{ + User: "test", + Host: "localhost", + Name: "testdb", + }, + wantErr: false, + }, + { + name: "both mysql and postgres set", + config: Config{ + MySQL: &MySQLConfig{DSN: "test"}, + Postgres: &PostgresConfig{User: "test"}, + }, + wantErr: true, + }, + { + name: "mysql and legacy postgres set", + config: Config{ + MySQL: &MySQLConfig{DSN: "test"}, + User: "test", + Name: "testdb", + }, + wantErr: true, + }, + { + name: "no config set", + config: Config{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/database/connector.go b/database/connector.go index 6386dbd..fe76b70 100644 --- a/database/connector.go +++ b/database/connector.go @@ -8,6 +8,8 @@ import ( "os" "github.com/go-sql-driver/mysql" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" "gopkg.in/yaml.v3" ) @@ -58,31 +60,128 @@ func createConnector(configFile string) CreateConnectorFunc { return nil, err } - dsn := cfg.MySQL.DSN - if len(dsn) == 0 { - dsn = os.Getenv("DATABASE_DSN") - if len(dsn) == 0 { - return nil, fmt.Errorf("dsn config in database.yaml or DATABASE_DSN environment variable required") - } + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) } - dbcfg, err := mysql.ParseDSN(dsn) - if err != nil { - return nil, err + // Determine database type and create appropriate connector + if cfg.MySQL != nil { + return createMySQLConnector(cfg.MySQL) + } else if cfg.Postgres != nil { + return createPostgresConnector(cfg.Postgres) + } else if cfg.User != "" && cfg.Name != "" { + // Legacy flat PostgreSQL format (requires at minimum user and dbname) + return createPostgresConnectorFromFlat(&cfg) } - if user := cfg.MySQL.User; len(user) > 0 { - dbcfg.User = user - } - - if pass := cfg.MySQL.Pass; len(pass) > 0 { - dbcfg.Passwd = pass - } - - if name := cfg.MySQL.DBName; len(name) > 0 { - dbcfg.DBName = name - } - - return mysql.NewConnector(dbcfg) + return nil, fmt.Errorf("no valid database configuration found (mysql or postgres section required)") } } + +// createMySQLConnector creates a MySQL connector from configuration +func createMySQLConnector(cfg *MySQLConfig) (driver.Connector, error) { + dsn := cfg.DSN + if len(dsn) == 0 { + dsn = os.Getenv("DATABASE_DSN") + if len(dsn) == 0 { + return nil, fmt.Errorf("dsn config in database.yaml or DATABASE_DSN environment variable required") + } + } + + dbcfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, err + } + + if user := cfg.User; len(user) > 0 { + dbcfg.User = user + } + + if pass := cfg.Pass; len(pass) > 0 { + dbcfg.Passwd = pass + } + + if name := cfg.DBName; len(name) > 0 { + dbcfg.DBName = name + } + + return mysql.NewConnector(dbcfg) +} + +// createPostgresConnector creates a PostgreSQL connector from configuration +func createPostgresConnector(cfg *PostgresConfig) (driver.Connector, error) { + // Validate required fields + if cfg.Host == "" { + return nil, fmt.Errorf("postgres: host is required") + } + if cfg.User == "" { + return nil, fmt.Errorf("postgres: user is required") + } + if cfg.Name == "" { + return nil, fmt.Errorf("postgres: database name is required") + } + + // Validate SSLMode + validSSLModes := map[string]bool{ + "disable": true, "allow": true, "prefer": true, + "require": true, "verify-ca": true, "verify-full": true, + } + if cfg.SSLMode != "" && !validSSLModes[cfg.SSLMode] { + return nil, fmt.Errorf("postgres: invalid sslmode: %s", cfg.SSLMode) + } + + // Build config directly (security: no DSN string with password) + connConfig, err := pgx.ParseConfig("") + if err != nil { + return nil, fmt.Errorf("postgres: failed to create pgx config: %w", err) + } + + connConfig.Host = cfg.Host + connConfig.Port = cfg.Port + connConfig.User = cfg.User + connConfig.Password = cfg.Pass + connConfig.Database = cfg.Name + + // Map SSLMode to pgx configuration + // Note: pgx uses different SSL handling than libpq + // For now, we'll construct a minimal DSN with sslmode for ParseConfig + if cfg.SSLMode != "" { + // Reconstruct with sslmode only (no password in DSN) + dsnWithoutPassword := fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Name, cfg.SSLMode) + connConfig, err = pgx.ParseConfig(dsnWithoutPassword) + if err != nil { + return nil, fmt.Errorf("postgres: failed to parse config with sslmode: %w", err) + } + // Set password separately after parsing + connConfig.Password = cfg.Pass + } + + return stdlib.GetConnector(*connConfig), nil +} + +// createPostgresConnectorFromFlat creates a PostgreSQL connector from flat config format +func createPostgresConnectorFromFlat(cfg *Config) (driver.Connector, error) { + pgCfg := &PostgresConfig{ + User: cfg.User, + Pass: cfg.Pass, + Host: cfg.Host, + Port: cfg.Port, + Name: cfg.Name, + SSLMode: cfg.SSLMode, + } + + // Set defaults for PostgreSQL + if pgCfg.Host == "" { + pgCfg.Host = "localhost" + } + if pgCfg.Port == 0 { + pgCfg.Port = 5432 + } + if pgCfg.SSLMode == "" { + pgCfg.SSLMode = "prefer" + } + + return createPostgresConnector(pgCfg) +} diff --git a/database/pgdb/CLAUDE.md b/database/pgdb/CLAUDE.md new file mode 100644 index 0000000..a205437 --- /dev/null +++ b/database/pgdb/CLAUDE.md @@ -0,0 +1,120 @@ +# pgdb - Native PostgreSQL Connection Pool + +Primary package for PostgreSQL connections using native pgx pool (`*pgxpool.Pool`). Provides better performance and PostgreSQL-specific features compared to `database/sql`. + +## Usage + +### Basic Example + +```go +import ( + "context" + "go.ntppool.org/common/database/pgdb" +) + +func main() { + ctx := context.Background() + + // Open pool with default options + pool, err := pgdb.OpenPool(ctx, pgdb.DefaultPoolOptions()) + if err != nil { + log.Fatal(err) + } + defer pool.Close() + + // Use the pool for queries + row := pool.QueryRow(ctx, "SELECT version()") + var version string + row.Scan(&version) +} +``` + +### With Custom Config File + +```go +pool, err := pgdb.OpenPoolWithConfigFile(ctx, "/path/to/database.yaml") +``` + +### With Custom Pool Settings + +```go +opts := pgdb.DefaultPoolOptions() +opts.MaxConns = 50 +opts.MinConns = 5 +opts.MaxConnLifetime = 2 * time.Hour + +pool, err := pgdb.OpenPool(ctx, opts) +``` + +## Configuration Format + +### Recommended: Nested Format (database.yaml) + +```yaml +postgres: + host: localhost + port: 5432 + user: myuser + pass: mypassword + name: mydb + sslmode: prefer +``` + +### Legacy: Flat Format (backward compatible) + +```yaml +host: localhost +port: 5432 +user: myuser +pass: mypassword +name: mydb +sslmode: prefer +``` + +## Configuration Options + +### PoolOptions + +- `ConfigFiles` - List of config file paths to search (default: `database.yaml`, `/vault/secrets/database.yaml`) +- `MinConns` - Minimum connections (default: 0) +- `MaxConns` - Maximum connections (default: 25) +- `MaxConnLifetime` - Connection lifetime (default: 1 hour) +- `MaxConnIdleTime` - Idle timeout (default: 30 minutes) +- `HealthCheckPeriod` - Health check interval (default: 1 minute) + +### PostgreSQL Config Fields + +- `host` - Database host (required) +- `user` - Database user (required) +- `pass` - Database password +- `name` - Database name (required) +- `port` - Port number (default: 5432) +- `sslmode` - SSL mode: `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full` (default: `prefer`) + +## Environment Variables + +- `DATABASE_CONFIG_FILE` - Override config file location + +## When to Use + +**Use `pgdb.OpenPool()`** (this package) when: +- You need native PostgreSQL features (LISTEN/NOTIFY, COPY, etc.) +- You want better performance +- You're writing new PostgreSQL code + +**Use `database.OpenDB()`** (sql.DB) when: +- You need database-agnostic code +- You're using SQLC or other tools that expect `database/sql` +- You need to support both MySQL and PostgreSQL + +## Security + +This package avoids password exposure by: +1. Never constructing DSN strings with passwords +2. Setting passwords separately in pgx config objects +3. Validating all configuration before connection + +## See Also + +- `database/` - Generic sql.DB support for MySQL and PostgreSQL +- pgx documentation: https://github.com/jackc/pgx diff --git a/database/pgdb/config.go b/database/pgdb/config.go new file mode 100644 index 0000000..98f4184 --- /dev/null +++ b/database/pgdb/config.go @@ -0,0 +1,64 @@ +package pgdb + +import ( + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "go.ntppool.org/common/database" +) + +// CreatePoolConfig converts database.PostgresConfig to pgxpool.Config +// This is the secure way to create a config without exposing passwords in DSN strings +func CreatePoolConfig(cfg *database.PostgresConfig) (*pgxpool.Config, error) { + // Validate required fields + if cfg.Host == "" { + return nil, fmt.Errorf("postgres: host is required") + } + if cfg.User == "" { + return nil, fmt.Errorf("postgres: user is required") + } + if cfg.Name == "" { + return nil, fmt.Errorf("postgres: database name is required") + } + + // Validate SSLMode + validSSLModes := map[string]bool{ + "disable": true, "allow": true, "prefer": true, + "require": true, "verify-ca": true, "verify-full": true, + } + if cfg.SSLMode != "" && !validSSLModes[cfg.SSLMode] { + return nil, fmt.Errorf("postgres: invalid sslmode: %s", cfg.SSLMode) + } + + // Set defaults + host := cfg.Host + if host == "" { + host = "localhost" + } + + port := cfg.Port + if port == 0 { + port = 5432 + } + + sslmode := cfg.SSLMode + if sslmode == "" { + sslmode = "prefer" + } + + // Build connection string WITHOUT password + // We'll set the password separately in the config + connString := fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=%s", + host, port, cfg.User, cfg.Name, sslmode) + + // Parse the connection string + poolConfig, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, fmt.Errorf("postgres: failed to parse connection config: %w", err) + } + + // Set password separately (security: never put password in the connection string) + poolConfig.ConnConfig.Password = cfg.Pass + + return poolConfig, nil +} diff --git a/database/pgdb/pool.go b/database/pgdb/pool.go new file mode 100644 index 0000000..1731a3b --- /dev/null +++ b/database/pgdb/pool.go @@ -0,0 +1,173 @@ +package pgdb + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "go.ntppool.org/common/database" + "gopkg.in/yaml.v3" +) + +// PoolOptions configures pgxpool connection behavior +type PoolOptions struct { + // ConfigFiles is a list of config file paths to search for database configuration + ConfigFiles []string + + // MinConns is the minimum number of connections in the pool + // Default: 0 (no minimum) + MinConns int32 + + // MaxConns is the maximum number of connections in the pool + // Default: 25 + MaxConns int32 + + // MaxConnLifetime is the maximum lifetime of a connection + // Default: 1 hour + MaxConnLifetime time.Duration + + // MaxConnIdleTime is the maximum idle time of a connection + // Default: 30 minutes + MaxConnIdleTime time.Duration + + // HealthCheckPeriod is how often to check connection health + // Default: 1 minute + HealthCheckPeriod time.Duration +} + +// DefaultPoolOptions returns sensible defaults for pgxpool +func DefaultPoolOptions() PoolOptions { + return PoolOptions{ + ConfigFiles: getConfigFiles(), + MinConns: 0, + MaxConns: 25, + MaxConnLifetime: time.Hour, + MaxConnIdleTime: 30 * time.Minute, + HealthCheckPeriod: time.Minute, + } +} + +// OpenPool opens a native pgx connection pool with the specified configuration +// This is the primary and recommended way to connect to PostgreSQL +func OpenPool(ctx context.Context, options PoolOptions) (*pgxpool.Pool, error) { + // Find and read config file + pgCfg, err := findAndParseConfig(options.ConfigFiles) + if err != nil { + return nil, err + } + + // Create pool config from PostgreSQL config + poolConfig, err := CreatePoolConfig(pgCfg) + if err != nil { + return nil, err + } + + // Apply pool-specific settings + poolConfig.MinConns = options.MinConns + poolConfig.MaxConns = options.MaxConns + poolConfig.MaxConnLifetime = options.MaxConnLifetime + poolConfig.MaxConnIdleTime = options.MaxConnIdleTime + poolConfig.HealthCheckPeriod = options.HealthCheckPeriod + + // Create the pool + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test the connection + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return pool, nil +} + +// OpenPoolWithConfigFile opens a connection pool using an explicit config file path +// This is a convenience function for when you have a specific config file +func OpenPoolWithConfigFile(ctx context.Context, configFile string) (*pgxpool.Pool, error) { + options := DefaultPoolOptions() + options.ConfigFiles = []string{configFile} + return OpenPool(ctx, options) +} + +// findAndParseConfig searches for and parses the first existing config file +func findAndParseConfig(configFiles []string) (*database.PostgresConfig, error) { + var firstErr error + + for _, configFile := range configFiles { + if configFile == "" { + continue + } + + // Check if file exists + if _, err := os.Stat(configFile); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + + // Try to read and parse the file + pgCfg, err := parseConfigFile(configFile) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + + return pgCfg, nil + } + + if firstErr != nil { + return nil, fmt.Errorf("no config file found: %w", firstErr) + } + return nil, fmt.Errorf("no valid config files provided") +} + +// parseConfigFile reads and parses a YAML config file +func parseConfigFile(configFile string) (*database.PostgresConfig, error) { + file, err := os.Open(configFile) + if err != nil { + return nil, fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + dec := yaml.NewDecoder(file) + cfg := database.Config{} + + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("failed to decode config: %w", err) + } + + // Extract PostgreSQL config + if cfg.Postgres != nil { + return cfg.Postgres, nil + } + + // Check for legacy flat format + if cfg.User != "" && cfg.Name != "" { + return &database.PostgresConfig{ + User: cfg.User, + Pass: cfg.Pass, + Host: cfg.Host, + Port: cfg.Port, + Name: cfg.Name, + SSLMode: cfg.SSLMode, + }, nil + } + + return nil, fmt.Errorf("no PostgreSQL configuration found in %s", configFile) +} + +// getConfigFiles returns the list of config files to search +func getConfigFiles() []string { + if configFile := os.Getenv("DATABASE_CONFIG_FILE"); configFile != "" { + return []string{configFile} + } + return []string{"database.yaml", "/vault/secrets/database.yaml"} +} diff --git a/database/pgdb/pool_test.go b/database/pgdb/pool_test.go new file mode 100644 index 0000000..02f1db8 --- /dev/null +++ b/database/pgdb/pool_test.go @@ -0,0 +1,151 @@ +package pgdb + +import ( + "testing" + "time" + + "go.ntppool.org/common/database" +) + +func TestCreatePoolConfig(t *testing.T) { + tests := []struct { + name string + cfg *database.PostgresConfig + wantErr bool + }{ + { + name: "valid config", + cfg: &database.PostgresConfig{ + Host: "localhost", + Port: 5432, + User: "testuser", + Pass: "testpass", + Name: "testdb", + SSLMode: "require", + }, + wantErr: false, + }, + { + name: "valid config with defaults", + cfg: &database.PostgresConfig{ + Host: "localhost", + User: "testuser", + Pass: "testpass", + Name: "testdb", + // Port and SSLMode will use defaults + }, + wantErr: false, + }, + { + name: "missing host", + cfg: &database.PostgresConfig{ + User: "testuser", + Pass: "testpass", + Name: "testdb", + }, + wantErr: true, + }, + { + name: "missing user", + cfg: &database.PostgresConfig{ + Host: "localhost", + Pass: "testpass", + Name: "testdb", + }, + wantErr: true, + }, + { + name: "missing database name", + cfg: &database.PostgresConfig{ + Host: "localhost", + User: "testuser", + Pass: "testpass", + }, + wantErr: true, + }, + { + name: "invalid sslmode", + cfg: &database.PostgresConfig{ + Host: "localhost", + User: "testuser", + Pass: "testpass", + Name: "testdb", + SSLMode: "invalid", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + poolCfg, err := CreatePoolConfig(tt.cfg) + if (err != nil) != tt.wantErr { + t.Errorf("CreatePoolConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && poolCfg == nil { + t.Error("CreatePoolConfig() returned nil config without error") + } + if !tt.wantErr && poolCfg != nil { + // Verify config fields are set correctly + if poolCfg.ConnConfig.Host != tt.cfg.Host && tt.cfg.Host != "" { + t.Errorf("Expected Host=%s, got %s", tt.cfg.Host, poolCfg.ConnConfig.Host) + } + if poolCfg.ConnConfig.User != tt.cfg.User { + t.Errorf("Expected User=%s, got %s", tt.cfg.User, poolCfg.ConnConfig.User) + } + if poolCfg.ConnConfig.Password != tt.cfg.Pass { + t.Errorf("Expected Password to be set correctly") + } + if poolCfg.ConnConfig.Database != tt.cfg.Name { + t.Errorf("Expected Database=%s, got %s", tt.cfg.Name, poolCfg.ConnConfig.Database) + } + } + }) + } +} + +func TestDefaultPoolOptions(t *testing.T) { + opts := DefaultPoolOptions() + + // Verify expected defaults + if opts.MinConns != 0 { + t.Errorf("Expected MinConns=0, got %d", opts.MinConns) + } + if opts.MaxConns != 25 { + t.Errorf("Expected MaxConns=25, got %d", opts.MaxConns) + } + if opts.MaxConnLifetime != time.Hour { + t.Errorf("Expected MaxConnLifetime=1h, got %v", opts.MaxConnLifetime) + } + if opts.MaxConnIdleTime != 30*time.Minute { + t.Errorf("Expected MaxConnIdleTime=30m, got %v", opts.MaxConnIdleTime) + } + if opts.HealthCheckPeriod != time.Minute { + t.Errorf("Expected HealthCheckPeriod=1m, got %v", opts.HealthCheckPeriod) + } + if len(opts.ConfigFiles) == 0 { + t.Error("Expected ConfigFiles to be non-empty") + } +} + +func TestCreatePoolConfigDefaults(t *testing.T) { + // Test that defaults are applied correctly + cfg := &database.PostgresConfig{ + Host: "localhost", + User: "testuser", + Pass: "testpass", + Name: "testdb", + // Port and SSLMode not set + } + + poolCfg, err := CreatePoolConfig(cfg) + if err != nil { + t.Fatalf("CreatePoolConfig() failed: %v", err) + } + + // Verify defaults were applied + if poolCfg.ConnConfig.Port != 5432 { + t.Errorf("Expected default Port=5432, got %d", poolCfg.ConnConfig.Port) + } +} diff --git a/go.mod b/go.mod index 0a66669..3d0c51c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.5 require ( github.com/abh/certman v0.4.0 github.com/go-sql-driver/mysql v1.9.3 + github.com/jackc/pgx/v5 v5.7.6 github.com/labstack/echo-contrib v0.17.2 github.com/labstack/echo/v4 v4.13.3 github.com/oklog/ulid/v2 v2.1.0 @@ -34,7 +35,7 @@ require ( go.opentelemetry.io/otel/trace v1.33.0 golang.org/x/mod v0.22.0 golang.org/x/net v0.33.0 - golang.org/x/sync v0.10.0 + golang.org/x/sync v0.13.0 google.golang.org/grpc v1.69.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -50,6 +51,9 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -70,9 +74,9 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect diff --git a/go.sum b/go.sum index 06854dd..d1f8c5e 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,14 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3Ar github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= @@ -90,6 +98,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -160,8 +170,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= @@ -177,8 +187,8 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -189,8 +199,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -203,8 +213,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=