go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/sdk/db/config.go (about)

     1  /*
     2  
     3  Copyright (c) 2023 - Present. Will Charczuk. All rights reserved.
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository.
     5  
     6  */
     7  
     8  package db
     9  
    10  import (
    11  	"context"
    12  	"fmt"
    13  	"net/url"
    14  	"strings"
    15  	"time"
    16  
    17  	"go.charczuk.com/sdk/configutil"
    18  )
    19  
    20  const (
    21  	// DefaultEngine is the default database engine.
    22  	DefaultEngine = "pgx" // "postgres"
    23  )
    24  
    25  const (
    26  	// EnvVarDBEngine is the environment variable used to set the Go `sql` driver.
    27  	EnvVarDBEngine = "DB_ENGINE"
    28  	// EnvVarDatabaseURL is the environment variable used to set the entire
    29  	// database connection string.
    30  	EnvVarDatabaseURL = "DATABASE_URL"
    31  	// EnvVarDBHost is the environment variable used to set the host in a
    32  	// database connection string.
    33  	EnvVarDBHost = "DB_HOST"
    34  	// EnvVarDBPort is the environment variable used to set the port in a
    35  	// database connection string.
    36  	EnvVarDBPort = "DB_PORT"
    37  	// EnvVarDBName is the environment variable used to set the database name
    38  	// in a database connection string.
    39  	EnvVarDBName = "DB_NAME"
    40  	// EnvVarDBSchema is the environment variable used to set the database
    41  	// schema in a database connection string.
    42  	EnvVarDBSchema = "DB_SCHEMA"
    43  	// EnvVarDBApplicationName is the environment variable used to set the
    44  	// `application_name` configuration parameter in a `lib/pq` connection
    45  	// string.
    46  	//
    47  	// See: https://www.postgresql.org/docs/12/runtime-config-logging.html#GUC-APPLICATION-NAME
    48  	EnvVarDBApplicationName = "DB_APPLICATION_NAME"
    49  	// EnvVarDBUser is the environment variable used to set the user in a
    50  	// database connection string.
    51  	EnvVarDBUser = "DB_USER"
    52  	// EnvVarDBPassword is the environment variable used to set the password
    53  	// in a database connection string.
    54  	EnvVarDBPassword = "DB_PASSWORD"
    55  	// EnvVarDBConnectTimeout is is the environment variable used to set the
    56  	// connect timeout in a database connection string.
    57  	EnvVarDBConnectTimeout = "DB_CONNECT_TIMEOUT"
    58  	// EnvVarDBLockTimeout is is the environment variable used to set the lock
    59  	// timeout on a database config.
    60  	EnvVarDBLockTimeout = "DB_LOCK_TIMEOUT"
    61  	// EnvVarDBStatementTimeout is is the environment variable used to set the
    62  	// statement timeout on a database config.
    63  	EnvVarDBStatementTimeout = "DB_STATEMENT_TIMEOUT"
    64  	// EnvVarDBSSLMode is the environment variable used to set the SSL mode in
    65  	// a database connection string.
    66  	EnvVarDBSSLMode = "DB_SSLMODE"
    67  	// EnvVarDBIdleConnections is the environment variable used to set the
    68  	// maximum number of idle connections allowed in a connection pool.
    69  	EnvVarDBIdleConnections = "DB_IDLE_CONNECTIONS"
    70  	// EnvVarDBMaxConnections is the environment variable used to set the
    71  	// maximum number of connections allowed in a connection pool.
    72  	EnvVarDBMaxConnections = "DB_MAX_CONNECTIONS"
    73  	// EnvVarDBMaxLifetime is the environment variable used to set the maximum
    74  	// lifetime of a connection in a connection pool.
    75  	EnvVarDBMaxLifetime = "DB_MAX_LIFETIME"
    76  	// EnvVarDBMaxIdleTime is the environment variable used to set the maximum
    77  	// time a connection can be idle.
    78  	EnvVarDBMaxIdleTime = "DB_MAX_IDLE_TIME"
    79  	// EnvVarDBBufferPoolSize is the environment variable used to set the buffer
    80  	// pool size on a connection in a connection pool.
    81  	EnvVarDBBufferPoolSize = "DB_BUFFER_POOL_SIZE"
    82  	// EnvVarDBDialect is the environment variable used to set the dialect
    83  	// on a connection configuration (e.g. `postgres` or `cockroachdb`).
    84  	EnvVarDBDialect = "DB_DIALECT"
    85  )
    86  
    87  const (
    88  	// DefaultHost is the default database hostname, typically used
    89  	// when developing locally.
    90  	DefaultHost = "localhost"
    91  	// DefaultPort is the default postgres port.
    92  	DefaultPort = "5432"
    93  	// DefaultDatabase is the default database to connect to, we use
    94  	// `postgres` to not pollute the template databases.
    95  	DefaultDatabase = "postgres"
    96  
    97  	// DefaultSchema is the default schema to connect to
    98  	DefaultSchema = "public"
    99  )
   100  
   101  const (
   102  	// SSLModeDisable is an ssl mode.
   103  	// Postgres Docs: "I don't care about security, and I don't want to pay the overhead of encryption."
   104  	SSLModeDisable = "disable"
   105  	// SSLModeAllow is an ssl mode.
   106  	// Postgres Docs: "I don't care about security, but I will pay the overhead of encryption if the server insists on it."
   107  	SSLModeAllow = "allow"
   108  	// SSLModePrefer is an ssl mode.
   109  	// Postgres Docs: "I don't care about encryption, but I wish to pay the overhead of encryption if the server supports it"
   110  	SSLModePrefer = "prefer"
   111  	// SSLModeRequire is an ssl mode.
   112  	// Postgres Docs: "I want my data to be encrypted, and I accept the overhead. I trust that the network will make sure I always connect to the server I want."
   113  	SSLModeRequire = "require"
   114  	// SSLModeVerifyCA is an ssl mode.
   115  	// Postgres Docs: "I want my data encrypted, and I accept the overhead. I want to be sure that I connect to a server that I trust."
   116  	SSLModeVerifyCA = "verify-ca"
   117  	// SSLModeVerifyFull is an ssl mode.
   118  	// Postgres Docs: "I want my data encrypted, and I accept the overhead. I want to be sure that I connect to a server I trust, and that it's the one I specify."
   119  	SSLModeVerifyFull = "verify-full"
   120  )
   121  
   122  const (
   123  	// DefaultConnectTimeout is the default connect timeout.
   124  	DefaultConnectTimeout = 5 * time.Second
   125  	// DefaultIdleConnections is the default number of idle connections.
   126  	DefaultIdleConnections = 16
   127  	// DefaultMaxConnections is the default maximum number of connections.
   128  	DefaultMaxConnections = 32
   129  	// DefaultMaxLifetime is the default maximum lifetime of driver connections.
   130  	DefaultMaxLifetime = time.Duration(0)
   131  	// DefaultMaxIdleTime is the default maximum idle time of driver connections.
   132  	DefaultMaxIdleTime = time.Duration(0)
   133  	// DefaultBufferPoolSize is the default number of buffer pool entries to maintain.
   134  	DefaultBufferPoolSize = 1024
   135  )
   136  
   137  // Config is a set of connection config options.
   138  type Config struct {
   139  	// Engine is the database engine.
   140  	Engine string `json:"engine,omitempty" yaml:"engine,omitempty"`
   141  	// DSN is a fully formed DSN (this skips DSN formation from all other variables outside `schema`).
   142  	DSN string `json:"dsn,omitempty" yaml:"dsn,omitempty"`
   143  	// Host is the server to connect to.
   144  	Host string `json:"host,omitempty" yaml:"host,omitempty"`
   145  	// Port is the port to connect to.
   146  	Port string `json:"port,omitempty" yaml:"port,omitempty"`
   147  	// DBName is the database name
   148  	Database string `json:"database,omitempty" yaml:"database,omitempty"`
   149  	// Schema is the application schema within the database, defaults to `public`. This schema is used to set the
   150  	// Postgres "search_path" If you want to reference tables in other schemas, you'll need to specify those schemas
   151  	// in your queries e.g. "SELECT * FROM schema_two.table_one..."
   152  	// Using the public schema in a production application is considered bad practice as newly created roles will have
   153  	// visibility into this data by default. We strongly recommend specifying this option and using a schema that is
   154  	// owned by your service's role
   155  	// We recommend against setting a multi-schema search_path, but if you really want to, you provide multiple comma-
   156  	// separated schema names as the value for this config, or you can dbc.Invoke().Exec a SET statement on a newly
   157  	// opened connection such as "SET search_path = 'schema_one,schema_two';" Again, we recommend against this practice
   158  	// and encourage you to specify schema names beyond the first in your queries.
   159  	Schema string `json:"schema,omitempty" yaml:"schema,omitempty"`
   160  	// ApplicationName is the name set by an application connection to a database
   161  	// server, intended to be transmitted in the connection string. It can be
   162  	// used to uniquely identify an application and will be included in the
   163  	// `pg_stat_activity` view.
   164  	//
   165  	// See: https://www.postgresql.org/docs/12/runtime-config-logging.html#GUC-APPLICATION-NAME
   166  	ApplicationName string `json:"applicationName,omitempty" yaml:"applicationName,omitempty"`
   167  	// Username is the username for the connection via password auth.
   168  	Username string `json:"username,omitempty" yaml:"username,omitempty"`
   169  	// Password is the password for the connection via password auth.
   170  	Password string `json:"password,omitempty" yaml:"password,omitempty"`
   171  	// ConnectTimeout determines the maximum wait for connection. The minimum
   172  	// allowed timeout is 2 seconds, so anything below is treated the same
   173  	// as unset. PostgreSQL will only accept second precision so this value will be
   174  	// rounded to the nearest second before being set on a connection string.
   175  	// Use `Validate()` to confirm that `ConnectTimeout` is exact to second
   176  	// precision.
   177  	//
   178  	// See: https://www.postgresql.org/docs/10/libpq-connect.html#LIBPQ-CONNECT-CONNECT-TIMEOUT
   179  	ConnectTimeout time.Duration `json:"connectTimeout,omitempty" yaml:"connectTimeout,omitempty"`
   180  	// LockTimeout is the timeout to use when attempting to acquire a lock.
   181  	// PostgreSQL will only accept millisecond precision so this value will be
   182  	// rounded to the nearest millisecond before being set on a connection string.
   183  	// Use `Validate()` to confirm that `LockTimeout` is exact to millisecond
   184  	// precision.
   185  	//
   186  	// See: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT
   187  	LockTimeout time.Duration `json:"lockTimeout,omitempty" yaml:"lockTimeout,omitempty"`
   188  	// StatementTimeout is the timeout to use when invoking a SQL statement.
   189  	// PostgreSQL will only accept millisecond precision so this value will be
   190  	// rounded to the nearest millisecond before being set on a connection string.
   191  	// Use `Validate()` to confirm that `StatementTimeout` is exact to millisecond
   192  	// precision.
   193  	//
   194  	// See: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT
   195  	StatementTimeout time.Duration `json:"statementTimeout,omitempty" yaml:"statementTimeout,omitempty"`
   196  	// SSLMode is the sslmode for the connection.
   197  	SSLMode string `json:"sslMode,omitempty" yaml:"sslMode,omitempty"`
   198  	// IdleConnections is the number of idle connections.
   199  	IdleConnections int `json:"idleConnections,omitempty" yaml:"idleConnections,omitempty"`
   200  	// MaxConnections is the maximum number of connections.
   201  	MaxConnections int `json:"maxConnections,omitempty" yaml:"maxConnections,omitempty"`
   202  	// MaxLifetime is the maximum time a connection can be open.
   203  	MaxLifetime time.Duration `json:"maxLifetime,omitempty" yaml:"maxLifetime,omitempty"`
   204  	// MaxIdleTime is the maximum time a connection can be idle.
   205  	MaxIdleTime time.Duration `json:"maxIdleTime,omitempty" yaml:"maxIdleTime,omitempty"`
   206  	// BufferPoolSize is the number of query composition buffers to maintain.
   207  	BufferPoolSize int `json:"bufferPoolSize,omitempty" yaml:"bufferPoolSize,omitempty"`
   208  	// Dialect includes hints to tweak specific sql semantics by database connection.
   209  	Dialect string `json:"dialect,omitempty" yaml:"dialect,omitempty"`
   210  }
   211  
   212  // IsZero returns if the config is unset.
   213  func (c Config) IsZero() bool {
   214  	return c.DSN == "" && c.Host == "" && c.Port == "" && c.Database == "" && c.Schema == "" && c.Username == "" && c.Password == "" && c.SSLMode == ""
   215  }
   216  
   217  // Resolve applies any external data sources to the config.
   218  func (c *Config) Resolve(ctx context.Context) error {
   219  	return configutil.Resolve(ctx,
   220  		configutil.Set(&c.Engine, configutil.Env[string](EnvVarDBEngine), configutil.Lazy(&c.Engine), configutil.Const(DefaultEngine)),
   221  		configutil.Set(&c.DSN, configutil.Env[string](EnvVarDatabaseURL), configutil.Lazy(&c.DSN)),
   222  		configutil.Set(&c.Host, configutil.Env[string](EnvVarDBHost), configutil.Lazy(&c.Host), configutil.Const(DefaultHost)),
   223  		configutil.Set(&c.Port, configutil.Env[string](EnvVarDBPort), configutil.Lazy(&c.Port), configutil.Const(DefaultPort)),
   224  		configutil.Set(&c.Database, configutil.Env[string](EnvVarDBName), configutil.Lazy(&c.Database), configutil.Const(DefaultDatabase)),
   225  		configutil.Set(&c.Schema, configutil.Env[string](EnvVarDBSchema), configutil.Lazy(&c.Schema)),
   226  		configutil.Set(&c.ApplicationName, configutil.Env[string](EnvVarDBApplicationName), configutil.Const(c.ApplicationName)),
   227  		configutil.Set(&c.Username, configutil.Env[string](EnvVarDBUser), configutil.Lazy(&c.Username), configutil.Env[string]("USER")),
   228  		configutil.Set(&c.Password, configutil.Env[string](EnvVarDBPassword), configutil.Lazy(&c.Password)),
   229  		configutil.Set(&c.ConnectTimeout, configutil.Env[time.Duration](EnvVarDBConnectTimeout), configutil.Lazy(&c.ConnectTimeout), configutil.Const(DefaultConnectTimeout)),
   230  		configutil.Set(&c.LockTimeout, configutil.Env[time.Duration](EnvVarDBLockTimeout), configutil.Lazy(&c.LockTimeout)),
   231  		configutil.Set(&c.StatementTimeout, configutil.Env[time.Duration](EnvVarDBStatementTimeout), configutil.Lazy(&c.StatementTimeout)),
   232  		configutil.Set(&c.SSLMode, configutil.Env[string](EnvVarDBSSLMode), configutil.Lazy(&c.SSLMode)),
   233  		configutil.Set(&c.IdleConnections, configutil.Env[int](EnvVarDBIdleConnections), configutil.Lazy(&c.IdleConnections), configutil.Const(DefaultIdleConnections)),
   234  		configutil.Set(&c.MaxConnections, configutil.Env[int](EnvVarDBMaxConnections), configutil.Lazy(&c.MaxConnections), configutil.Const(DefaultMaxConnections)),
   235  		configutil.Set(&c.MaxLifetime, configutil.Env[time.Duration](EnvVarDBMaxLifetime), configutil.Lazy(&c.MaxLifetime), configutil.Const(DefaultMaxLifetime)),
   236  		configutil.Set(&c.MaxIdleTime, configutil.Env[time.Duration](EnvVarDBMaxIdleTime), configutil.Lazy(&c.MaxIdleTime), configutil.Const(DefaultMaxIdleTime)),
   237  		configutil.Set(&c.BufferPoolSize, configutil.Env[int](EnvVarDBBufferPoolSize), configutil.Lazy(&c.BufferPoolSize), configutil.Const(DefaultBufferPoolSize)),
   238  		configutil.Set(&c.Dialect, configutil.Env[string](EnvVarDBDialect), configutil.Lazy(&c.Dialect), configutil.Const(string(DialectPostgres))),
   239  	)
   240  }
   241  
   242  // CreateDSN creates a postgres connection string from the config.
   243  func (c Config) CreateDSN() string {
   244  	if c.DSN != "" {
   245  		return c.DSN
   246  	}
   247  
   248  	host := c.Host
   249  	if c.Port != "" {
   250  		host = host + ":" + c.Port
   251  	}
   252  
   253  	dsn := &url.URL{
   254  		Scheme: "postgres",
   255  		Host:   host,
   256  		Path:   c.Database,
   257  	}
   258  
   259  	if len(c.Username) > 0 {
   260  		if len(c.Password) > 0 {
   261  			dsn.User = url.UserPassword(c.Username, c.Password)
   262  		} else {
   263  			dsn.User = url.User(c.Username)
   264  		}
   265  	}
   266  
   267  	queryArgs := url.Values{}
   268  	if len(c.SSLMode) > 0 {
   269  		queryArgs.Add("sslmode", c.SSLMode)
   270  	}
   271  	if c.ConnectTimeout > 0 {
   272  		setTimeoutSeconds(queryArgs, "connect_timeout", c.ConnectTimeout)
   273  	}
   274  	if c.LockTimeout > 0 {
   275  		setTimeoutMilliseconds(queryArgs, "lock_timeout", c.LockTimeout)
   276  	}
   277  	if c.StatementTimeout > 0 {
   278  		setTimeoutMilliseconds(queryArgs, "statement_timeout", c.StatementTimeout)
   279  	}
   280  	if c.Schema != "" {
   281  		queryArgs.Add("search_path", c.Schema)
   282  	}
   283  	if c.ApplicationName != "" {
   284  		queryArgs.Add("application_name", c.ApplicationName)
   285  	}
   286  
   287  	dsn.RawQuery = queryArgs.Encode()
   288  	return dsn.String()
   289  }
   290  
   291  // CreateLoggingDSN creates a postgres connection string from the config suitable for logging.
   292  // It will not include the password.
   293  func (c Config) CreateLoggingDSN() string {
   294  	// NOTE: If we're provided a DSN, we _really_ want to have CreateDSN get
   295  	//       skipped because it'll just dump the DSN raw.
   296  	if c.DSN != "" {
   297  		u, _ := url.Parse(c.DSN)
   298  		u.User = url.User(u.User.Username())
   299  		return u.String()
   300  	}
   301  	// NOTE: Since `c` is a value receiver, we can modify it without
   302  	//       mutating the actual value.
   303  	c.Password = ""
   304  	return c.CreateDSN()
   305  }
   306  
   307  // Validate validates that user-provided values are valid, e.g. that timeouts
   308  // can be exactly rounded into a multiple of a given base value.
   309  func (c Config) Validate() error {
   310  	if c.ConnectTimeout.Round(time.Second) != c.ConnectTimeout {
   311  		return fmt.Errorf("invalid configuration value; connect_timeout=%s; %w", c.ConnectTimeout, ErrDurationConversion)
   312  	}
   313  	if c.LockTimeout.Round(time.Millisecond) != c.LockTimeout {
   314  		return fmt.Errorf("invalid configuration value; lock_timeout=%s; %w", c.LockTimeout, ErrDurationConversion)
   315  	}
   316  	if c.StatementTimeout.Round(time.Millisecond) != c.StatementTimeout {
   317  		return fmt.Errorf("invalid configuration value; statement_timeout=%s; %w", c.StatementTimeout, ErrDurationConversion)
   318  	}
   319  
   320  	return nil
   321  }
   322  
   323  // ValidateProduction validates production configuration for the config.
   324  func (c Config) ValidateProduction() error {
   325  	if !(len(c.SSLMode) == 0 ||
   326  		strings.EqualFold(c.SSLMode, SSLModeRequire) ||
   327  		strings.EqualFold(c.SSLMode, SSLModeVerifyCA) ||
   328  		strings.EqualFold(c.SSLMode, SSLModeVerifyFull)) {
   329  		return ErrUnsafeSSLMode
   330  	}
   331  	if len(c.Username) == 0 {
   332  		return ErrUsernameUnset
   333  	}
   334  	if len(c.Password) == 0 {
   335  		return ErrPasswordUnset
   336  	}
   337  	return c.Validate()
   338  }
   339  
   340  // setTimeoutMilliseconds sets a timeout value in connection string query parameters.
   341  //
   342  // Valid units for this parameter in PostgresSQL are "ms", "s", "min", "h"
   343  // and "d" and the value should be between 0 and 2147483647ms. We explicitly
   344  // cast to milliseconds but leave validation on the value to PostgreSQL.
   345  //
   346  // See:
   347  // - https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT
   348  // - https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT
   349  func setTimeoutMilliseconds(q url.Values, name string, d time.Duration) {
   350  	ms := d.Round(time.Millisecond) / time.Millisecond
   351  	q.Add(name, fmt.Sprintf("%dms", ms))
   352  }
   353  
   354  // setTimeoutSeconds sets a timeout value in connection string query parameters.
   355  //
   356  // This timeout is expected to be an exact number of seconds (as an integer)
   357  // so we convert `d` to an integer first and set the value as a query parameter
   358  // without units.
   359  //
   360  // See:
   361  // - https://www.postgresql.org/docs/10/libpq-connect.html#LIBPQ-CONNECT-CONNECT-TIMEOUT
   362  func setTimeoutSeconds(q url.Values, name string, d time.Duration) {
   363  	s := d.Round(time.Second) / time.Second
   364  	q.Add(name, fmt.Sprintf("%d", s))
   365  }