fix(sqlmigrate): defensive table-missing check at rows.Err() across backends

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).
This commit is contained in:
AJ ONeal 2026-04-10 00:15:06 -06:00
parent e11b228765
commit 3402b60bc6
No known key found for this signature in database
10 changed files with 717 additions and 10 deletions

7
.gitignore vendored
View File

@ -36,3 +36,10 @@ io/transform/gsheet2csv/cmd/gsheet2tsv/gsheet2tsv
# vendor/ # vendor/
.DS_Store .DS_Store
# Agent session files
agents/TASKS.md
agents/REVIEW.md
agents/HANDOFF.md
agents/issues.d/
agents/tmp/

View File

@ -2,4 +2,19 @@ module github.com/therootcompany/golib/database/sqlmigrate/litemigrate
go 1.26.1 go 1.26.1
require github.com/therootcompany/golib/database/sqlmigrate v1.0.2 require (
github.com/therootcompany/golib/database/sqlmigrate v1.0.2
modernc.org/sqlite v1.48.2
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

View File

@ -1,2 +1,53 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/therootcompany/golib/database/sqlmigrate v1.0.2 h1:hcmhYyUFVj/GqyChP+0Ry2WZCHnoruFMbsy+2KVzsfA= github.com/therootcompany/golib/database/sqlmigrate v1.0.2 h1:hcmhYyUFVj/GqyChP+0Ry2WZCHnoruFMbsy+2KVzsfA=
github.com/therootcompany/golib/database/sqlmigrate v1.0.2/go.mod h1:7PQUjwT78Hx+SftcIKI2PH4zSFlrSO0V9h618PJqC38= github.com/therootcompany/golib/database/sqlmigrate v1.0.2/go.mod h1:7PQUjwT78Hx+SftcIKI2PH4zSFlrSO0V9h618PJqC38=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -13,8 +13,8 @@ package litemigrate
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings"
"github.com/therootcompany/golib/database/sqlmigrate" "github.com/therootcompany/golib/database/sqlmigrate"
) )
@ -62,16 +62,30 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error {
// Applied returns all applied migrations from the _migrations table. // Applied returns all applied migrations from the _migrations table.
// Returns an empty slice if the table does not exist. // Returns an empty slice if the table does not exist.
//
// We probe sqlite_master first rather than catching the SELECT error.
// SQLite returns the generic SQLITE_ERROR code (1) for "no such table",
// which is too coarse to distinguish from other errors via the typed
// driver error. The probe lets us use errors.Is(sql.ErrNoRows) instead
// of string-matching the error message.
func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error) { func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error) {
rows, err := m.Conn.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") var name string
if err != nil { err := m.Conn.QueryRowContext(
// SQLite reports "no such table: _migrations" — stable across versions ctx,
if strings.Contains(err.Error(), "no such table") { "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_migrations'",
).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
if err != nil {
return nil, fmt.Errorf("%w: probing sqlite_master: %w", sqlmigrate.ErrQueryApplied, err)
}
rows, err := m.Conn.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name")
if err != nil {
return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err) return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var applied []sqlmigrate.Migration var applied []sqlmigrate.Migration
for rows.Next() { for rows.Next() {

View File

@ -0,0 +1,124 @@
package litemigrate_test
import (
"database/sql"
"testing"
_ "modernc.org/sqlite"
"github.com/therootcompany/golib/database/sqlmigrate/litemigrate"
)
// openMem opens a fresh in-memory SQLite database and returns the conn.
// The cleanup closes both the conn and the underlying *sql.DB.
func openMem(t *testing.T) *sql.Conn {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conn, err := db.Conn(t.Context())
if err != nil {
t.Fatalf("conn: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// TestAppliedNoMigrationsTable verifies Applied returns (nil, nil) when
// the _migrations table does not exist. Regression test for the
// table-missing handling — caught a class of bugs where the error type
// or message changes between SQLite driver versions.
func TestAppliedNoMigrationsTable(t *testing.T) {
conn := openMem(t)
m := litemigrate.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 := openMem(t)
ctx := t.Context()
if _, err := conn.ExecContext(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 := litemigrate.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 := openMem(t)
ctx := t.Context()
if _, err := conn.ExecContext(ctx, `CREATE TABLE _migrations (id TEXT, name TEXT)`); err != nil {
t.Fatalf("setup: %v", err)
}
m := litemigrate.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 still returns (nil, nil) after
// the _migrations table is dropped — exercises any prepared-statement
// caching the driver may do.
func TestAppliedAfterDropTable(t *testing.T) {
conn := openMem(t)
ctx := t.Context()
if _, err := conn.ExecContext(ctx, `CREATE TABLE _migrations (id TEXT, name TEXT)`); err != nil {
t.Fatalf("create: %v", err)
}
m := litemigrate.New(conn)
if _, err := m.Applied(ctx); err != nil {
t.Fatalf("first Applied: %v", err)
}
if _, err := conn.ExecContext(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))
}
}

View File

@ -59,11 +59,13 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error {
// Applied returns all applied migrations from the _migrations table. // Applied returns all applied migrations from the _migrations table.
// Returns an empty slice if the table does not exist (SQL Server error 208). // Returns an empty slice if the table does not exist (SQL Server error 208).
//
// The table-missing check is applied at both Query and rows.Err — some
// drivers may surface the error lazily after iteration begins.
func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error) { func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error) {
rows, err := m.Conn.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") rows, err := m.Conn.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name")
if err != nil { if err != nil {
// SQL Server error 208: "Invalid object name '_migrations'" if isUndefinedTable(err) {
if msErr, ok := errors.AsType[mssql.Error](err); ok && msErr.Number == 208 {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err) return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err)
@ -79,8 +81,19 @@ func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error)
applied = append(applied, a) applied = append(applied, a)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
if isUndefinedTable(err) {
return nil, nil
}
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
} }
return applied, nil return applied, nil
} }
// isUndefinedTable reports whether err is SQL Server error 208
// ("Invalid object name '_migrations'"), which is what we get when
// _migrations doesn't exist yet.
func isUndefinedTable(err error) bool {
msErr, ok := errors.AsType[mssql.Error](err)
return ok && msErr.Number == 208
}

View File

@ -0,0 +1,145 @@
package msmigrate_test
import (
"database/sql"
"os"
"testing"
_ "github.com/microsoft/go-mssqldb"
"github.com/therootcompany/golib/database/sqlmigrate/msmigrate"
)
// connect opens a *sql.Conn from MSSQL_TEST_URL, skips the test if the
// env var is unset, and ensures _migrations does not exist on entry,
// with cleanup on exit.
//
// Note: SQL Server does not have per-connection search_path. Tests run
// against the user's default schema and clean up _migrations directly,
// rather than using a per-test schema.
func connect(t *testing.T) *sql.Conn {
t.Helper()
url := os.Getenv("MSSQL_TEST_URL")
if url == "" {
t.Skip("MSSQL_TEST_URL not set")
}
ctx := t.Context()
db, err := sql.Open("sqlserver", url)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conn, err := db.Conn(ctx)
if err != nil {
t.Fatalf("conn: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if _, err := conn.ExecContext(ctx, "DROP TABLE IF EXISTS _migrations"); err != nil {
t.Fatalf("pre-cleanup _migrations: %v", err)
}
t.Cleanup(func() {
_, _ = conn.ExecContext(ctx, "DROP TABLE IF EXISTS _migrations")
})
return conn
}
// TestAppliedNoMigrationsTable verifies Applied returns (nil, nil) when
// the _migrations table does not exist (SQL Server error 208). Defensive
// regression test against drivers that may surface the error lazily.
func TestAppliedNoMigrationsTable(t *testing.T) {
conn := connect(t)
m := msmigrate.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.ExecContext(ctx, `
CREATE TABLE _migrations (id NVARCHAR(16), name NVARCHAR(255))
`); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := conn.ExecContext(ctx,
`INSERT INTO _migrations (id, name) VALUES ('abc12345', '0001_init'), ('def67890', '0002_users')`,
); err != nil {
t.Fatalf("insert: %v", err)
}
m := msmigrate.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.ExecContext(ctx, `CREATE TABLE _migrations (id NVARCHAR(16), name NVARCHAR(255))`); err != nil {
t.Fatalf("create: %v", err)
}
m := msmigrate.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 still returns (nil, nil)
// after the _migrations table is dropped — exercises any prepared-
// statement caching the driver may do.
func TestAppliedAfterDropTable(t *testing.T) {
conn := connect(t)
ctx := t.Context()
if _, err := conn.ExecContext(ctx, `CREATE TABLE _migrations (id NVARCHAR(16), name NVARCHAR(255))`); err != nil {
t.Fatalf("create: %v", err)
}
m := msmigrate.New(conn)
if _, err := m.Applied(ctx); err != nil {
t.Fatalf("first Applied: %v", err)
}
if _, err := conn.ExecContext(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))
}
}

View File

@ -87,10 +87,13 @@ func (m *Migrator) exec(ctx context.Context, sqlStr string) error {
// Applied returns all applied migrations from the _migrations table. // Applied returns all applied migrations from the _migrations table.
// Returns an empty slice if the table does not exist (MySQL error 1146). // Returns an empty slice if the table does not exist (MySQL error 1146).
//
// The table-missing check is applied at both Query and rows.Err — some
// drivers may surface the error lazily after iteration begins.
func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error) { func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error) {
rows, err := m.Conn.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") rows, err := m.Conn.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name")
if err != nil { if err != nil {
if mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok && mysqlErr.Number == 1146 { if isUndefinedTable(err) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err) return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err)
@ -106,8 +109,18 @@ func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.Migration, error)
applied = append(applied, a) applied = append(applied, a)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
if isUndefinedTable(err) {
return nil, nil
}
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
} }
return applied, nil return applied, nil
} }
// isUndefinedTable reports whether err is MySQL error 1146 (table doesn't exist),
// which is what we get when _migrations doesn't exist yet.
func isUndefinedTable(err error) bool {
mysqlErr, ok := errors.AsType[*mysql.MySQLError](err)
return ok && mysqlErr.Number == 1146
}

View File

@ -0,0 +1,165 @@
package mymigrate_test
import (
"database/sql"
"os"
"testing"
_ "github.com/go-sql-driver/mysql"
"github.com/therootcompany/golib/database/sqlmigrate/mymigrate"
)
// connect opens a *sql.Conn from MYSQL_TEST_DSN, skips the test if the
// env var is unset, and isolates the test in its own database with
// automatic cleanup.
func connect(t *testing.T) *sql.Conn {
t.Helper()
dsn := os.Getenv("MYSQL_TEST_DSN")
if dsn == "" {
t.Skip("MYSQL_TEST_DSN not set")
}
ctx := t.Context()
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Use a per-test database so concurrent tests don't collide and
// _migrations is guaranteed not to exist on entry.
dbName := "mymigrate_test_" + sanitize(t.Name())
if _, err := db.ExecContext(ctx, "DROP DATABASE IF EXISTS "+dbName); err != nil {
t.Fatalf("drop database: %v", err)
}
if _, err := db.ExecContext(ctx, "CREATE DATABASE "+dbName); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(ctx, "DROP DATABASE IF EXISTS "+dbName)
})
conn, err := db.Conn(ctx)
if err != nil {
t.Fatalf("conn: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if _, err := conn.ExecContext(ctx, "USE "+dbName); err != nil {
t.Fatalf("use database: %v", err)
}
return conn
}
// sanitize converts a test name to a valid MySQL 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 verifies Applied returns (nil, nil) when
// the _migrations table does not exist (MySQL error 1146). Defensive
// regression test against drivers that may surface the error lazily.
func TestAppliedNoMigrationsTable(t *testing.T) {
conn := connect(t)
m := mymigrate.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.ExecContext(ctx, `
CREATE TABLE _migrations (id VARCHAR(16), name VARCHAR(255))
`); err != nil {
t.Fatalf("create table: %v", err)
}
if _, err := conn.ExecContext(ctx,
`INSERT INTO _migrations (id, name) VALUES ('abc12345', '0001_init'), ('def67890', '0002_users')`,
); err != nil {
t.Fatalf("insert: %v", err)
}
m := mymigrate.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.ExecContext(ctx, `CREATE TABLE _migrations (id VARCHAR(16), name VARCHAR(255))`); err != nil {
t.Fatalf("create table: %v", err)
}
m := mymigrate.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 still returns (nil, nil) after
// the _migrations table is dropped — exercises any prepared-statement
// caching the driver may do.
func TestAppliedAfterDropTable(t *testing.T) {
conn := connect(t)
ctx := t.Context()
if _, err := conn.ExecContext(ctx, `CREATE TABLE _migrations (id VARCHAR(16), name VARCHAR(255))`); err != nil {
t.Fatalf("create: %v", err)
}
m := mymigrate.New(conn)
if _, err := m.Applied(ctx); err != nil {
t.Fatalf("first Applied: %v", err)
}
if _, err := conn.ExecContext(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))
}
}

View File

@ -0,0 +1,160 @@
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))
}
}