diff --git a/database/pgdb/CLAUDE.md b/database/pgdb/CLAUDE.md index a205437..b577606 100644 --- a/database/pgdb/CLAUDE.md +++ b/database/pgdb/CLAUDE.md @@ -93,8 +93,34 @@ sslmode: prefer ## Environment Variables +- `DATABASE_URI` - PostgreSQL connection URI (takes precedence over config files) - `DATABASE_CONFIG_FILE` - Override config file location +### URI Format + +Standard PostgreSQL URI format: +``` +postgresql://user:password@host:port/database?sslmode=require&pool_max_conns=10 +``` + +Pool settings can be included in the URI query string: +- `pool_max_conns`, `pool_min_conns` +- `pool_max_conn_lifetime`, `pool_max_conn_idle_time` +- `pool_health_check_period` + +When using `DATABASE_URI`, `PoolOptions` are ignored and all settings come from the URI. + +Example with CloudNativePG: +```yaml +# Mount the secret's 'uri' key as DATABASE_URI +env: + - name: DATABASE_URI + valueFrom: + secretKeyRef: + name: mydb-app + key: uri +``` + ## When to Use **Use `pgdb.OpenPool()`** (this package) when: diff --git a/database/pgdb/pool.go b/database/pgdb/pool.go index 1731a3b..7a7b4e9 100644 --- a/database/pgdb/pool.go +++ b/database/pgdb/pool.go @@ -51,25 +51,45 @@ func DefaultPoolOptions() PoolOptions { // OpenPool opens a native pgx connection pool with the specified configuration // This is the primary and recommended way to connect to PostgreSQL +// +// Configuration precedence (highest to lowest): +// 1. DATABASE_URI environment variable (pool settings can be included in URI) +// 2. DATABASE_CONFIG_FILE environment variable (YAML) +// 3. Default config files (database.yaml, /vault/secrets/database.yaml) +// +// When using DATABASE_URI, pool settings (pool_max_conns, pool_min_conns, etc.) +// can be specified in the URI query string and PoolOptions are ignored. +// When using config files, PoolOptions are applied. 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 - } + var poolConfig *pgxpool.Config + var err error - // Create pool config from PostgreSQL config - poolConfig, err := CreatePoolConfig(pgCfg) - if err != nil { - return nil, err - } + // Check DATABASE_URI environment variable first (highest priority) + if uri := os.Getenv("DATABASE_URI"); uri != "" { + poolConfig, err = pgxpool.ParseConfig(uri) + if err != nil { + return nil, fmt.Errorf("failed to parse DATABASE_URI: %w", err) + } + // Pool settings from URI are used; PoolOptions ignored + } else { + // Fall back to config file approach + pgCfg, err := findAndParseConfig(options.ConfigFiles) + 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 + poolConfig, err = CreatePoolConfig(pgCfg) + if err != nil { + return nil, err + } + + // Apply pool-specific settings from PoolOptions (config files don't support these) + 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) diff --git a/database/pgdb/pool_test.go b/database/pgdb/pool_test.go index 02f1db8..efd14e1 100644 --- a/database/pgdb/pool_test.go +++ b/database/pgdb/pool_test.go @@ -1,6 +1,9 @@ package pgdb import ( + "context" + "os" + "strings" "testing" "time" @@ -149,3 +152,63 @@ func TestCreatePoolConfigDefaults(t *testing.T) { t.Errorf("Expected default Port=5432, got %d", poolCfg.ConnConfig.Port) } } + +func TestOpenPoolWithDatabaseURI(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This test requires a running PostgreSQL instance + uri := os.Getenv("TEST_DATABASE_URI") + if uri == "" { + t.Skip("TEST_DATABASE_URI not set") + } + + ctx := context.Background() + t.Setenv("DATABASE_URI", uri) + + pool, err := OpenPool(ctx, DefaultPoolOptions()) + if err != nil { + t.Fatalf("OpenPool failed: %v", err) + } + defer pool.Close() + + // Verify connection works + var result int + err = pool.QueryRow(ctx, "SELECT 1").Scan(&result) + if err != nil { + t.Fatalf("query failed: %v", err) + } + if result != 1 { + t.Errorf("expected 1, got %d", result) + } +} + +func TestDatabaseURIPrecedence(t *testing.T) { + // Test that DATABASE_URI takes precedence over config files + // We use localhost with a port that's unlikely to have postgres running + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Set DATABASE_URI to a parseable URI pointing to a non-listening port + t.Setenv("DATABASE_URI", "postgres://testuser:testpass@127.0.0.1:59999/testdb?connect_timeout=1") + + // Set config files to a nonexistent path - if this were used, we'd get + // "config file not found" error instead of connection refused + opts := DefaultPoolOptions() + opts.ConfigFiles = []string{"/nonexistent/path/database.yaml"} + + _, err := OpenPool(ctx, opts) + + // Should fail with connection error (not config file error) + // This proves DATABASE_URI was used instead of config files + if err == nil { + t.Fatal("expected error, got nil") + } + + // The error should be about connection failure, not about missing config file + errStr := err.Error() + if strings.Contains(errStr, "config file") || strings.Contains(errStr, "no such file") { + t.Errorf("expected connection error, got config file error: %v", err) + } +}