mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 12:48:00 +00:00
Apply the same lazy-error pattern fix to all backends, plus regression
tests that catch the bug.
pgmigrate is the confirmed-broken case (pgx/v5's Conn.Query is lazy and
surfaces 42P01 at rows.Err() once the prepared statement cache is primed).
The defensive check at rows.Err() is also added to mymigrate and msmigrate
in case their drivers exhibit similar behavior in some configurations.
litemigrate is refactored to probe sqlite_master with errors.Is(sql.ErrNoRows)
instead of string-matching the error message — SQLite returns the generic
SQLITE_ERROR code for "no such table" so a typed-error approach isn't
possible at the driver layer; the probe lets us use idiomatic errors.Is.
Tests:
- litemigrate: in-memory SQLite, runs on every go test (no infra)
- pgmigrate: PG_TEST_URL env-gated; verified against real Postgres,
TestAppliedAfterDropTable reproduces the agent's exact error
message ("reading rows: ... 42P01") without the fix
- mymigrate: MYSQL_TEST_DSN env-gated
- msmigrate: MSSQL_TEST_URL env-gated; verified against real SQL Server
Each backend has four cases: missing table, populated table, empty table,
and table-dropped-after-cache-primed (the lazy-error scenario).
161 lines
4.7 KiB
Go
161 lines
4.7 KiB
Go
package pgmigrate_test
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/therootcompany/golib/database/sqlmigrate/pgmigrate"
|
|
)
|
|
|
|
// connect opens a pgx connection from PG_TEST_URL, skips the test if
|
|
// the env var is unset, and isolates the test in its own schema with
|
|
// automatic cleanup.
|
|
func connect(t *testing.T) *pgx.Conn {
|
|
t.Helper()
|
|
pgURL := os.Getenv("PG_TEST_URL")
|
|
if pgURL == "" {
|
|
t.Skip("PG_TEST_URL not set")
|
|
}
|
|
|
|
ctx := t.Context()
|
|
conn, err := pgx.Connect(ctx, pgURL)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close(ctx) })
|
|
|
|
// Use a per-test schema so concurrent tests don't collide and
|
|
// _migrations is guaranteed not to exist on entry.
|
|
schema := "pgmigrate_test_" + sanitize(t.Name())
|
|
if _, err := conn.Exec(ctx, "DROP SCHEMA IF EXISTS "+schema+" CASCADE"); err != nil {
|
|
t.Fatalf("drop schema: %v", err)
|
|
}
|
|
if _, err := conn.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
|
|
t.Fatalf("create schema: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = conn.Exec(ctx, "DROP SCHEMA IF EXISTS "+schema+" CASCADE")
|
|
})
|
|
if _, err := conn.Exec(ctx, "SET search_path TO "+schema); err != nil {
|
|
t.Fatalf("set search_path: %v", err)
|
|
}
|
|
|
|
return conn
|
|
}
|
|
|
|
// sanitize converts a test name to a valid PostgreSQL identifier suffix.
|
|
func sanitize(s string) string {
|
|
out := make([]byte, 0, len(s))
|
|
for _, c := range []byte(s) {
|
|
switch {
|
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
|
out = append(out, c)
|
|
default:
|
|
out = append(out, '_')
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// TestAppliedNoMigrationsTable is the regression test for the bug where
|
|
// pgx surfaces error 42P01 lazily at rows.Err() rather than at Query().
|
|
// Before the fix, this returned: reading rows: ERROR: relation
|
|
// "_migrations" does not exist (SQLSTATE 42P01).
|
|
func TestAppliedNoMigrationsTable(t *testing.T) {
|
|
conn := connect(t)
|
|
|
|
m := pgmigrate.New(conn)
|
|
applied, err := m.Applied(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Applied() error = %v, want nil", err)
|
|
}
|
|
if len(applied) != 0 {
|
|
t.Errorf("Applied() len = %d, want 0", len(applied))
|
|
}
|
|
}
|
|
|
|
// TestAppliedWithMigrationsTable verifies Applied reads existing rows.
|
|
func TestAppliedWithMigrationsTable(t *testing.T) {
|
|
conn := connect(t)
|
|
ctx := t.Context()
|
|
|
|
if _, err := conn.Exec(ctx, `
|
|
CREATE TABLE _migrations (id TEXT, name TEXT);
|
|
INSERT INTO _migrations (id, name) VALUES ('abc12345', '0001_init');
|
|
INSERT INTO _migrations (id, name) VALUES ('def67890', '0002_users');
|
|
`); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
m := pgmigrate.New(conn)
|
|
applied, err := m.Applied(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Applied() error = %v", err)
|
|
}
|
|
if len(applied) != 2 {
|
|
t.Fatalf("Applied() len = %d, want 2", len(applied))
|
|
}
|
|
if applied[0].Name != "0001_init" || applied[0].ID != "abc12345" {
|
|
t.Errorf("applied[0] = %+v, want {abc12345 0001_init}", applied[0])
|
|
}
|
|
if applied[1].Name != "0002_users" || applied[1].ID != "def67890" {
|
|
t.Errorf("applied[1] = %+v, want {def67890 0002_users}", applied[1])
|
|
}
|
|
}
|
|
|
|
// TestAppliedEmptyMigrationsTable verifies Applied returns an empty
|
|
// slice (not an error) when _migrations exists but has no rows.
|
|
func TestAppliedEmptyMigrationsTable(t *testing.T) {
|
|
conn := connect(t)
|
|
ctx := t.Context()
|
|
|
|
if _, err := conn.Exec(ctx, `CREATE TABLE _migrations (id TEXT, name TEXT)`); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
m := pgmigrate.New(conn)
|
|
applied, err := m.Applied(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Applied() error = %v", err)
|
|
}
|
|
if len(applied) != 0 {
|
|
t.Errorf("Applied() len = %d, want 0", len(applied))
|
|
}
|
|
}
|
|
|
|
// TestAppliedAfterDropTable verifies Applied handles the case where the
|
|
// _migrations table once existed (so pgx may have cached its prepared
|
|
// statement) but has been dropped. This is the scenario most likely to
|
|
// trigger pgx's lazy 42P01 error at rows.Err() rather than at Query().
|
|
func TestAppliedAfterDropTable(t *testing.T) {
|
|
conn := connect(t)
|
|
ctx := t.Context()
|
|
|
|
if _, err := conn.Exec(ctx, `CREATE TABLE _migrations (id TEXT, name TEXT)`); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
m := pgmigrate.New(conn)
|
|
// Prime pgx's prepared-statement cache by calling Applied successfully.
|
|
if _, err := m.Applied(ctx); err != nil {
|
|
t.Fatalf("first Applied: %v", err)
|
|
}
|
|
|
|
// Now drop the table out from under pgx. The cached prepared statement
|
|
// references a relation that no longer exists; the next Applied call
|
|
// must still return (nil, nil), not an error.
|
|
if _, err := conn.Exec(ctx, `DROP TABLE _migrations`); err != nil {
|
|
t.Fatalf("drop: %v", err)
|
|
}
|
|
|
|
applied, err := m.Applied(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Applied() after DROP TABLE error = %v, want nil", err)
|
|
}
|
|
if len(applied) != 0 {
|
|
t.Errorf("Applied() len = %d, want 0", len(applied))
|
|
}
|
|
}
|