// pgm // // A simple PG string query builder // // Author: Ankit Patial package pgm import ( "context" "errors" "fmt" "log/slog" "strings" "sync/atomic" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) var ( poolPGX atomic.Pointer[pgxpool.Pool] ErrConnStringMissing = errors.New("connection string is empty") ) // Common errors returned by pgm operations var ( ErrInitTX = errors.New("failed to init db.tx") ErrCommitTX = errors.New("failed to commit db.tx") ErrNoRows = errors.New("no data found") ) // Config holds the configuration for initializing the connection pool. // All fields except ConnString are optional and will use pgx defaults if not set. type Config struct { ConnString string MaxConns int32 MinConns int32 MaxConnLifetime time.Duration MaxConnIdleTime time.Duration } // InitPool initializes the connection pool with the provided configuration. // It validates the configuration and panics if invalid. // This function should be called once at application startup. // // Example: // // pgm.InitPool(pgm.Config{ // ConnString: "postgres://user:pass@localhost/dbname", // MaxConns: 100, // MinConns: 5, // }) func InitPool(conf Config) { if conf.ConnString == "" { panic(ErrConnStringMissing) } // Validate configuration if conf.MaxConns > 0 && conf.MinConns > 0 && conf.MinConns > conf.MaxConns { panic(fmt.Errorf("MinConns (%d) cannot be greater than MaxConns (%d)", conf.MinConns, conf.MaxConns)) } if conf.MaxConns < 0 || conf.MinConns < 0 { panic(errors.New("connection pool configuration cannot have negative values")) } cfg, err := pgxpool.ParseConfig(conf.ConnString) if err != nil { panic(err) } if conf.MaxConns > 0 { cfg.MaxConns = conf.MaxConns // 100 } if conf.MinConns > 0 { cfg.MinConns = conf.MinConns // 5 } if conf.MaxConnLifetime > 0 { cfg.MaxConnLifetime = conf.MaxConnLifetime // time.Minute * 10 } if conf.MaxConnIdleTime > 0 { cfg.MaxConnIdleTime = conf.MaxConnIdleTime // time.Minute * 5 } p, err := pgxpool.NewWithConfig(context.Background(), cfg) if err != nil { panic(err) } if err = p.Ping(context.Background()); err != nil { panic(err) } poolPGX.Store(p) } // GetPool returns the initialized connection pool instance. // It panics with a descriptive message if InitPool() has not been called. // This is a fail-fast approach to catch programming errors early. func GetPool() *pgxpool.Pool { p := poolPGX.Load() if p == nil { panic("pgm: connection pool not initialized, call InitPool() first") } return p } // ClosePool closes the connection pool gracefully. // Should be called during application shutdown. func ClosePool() { if p := poolPGX.Load(); p != nil { p.Close() poolPGX.Store(nil) } } // BeginTx begins a new database transaction from the connection pool. // Returns an error if the transaction cannot be started. // Remember to commit or rollback the transaction when done. // // Example: // // tx, err := pgm.BeginTx(ctx) // if err != nil { // return err // } // defer tx.Rollback(ctx) // rollback on error // // // ... do work ... // // return tx.Commit(ctx) func BeginTx(ctx context.Context) (pgx.Tx, error) { tx, err := poolPGX.Load().Begin(ctx) if err != nil { slog.Error("failed to begin transaction", "error", err) return nil, fmt.Errorf("failed to open db tx: %w", err) } return tx, nil } // IsNotFound checks if an error is a "no rows" error from pgx. // Returns true if the error indicates no rows were found in a query result. func IsNotFound(err error) bool { return errors.Is(err, pgx.ErrNoRows) } // PgTime converts a Go time.Time to PostgreSQL timestamptz type. // The time is stored as-is (preserves timezone information). func PgTime(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} } // PgTimeNow returns the current time as PostgreSQL timestamptz type. func PgTimeNow() pgtype.Timestamptz { return pgtype.Timestamptz{Time: time.Now(), Valid: true} } // TsAndQuery converts a text search query to use AND operator between terms. // Example: "hello world" becomes "hello & world" func TsAndQuery(q string) string { return strings.Join(strings.Fields(q), " & ") } // TsPrefixAndQuery converts a text search query to use AND operator with prefix matching. // Example: "hello world" becomes "hello:* & world:*" func TsPrefixAndQuery(q string) string { return strings.Join(fieldsWithSufix(q, ":*"), " & ") } // TsOrQuery converts a text search query to use OR operator between terms. // Example: "hello world" becomes "hello | world" func TsOrQuery(q string) string { return strings.Join(strings.Fields(q), " | ") } // TsPrefixOrQuery converts a text search query to use OR operator with prefix matching. // Example: "hello world" becomes "hello:* | world:*" func TsPrefixOrQuery(q string) string { return strings.Join(fieldsWithSufix(q, ":*"), " | ") } func fieldsWithSufix(v, sufix string) []string { fields := strings.Fields(v) prefixed := make([]string, len(fields)) for i, f := range fields { prefixed[i] = f + sufix } return prefixed }