d59dcbe74e
Stand up the shared library with its first module: the Postgres plumbing every service currently copy-pastes — the DSN builder, the pool constructor, and the migration runner arrproxy proved out. - Add pg.DSNFromEnv, generalising the identical Sprintf builders in encapi, artifactapi and forgebot into one prefixed lookup with DATABASE_URL passthrough and libpq fallbacks. - Add pg.New and pg.NewMigrated, which ping before returning so an unreachable server fails at startup rather than on the first query. - Add pg.Migrate, lifting arrproxy's runner verbatim in semantics and generalising the hardcoded advisory-lock key to FNV-1a/64 of a caller-supplied name and the embedded set to an fs.FS. - Add pg/pgtest, unifying the encapi and artifactapi testcontainers helpers, with SkipIfShort so container-backed tests self-skip on the Docker-less Kubernetes runners. - Add the Makefile, README and pre-commit config, plus test, pre-commit and build pipelines on golib-ci.
174 lines
3.9 KiB
Go
174 lines
3.9 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io/fs"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// brokenFS fails every Open, so fs.ReadDir(".") fails.
|
|
type brokenFS struct{}
|
|
|
|
func (brokenFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
|
|
|
|
// missingFileFS lists a migration that cannot then be read, the shape a
|
|
// mis-built embed or a racing file deletion produces.
|
|
type missingFileFS struct{}
|
|
|
|
func (missingFileFS) Open(string) (fs.File, error) { return nil, fs.ErrNotExist }
|
|
|
|
func (missingFileFS) ReadDir(string) ([]fs.DirEntry, error) {
|
|
return []fs.DirEntry{fakeDirEntry{name: "0001_first.sql"}}, nil
|
|
}
|
|
|
|
type fakeDirEntry struct {
|
|
name string
|
|
dir bool
|
|
}
|
|
|
|
func (e fakeDirEntry) Name() string { return e.name }
|
|
func (e fakeDirEntry) IsDir() bool { return e.dir }
|
|
func (e fakeDirEntry) Type() fs.FileMode {
|
|
if e.dir {
|
|
return fs.ModeDir
|
|
}
|
|
return 0
|
|
}
|
|
func (e fakeDirEntry) Info() (fs.FileInfo, error) { return nil, errors.New("no info") }
|
|
|
|
// execCall records one statement the migrator issued.
|
|
type execCall struct {
|
|
sql string
|
|
args []any
|
|
}
|
|
|
|
// fakeSession is a pgx connection that records statements instead of running
|
|
// them. Embedding nothing: it implements the whole session interface.
|
|
type fakeSession struct {
|
|
execs []execCall
|
|
execErrs map[string]error
|
|
|
|
queries []string
|
|
rows *fakeRows
|
|
queryErr error
|
|
|
|
tx *fakeTx
|
|
beginErr error
|
|
}
|
|
|
|
func (s *fakeSession) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
|
s.execs = append(s.execs, execCall{sql: sql, args: args})
|
|
return pgconn.NewCommandTag("SELECT 1"), s.execErrs[sql]
|
|
}
|
|
|
|
func (s *fakeSession) Query(_ context.Context, sql string, _ ...any) (pgx.Rows, error) {
|
|
s.queries = append(s.queries, sql)
|
|
if s.queryErr != nil {
|
|
return nil, s.queryErr
|
|
}
|
|
return s.rows, nil
|
|
}
|
|
|
|
func (s *fakeSession) Begin(context.Context) (pgx.Tx, error) {
|
|
if s.beginErr != nil {
|
|
return nil, s.beginErr
|
|
}
|
|
if s.tx == nil {
|
|
s.tx = &fakeTx{}
|
|
}
|
|
return s.tx, nil
|
|
}
|
|
|
|
// execSQL returns just the statements issued, in order.
|
|
func (s *fakeSession) execSQL() []string {
|
|
out := make([]string, 0, len(s.execs))
|
|
for _, c := range s.execs {
|
|
out = append(out, c.sql)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fakeRows serves a fixed column of strings. The embedded interface supplies
|
|
// the pgx.Rows methods the migrator never calls; calling one panics, which is
|
|
// the intent — it would mean the migrator grew an untested dependency.
|
|
type fakeRows struct {
|
|
pgx.Rows
|
|
|
|
values []string
|
|
i int
|
|
scanErr error
|
|
err error
|
|
closed bool
|
|
}
|
|
|
|
func (r *fakeRows) Next() bool {
|
|
if r.i >= len(r.values) {
|
|
return false
|
|
}
|
|
r.i++
|
|
return true
|
|
}
|
|
|
|
func (r *fakeRows) Scan(dest ...any) error {
|
|
if r.scanErr != nil {
|
|
return r.scanErr
|
|
}
|
|
if len(dest) != 1 {
|
|
return errors.New("expected exactly one scan destination")
|
|
}
|
|
p, ok := dest[0].(*string)
|
|
if !ok {
|
|
return errors.New("expected a *string scan destination")
|
|
}
|
|
*p = r.values[r.i-1]
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeRows) Close() { r.closed = true }
|
|
func (r *fakeRows) Err() error { return r.err }
|
|
|
|
// fakeTx records the statements and the terminal call of a transaction.
|
|
type fakeTx struct {
|
|
pgx.Tx
|
|
|
|
execs []execCall
|
|
execErrs map[string]error
|
|
commitErr error
|
|
committed bool
|
|
rolled int
|
|
}
|
|
|
|
func (t *fakeTx) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
|
t.execs = append(t.execs, execCall{sql: sql, args: args})
|
|
return pgconn.NewCommandTag("INSERT 0 1"), t.execErrs[sql]
|
|
}
|
|
|
|
func (t *fakeTx) Commit(context.Context) error {
|
|
if t.commitErr != nil {
|
|
return t.commitErr
|
|
}
|
|
t.committed = true
|
|
return nil
|
|
}
|
|
|
|
func (t *fakeTx) Rollback(context.Context) error {
|
|
t.rolled++
|
|
if t.committed {
|
|
// What pgx returns for a rollback after a successful commit; the
|
|
// deferred rollback in Apply must tolerate it.
|
|
return pgx.ErrTxClosed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *fakeTx) execSQL() []string {
|
|
out := make([]string, 0, len(t.execs))
|
|
for _, c := range t.execs {
|
|
out = append(out, c.sql)
|
|
}
|
|
return out
|
|
}
|