feat(database): add PostgreSQL support with native pgx pool
Add PostgreSQL support to database package alongside existing MySQL support. Both databases share common infrastructure (pool management, metrics, transactions) while using database-specific connectors. database/ changes: - Add PostgresConfig struct and PostgreSQL connector using pgx/stdlib - Change MySQL config from DBConfig to *MySQLConfig (pointer) - Add Config.Validate() to prevent multiple database configs - Add PostgreSQL connector with secure config building (no password in DSN) - Add field validation and secure defaults (SSLMode="prefer") - Support legacy flat PostgreSQL config format for backward compatibility - Add tests for PostgreSQL configs and validation New database/pgdb/ package: - Native pgx connection pool support (*pgxpool.Pool) - OpenPool() and OpenPoolWithConfigFile() APIs - CreatePoolConfig() for secure config conversion - PoolOptions for fine-grained pool control - Full test coverage and documentation Security: - Passwords never exposed in DSN strings - Set passwords separately in pgx config objects - Validate all configuration before connection Architecture: - Shared code in database/ for both MySQL and PostgreSQL (sql.DB) - database/pgdb/ for PostgreSQL-specific native pool support
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,15 +10,67 @@ import (
|
|||||||
|
|
||||||
// Config represents the database configuration structure
|
// Config represents the database configuration structure
|
||||||
type Config struct {
|
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
|
// MySQLConfig represents the MySQL database configuration
|
||||||
type DBConfig struct {
|
type MySQLConfig struct {
|
||||||
DSN string `default:"" flag:"dsn" usage:"Database DSN"`
|
DSN string `yaml:"dsn" default:"" flag:"dsn" usage:"Database DSN"`
|
||||||
User string `default:"" flag:"user"`
|
User string `yaml:"user" default:"" flag:"user"`
|
||||||
Pass string `default:"" flag:"pass"`
|
Pass string `yaml:"pass" default:"" flag:"pass"`
|
||||||
DBName string // Optional database name override
|
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
|
// ConfigOptions allows customization of database opening behavior
|
||||||
|
@@ -56,9 +56,9 @@ func TestMonitorConfigOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigStructures(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{
|
config := Config{
|
||||||
MySQL: DBConfig{
|
MySQL: &MySQLConfig{
|
||||||
DSN: "user:pass@tcp(localhost:3306)/dbname",
|
DSN: "user:pass@tcp(localhost:3306)/dbname",
|
||||||
User: "testuser",
|
User: "testuser",
|
||||||
Pass: "testpass",
|
Pass: "testpass",
|
||||||
@@ -79,3 +79,118 @@ func TestConfigStructures(t *testing.T) {
|
|||||||
t.Errorf("Expected DBName='testdb', got '%s'", config.MySQL.DBName)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -8,6 +8,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,31 +60,128 @@ func createConnector(configFile string) CreateConnectorFunc {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := cfg.MySQL.DSN
|
// Validate configuration
|
||||||
if len(dsn) == 0 {
|
if err := cfg.Validate(); err != nil {
|
||||||
dsn = os.Getenv("DATABASE_DSN")
|
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||||
if len(dsn) == 0 {
|
|
||||||
return nil, fmt.Errorf("dsn config in database.yaml or DATABASE_DSN environment variable required")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbcfg, err := mysql.ParseDSN(dsn)
|
// Determine database type and create appropriate connector
|
||||||
if err != nil {
|
if cfg.MySQL != nil {
|
||||||
return nil, err
|
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 {
|
return nil, fmt.Errorf("no valid database configuration found (mysql or postgres section required)")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
120
database/pgdb/CLAUDE.md
Normal file
120
database/pgdb/CLAUDE.md
Normal file
@@ -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
|
64
database/pgdb/config.go
Normal file
64
database/pgdb/config.go
Normal file
@@ -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
|
||||||
|
}
|
173
database/pgdb/pool.go
Normal file
173
database/pgdb/pool.go
Normal file
@@ -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"}
|
||||||
|
}
|
151
database/pgdb/pool_test.go
Normal file
151
database/pgdb/pool_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
12
go.mod
12
go.mod
@@ -5,6 +5,7 @@ go 1.23.5
|
|||||||
require (
|
require (
|
||||||
github.com/abh/certman v0.4.0
|
github.com/abh/certman v0.4.0
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
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-contrib v0.17.2
|
||||||
github.com/labstack/echo/v4 v4.13.3
|
github.com/labstack/echo/v4 v4.13.3
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
github.com/oklog/ulid/v2 v2.1.0
|
||||||
@@ -34,7 +35,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.33.0
|
go.opentelemetry.io/otel/trace v1.33.0
|
||||||
golang.org/x/mod v0.22.0
|
golang.org/x/mod v0.22.0
|
||||||
golang.org/x/net v0.33.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
|
google.golang.org/grpc v1.69.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,9 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // 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/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // 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/stdoutmetric v1.33.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
golang.org/x/time v0.8.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/api v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||||
|
26
go.sum
26
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
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/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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
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.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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
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-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.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.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
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-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
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.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.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.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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
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 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
Reference in New Issue
Block a user