diff --git a/database/sqlmigrate/litemigrate/litemigrate.go b/database/sqlmigrate/litemigrate/litemigrate.go index eb33ec4..3f873bd 100644 --- a/database/sqlmigrate/litemigrate/litemigrate.go +++ b/database/sqlmigrate/litemigrate/litemigrate.go @@ -55,10 +55,10 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error { return nil } -// Applied returns the names of 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. -func (m *Migrator) Applied(ctx context.Context) ([]string, error) { - rows, err := m.DB.QueryContext(ctx, "SELECT name FROM _migrations ORDER BY name") +func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) { + rows, err := m.DB.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") if err != nil { // SQLite reports "no such table: _migrations" — stable across versions if strings.Contains(err.Error(), "no such table") { @@ -68,17 +68,17 @@ func (m *Migrator) Applied(ctx context.Context) ([]string, error) { } defer rows.Close() - var names []string + var applied []sqlmigrate.AppliedMigration for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { + var a sqlmigrate.AppliedMigration + if err := rows.Scan(&a.ID, &a.Name); err != nil { return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err) } - names = append(names, name) + applied = append(applied, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) } - return names, nil + return applied, nil } diff --git a/database/sqlmigrate/msmigrate/msmigrate.go b/database/sqlmigrate/msmigrate/msmigrate.go index f1bbc60..606259f 100644 --- a/database/sqlmigrate/msmigrate/msmigrate.go +++ b/database/sqlmigrate/msmigrate/msmigrate.go @@ -55,10 +55,10 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error { return nil } -// Applied returns the names of 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). -func (m *Migrator) Applied(ctx context.Context) ([]string, error) { - rows, err := m.DB.QueryContext(ctx, "SELECT name FROM _migrations ORDER BY name") +func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) { + rows, err := m.DB.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") if err != nil { // SQL Server error 208: "Invalid object name '_migrations'" if msErr, ok := errors.AsType[mssql.Error](err); ok && msErr.Number == 208 { @@ -68,17 +68,17 @@ func (m *Migrator) Applied(ctx context.Context) ([]string, error) { } defer rows.Close() - var names []string + var applied []sqlmigrate.AppliedMigration for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { + var a sqlmigrate.AppliedMigration + if err := rows.Scan(&a.ID, &a.Name); err != nil { return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err) } - names = append(names, name) + applied = append(applied, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) } - return names, nil + return applied, nil } diff --git a/database/sqlmigrate/mymigrate/mymigrate.go b/database/sqlmigrate/mymigrate/mymigrate.go index 9120de7..18744c6 100644 --- a/database/sqlmigrate/mymigrate/mymigrate.go +++ b/database/sqlmigrate/mymigrate/mymigrate.go @@ -58,10 +58,10 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error { return nil } -// Applied returns the names of 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). -func (m *Migrator) Applied(ctx context.Context) ([]string, error) { - rows, err := m.DB.QueryContext(ctx, "SELECT name FROM _migrations ORDER BY name") +func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) { + rows, err := m.DB.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") if err != nil { if mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok && mysqlErr.Number == 1146 { return nil, nil @@ -70,17 +70,17 @@ func (m *Migrator) Applied(ctx context.Context) ([]string, error) { } defer rows.Close() - var names []string + var applied []sqlmigrate.AppliedMigration for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { + var a sqlmigrate.AppliedMigration + if err := rows.Scan(&a.ID, &a.Name); err != nil { return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err) } - names = append(names, name) + applied = append(applied, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) } - return names, nil + return applied, nil } diff --git a/database/sqlmigrate/pgmigrate/pgmigrate.go b/database/sqlmigrate/pgmigrate/pgmigrate.go index 5bd0bec..28a5667 100644 --- a/database/sqlmigrate/pgmigrate/pgmigrate.go +++ b/database/sqlmigrate/pgmigrate/pgmigrate.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" @@ -54,10 +53,10 @@ func (r *Migrator) execInTx(ctx context.Context, sql string) error { return nil } -// Applied returns the names of 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 (PG error 42P01). -func (r *Migrator) Applied(ctx context.Context) ([]string, error) { - rows, err := r.Pool.Query(ctx, "SELECT name FROM _migrations ORDER BY name") +func (r *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) { + rows, err := r.Pool.Query(ctx, "SELECT id, name FROM _migrations ORDER BY name") if err != nil { if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "42P01" { return nil, nil @@ -66,10 +65,17 @@ func (r *Migrator) Applied(ctx context.Context) ([]string, error) { } defer rows.Close() - names, err := pgx.CollectRows(rows, pgx.RowTo[string]) - if err != nil { + var applied []sqlmigrate.AppliedMigration + for rows.Next() { + var a sqlmigrate.AppliedMigration + if err := rows.Scan(&a.ID, &a.Name); err != nil { + return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err) + } + applied = append(applied, a) + } + if err := rows.Err(); err != nil { return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) } - return names, nil + return applied, nil } diff --git a/database/sqlmigrate/shmigrate/shmigrate.go b/database/sqlmigrate/shmigrate/shmigrate.go index aea8f28..0babf64 100644 --- a/database/sqlmigrate/shmigrate/shmigrate.go +++ b/database/sqlmigrate/shmigrate/shmigrate.go @@ -87,10 +87,11 @@ func (r *Migrator) exec(name, suffix, label string) error { return nil } -// Applied reads the migrations log file and returns applied migration names. +// Applied reads the migrations log file and returns applied migrations. +// The log file contains only names (one per line), so IDs will be empty. // Returns an empty slice if the file does not exist. When FS is set, reads // from that filesystem; otherwise reads from the OS filesystem. -func (r *Migrator) Applied(ctx context.Context) ([]string, error) { +func (r *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) { var f io.ReadCloser var err error if r.FS != nil { @@ -106,7 +107,7 @@ func (r *Migrator) Applied(ctx context.Context) ([]string, error) { } defer f.Close() - var names []string + var applied []sqlmigrate.AppliedMigration scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -115,11 +116,11 @@ func (r *Migrator) Applied(ctx context.Context) ([]string, error) { line = strings.TrimSpace(line[:idx]) } if line != "" { - names = append(names, line) + applied = append(applied, sqlmigrate.AppliedMigration{Name: line}) } } - return names, nil + return applied, nil } // Reset resets the migration counter. Call between Up and Down diff --git a/database/sqlmigrate/sqlmigrate.go b/database/sqlmigrate/sqlmigrate.go index ac8ff02..c5f7a14 100644 --- a/database/sqlmigrate/sqlmigrate.go +++ b/database/sqlmigrate/sqlmigrate.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io/fs" + "regexp" "slices" "strings" ) @@ -29,10 +30,17 @@ var ( // Migration represents a paired up/down migration. type Migration struct { Name string // e.g. "2026-04-05-001000_create-todos" + ID string // 8-char hex from INSERT INTO _migrations, parsed by Collect Up string // SQL content of the .up.sql file Down string // SQL content of the .down.sql file } +// AppliedMigration represents a migration recorded in the _migrations table. +type AppliedMigration struct { + ID string + Name string +} + // Status represents the current migration state. type Status struct { Applied []string @@ -50,14 +58,22 @@ type Migrator interface { // ExecDown runs the down migration. ExecDown(ctx context.Context, m Migration) error - // Applied returns the names of all applied migrations, sorted - // lexicographically. Returns an empty slice (not an error) if the - // migrations table or log does not exist yet. - Applied(ctx context.Context) ([]string, error) + // Applied returns all applied migrations from the _migrations table, + // sorted lexicographically by name. Returns an empty slice (not an + // error) if the migrations table or log does not exist yet. + Applied(ctx context.Context) ([]AppliedMigration, error) } +// idFromInsert extracts the hex ID from an INSERT INTO _migrations line. +// Matches: INSERT INTO _migrations (name, id) VALUES ('...', ''); +var idFromInsert = regexp.MustCompile( + `(?i)INSERT\s+INTO\s+_migrations\s*\(\s*name\s*,\s*id\s*\)\s*VALUES\s*\(\s*'[^']*'\s*,\s*'([0-9a-fA-F]+)'\s*\)`, +) + // Collect reads .up.sql and .down.sql files from fsys, pairs them by // basename, and returns them sorted lexicographically by name. +// If the up SQL contains an INSERT INTO _migrations line, the hex ID +// is extracted and stored in Migration.ID. func Collect(fsys fs.FS) ([]Migration, error) { ups := map[string]string{} downs := map[string]string{} @@ -100,8 +116,13 @@ func Collect(fsys fs.FS) ([]Migration, error) { if !ok { return nil, fmt.Errorf("%w: %s", ErrMissingDown, name) } + var id string + if m := idFromInsert.FindStringSubmatch(upSQL); m != nil { + id = m[1] + } migrations = append(migrations, Migration{ Name: name, + ID: id, Up: upSQL, Down: downSQL, }) @@ -130,17 +151,43 @@ func NamesOnly(names []string) []Migration { return migrations } +// isApplied returns true if the migration matches any applied entry by name or ID. +func isApplied(m Migration, applied []AppliedMigration) bool { + for _, a := range applied { + if a.Name == m.Name { + return true + } + if m.ID != "" && a.ID != "" && a.ID == m.ID { + return true + } + } + return false +} + +// findMigration looks up a migration by the applied entry's name or ID. +func findMigration(a AppliedMigration, byName map[string]Migration, byID map[string]Migration) (Migration, bool) { + if m, ok := byName[a.Name]; ok { + return m, true + } + if a.ID != "" { + if m, ok := byID[a.ID]; ok { + return m, true + } + } + return Migration{}, false +} + // Up applies up to n pending migrations using the given Runner. // If n <= 0, applies all pending. Returns the names of applied migrations. func Up(ctx context.Context, r Migrator, migrations []Migration, n int) ([]string, error) { - appliedNames, err := r.Applied(ctx) + applied, err := r.Applied(ctx) if err != nil { return nil, err } var pending []Migration for _, m := range migrations { - if !slices.Contains(appliedNames, m.Name) { + if !isApplied(m, applied) { pending = append(pending, m) } } @@ -166,18 +213,22 @@ func Up(ctx context.Context, r Migrator, migrations []Migration, n int) ([]strin // Down rolls back up to n applied migrations, most recent first. // If n <= 0, rolls back all applied. Returns the names of rolled-back migrations. func Down(ctx context.Context, r Migrator, migrations []Migration, n int) ([]string, error) { - appliedNames, err := r.Applied(ctx) + applied, err := r.Applied(ctx) if err != nil { return nil, err } byName := map[string]Migration{} + byID := map[string]Migration{} for _, m := range migrations { byName[m.Name] = m + if m.ID != "" { + byID[m.ID] = m + } } - reversed := make([]string, len(appliedNames)) - copy(reversed, appliedNames) + reversed := make([]AppliedMigration, len(applied)) + copy(reversed, applied) slices.Reverse(reversed) if n <= 0 || n > len(reversed) { @@ -185,15 +236,15 @@ func Down(ctx context.Context, r Migrator, migrations []Migration, n int) ([]str } var ran []string - for _, name := range reversed[:n] { - m, ok := byName[name] + for _, a := range reversed[:n] { + m, ok := findMigration(a, byName, byID) if !ok { - return ran, fmt.Errorf("%w: %s", ErrMissingDown, name) + return ran, fmt.Errorf("%w: %s", ErrMissingDown, a.Name) } if err := r.ExecDown(ctx, m); err != nil { - return ran, fmt.Errorf("%s (down): %w", name, err) + return ran, fmt.Errorf("%s (down): %w", a.Name, err) } - ran = append(ran, name) + ran = append(ran, a.Name) } return ran, nil @@ -201,14 +252,19 @@ func Down(ctx context.Context, r Migrator, migrations []Migration, n int) ([]str // GetStatus returns applied and pending migration lists. func GetStatus(ctx context.Context, r Migrator, migrations []Migration) (*Status, error) { - appliedNames, err := r.Applied(ctx) + applied, err := r.Applied(ctx) if err != nil { return nil, err } + appliedNames := make([]string, len(applied)) + for i, a := range applied { + appliedNames[i] = a.Name + } + var pending []string for _, m := range migrations { - if !slices.Contains(appliedNames, m.Name) { + if !isApplied(m, applied) { pending = append(pending, m.Name) } } diff --git a/database/sqlmigrate/sqlmigrate_test.go b/database/sqlmigrate/sqlmigrate_test.go index 83f3792..5111cf0 100644 --- a/database/sqlmigrate/sqlmigrate_test.go +++ b/database/sqlmigrate/sqlmigrate_test.go @@ -4,15 +4,25 @@ import ( "context" "errors" "slices" + "strings" "testing" "testing/fstest" "github.com/therootcompany/golib/database/sqlmigrate" ) +// applied builds an []AppliedMigration from names (IDs empty). +func applied(names ...string) []sqlmigrate.AppliedMigration { + out := make([]sqlmigrate.AppliedMigration, len(names)) + for i, n := range names { + out[i] = sqlmigrate.AppliedMigration{Name: n} + } + return out +} + // mockMigrator tracks applied migrations in memory. type mockMigrator struct { - applied []string + applied []sqlmigrate.AppliedMigration execErr error // if set, ExecUp/ExecDown return this on every call upCalls []string downCalls []string @@ -23,8 +33,10 @@ func (m *mockMigrator) ExecUp(_ context.Context, mig sqlmigrate.Migration) error if m.execErr != nil { return m.execErr } - m.applied = append(m.applied, mig.Name) - slices.Sort(m.applied) + m.applied = append(m.applied, sqlmigrate.AppliedMigration{Name: mig.Name, ID: mig.ID}) + slices.SortFunc(m.applied, func(a, b sqlmigrate.AppliedMigration) int { + return strings.Compare(a.Name, b.Name) + }) return nil } @@ -33,11 +45,11 @@ func (m *mockMigrator) ExecDown(_ context.Context, mig sqlmigrate.Migration) err if m.execErr != nil { return m.execErr } - m.applied = slices.DeleteFunc(m.applied, func(s string) bool { return s == mig.Name }) + m.applied = slices.DeleteFunc(m.applied, func(a sqlmigrate.AppliedMigration) bool { return a.Name == mig.Name }) return nil } -func (m *mockMigrator) Applied(_ context.Context) ([]string, error) { +func (m *mockMigrator) Applied(_ context.Context) ([]sqlmigrate.AppliedMigration, error) { return slices.Clone(m.applied), nil } @@ -72,6 +84,34 @@ func TestCollect(t *testing.T) { } }) + t.Run("parses ID from INSERT", func(t *testing.T) { + fsys := fstest.MapFS{ + "001_init.up.sql": {Data: []byte("CREATE TABLE a;\nINSERT INTO _migrations (name, id) VALUES ('001_init', 'abcd1234');")}, + "001_init.down.sql": {Data: []byte("DROP TABLE a;\nDELETE FROM _migrations WHERE id = 'abcd1234';")}, + } + migrations, err := sqlmigrate.Collect(fsys) + if err != nil { + t.Fatal(err) + } + if migrations[0].ID != "abcd1234" { + t.Errorf("ID = %q, want %q", migrations[0].ID, "abcd1234") + } + }) + + t.Run("no ID when no INSERT", func(t *testing.T) { + fsys := fstest.MapFS{ + "001_init.up.sql": {Data: []byte("CREATE TABLE a;")}, + "001_init.down.sql": {Data: []byte("DROP TABLE a;")}, + } + migrations, err := sqlmigrate.Collect(fsys) + if err != nil { + t.Fatal(err) + } + if migrations[0].ID != "" { + t.Errorf("ID = %q, want empty", migrations[0].ID) + } + }) + t.Run("missing down", func(t *testing.T) { fsys := fstest.MapFS{ "001_only-up.up.sql": {Data: []byte("CREATE TABLE x;")}, @@ -150,86 +190,93 @@ func TestUp(t *testing.T) { t.Run("apply all", func(t *testing.T) { m := &mockMigrator{} - applied, err := sqlmigrate.Up(ctx, m, migrations, 0) + ran, err := sqlmigrate.Up(ctx, m, migrations, 0) if err != nil { t.Fatal(err) } - if len(applied) != 3 { - t.Fatalf("applied %d, want 3", len(applied)) - } - if !slices.Equal(applied, []string{"001_init", "002_users", "003_posts"}) { - t.Errorf("applied = %v", applied) + if !slices.Equal(ran, []string{"001_init", "002_users", "003_posts"}) { + t.Errorf("applied = %v", ran) } }) t.Run("apply n", func(t *testing.T) { m := &mockMigrator{} - applied, err := sqlmigrate.Up(ctx, m, migrations, 2) + ran, err := sqlmigrate.Up(ctx, m, migrations, 2) if err != nil { t.Fatal(err) } - if len(applied) != 2 { - t.Fatalf("applied %d, want 2", len(applied)) - } - if applied[0] != "001_init" || applied[1] != "002_users" { - t.Errorf("applied = %v", applied) + if !slices.Equal(ran, []string{"001_init", "002_users"}) { + t.Errorf("applied = %v", ran) } }) t.Run("none pending", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init", "002_users", "003_posts"}} - applied, err := sqlmigrate.Up(ctx, m, migrations, 0) + m := &mockMigrator{applied: applied("001_init", "002_users", "003_posts")} + ran, err := sqlmigrate.Up(ctx, m, migrations, 0) if err != nil { t.Fatal(err) } - if len(applied) != 0 { - t.Fatalf("applied %d, want 0", len(applied)) + if len(ran) != 0 { + t.Fatalf("applied %d, want 0", len(ran)) } }) t.Run("partial pending", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init"}} - applied, err := sqlmigrate.Up(ctx, m, migrations, 0) + m := &mockMigrator{applied: applied("001_init")} + ran, err := sqlmigrate.Up(ctx, m, migrations, 0) if err != nil { t.Fatal(err) } - if !slices.Equal(applied, []string{"002_users", "003_posts"}) { - t.Errorf("applied = %v", applied) + if !slices.Equal(ran, []string{"002_users", "003_posts"}) { + t.Errorf("applied = %v", ran) } }) t.Run("n exceeds pending", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init"}} - applied, err := sqlmigrate.Up(ctx, m, migrations, 99) + m := &mockMigrator{applied: applied("001_init")} + ran, err := sqlmigrate.Up(ctx, m, migrations, 99) if err != nil { t.Fatal(err) } - if len(applied) != 2 { - t.Fatalf("applied %d, want 2", len(applied)) + if len(ran) != 2 { + t.Fatalf("applied %d, want 2", len(ran)) } }) t.Run("exec error stops and returns partial", func(t *testing.T) { - callCount := 0 - m := &mockMigrator{} - // fail on second call - origExecUp := m.ExecUp - _ = origExecUp - m2 := &failOnNthMigrator{failAt: 1} - applied, err := sqlmigrate.Up(ctx, m2, migrations, 0) + m := &failOnNthMigrator{failAt: 1} + ran, err := sqlmigrate.Up(ctx, m, migrations, 0) if err == nil { t.Fatal("expected error") } - if len(applied) != 1 { - t.Errorf("applied %d before error, want 1", len(applied)) + if len(ran) != 1 { + t.Errorf("applied %d before error, want 1", len(ran)) + } + }) + + t.Run("skips migration matched by ID", func(t *testing.T) { + // DB has migration applied under old name, but same ID + m := &mockMigrator{applied: []sqlmigrate.AppliedMigration{ + {Name: "001_old-name", ID: "aa11bb22"}, + }} + migs := []sqlmigrate.Migration{ + {Name: "001_new-name", ID: "aa11bb22", Up: "CREATE TABLE a;", Down: "DROP TABLE a;"}, + {Name: "002_users", Up: "CREATE TABLE b;", Down: "DROP TABLE b;"}, + } + ran, err := sqlmigrate.Up(ctx, m, migs, 0) + if err != nil { + t.Fatal(err) + } + // Only 002_users should be applied; 001 is matched by ID + if !slices.Equal(ran, []string{"002_users"}) { + t.Errorf("applied = %v, want [002_users]", ran) } - _ = callCount }) } // failOnNthMigrator fails on the Nth ExecUp call (0-indexed). type failOnNthMigrator struct { - applied []string + applied []sqlmigrate.AppliedMigration calls int failAt int } @@ -240,17 +287,19 @@ func (m *failOnNthMigrator) ExecUp(_ context.Context, mig sqlmigrate.Migration) return errors.New("connection lost") } m.calls++ - m.applied = append(m.applied, mig.Name) - slices.Sort(m.applied) + m.applied = append(m.applied, sqlmigrate.AppliedMigration{Name: mig.Name, ID: mig.ID}) + slices.SortFunc(m.applied, func(a, b sqlmigrate.AppliedMigration) int { + return strings.Compare(a.Name, b.Name) + }) return nil } func (m *failOnNthMigrator) ExecDown(_ context.Context, mig sqlmigrate.Migration) error { - m.applied = slices.DeleteFunc(m.applied, func(s string) bool { return s == mig.Name }) + m.applied = slices.DeleteFunc(m.applied, func(a sqlmigrate.AppliedMigration) bool { return a.Name == mig.Name }) return nil } -func (m *failOnNthMigrator) Applied(_ context.Context) ([]string, error) { +func (m *failOnNthMigrator) Applied(_ context.Context) ([]sqlmigrate.AppliedMigration, error) { return slices.Clone(m.applied), nil } @@ -265,7 +314,7 @@ func TestDown(t *testing.T) { } t.Run("rollback all", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init", "002_users", "003_posts"}} + m := &mockMigrator{applied: applied("001_init", "002_users", "003_posts")} rolled, err := sqlmigrate.Down(ctx, m, migrations, 0) if err != nil { t.Fatal(err) @@ -273,7 +322,6 @@ func TestDown(t *testing.T) { if len(rolled) != 3 { t.Fatalf("rolled %d, want 3", len(rolled)) } - // most recent first if rolled[0] != "003_posts" { t.Errorf("first rollback = %q, want 003_posts", rolled[0]) } @@ -283,7 +331,7 @@ func TestDown(t *testing.T) { }) t.Run("rollback n", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init", "002_users", "003_posts"}} + m := &mockMigrator{applied: applied("001_init", "002_users", "003_posts")} rolled, err := sqlmigrate.Down(ctx, m, migrations, 2) if err != nil { t.Fatal(err) @@ -306,7 +354,7 @@ func TestDown(t *testing.T) { t.Run("exec error", func(t *testing.T) { m := &mockMigrator{ - applied: []string{"001_init", "002_users"}, + applied: applied("001_init", "002_users"), execErr: errors.New("permission denied"), } rolled, err := sqlmigrate.Down(ctx, m, migrations, 1) @@ -319,7 +367,7 @@ func TestDown(t *testing.T) { }) t.Run("unknown migration in applied", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init", "999_unknown"}} + m := &mockMigrator{applied: applied("001_init", "999_unknown")} _, err := sqlmigrate.Down(ctx, m, migrations, 0) if !errors.Is(err, sqlmigrate.ErrMissingDown) { t.Errorf("got %v, want ErrMissingDown", err) @@ -327,7 +375,7 @@ func TestDown(t *testing.T) { }) t.Run("n exceeds applied", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init"}} + m := &mockMigrator{applied: applied("001_init")} rolled, err := sqlmigrate.Down(ctx, m, migrations, 99) if err != nil { t.Fatal(err) @@ -336,6 +384,23 @@ func TestDown(t *testing.T) { t.Fatalf("rolled %d, want 1", len(rolled)) } }) + + t.Run("finds migration by ID when name changed", func(t *testing.T) { + // DB has old name, file has new name, same ID + m := &mockMigrator{applied: []sqlmigrate.AppliedMigration{ + {Name: "001_old-name", ID: "aa11bb22"}, + }} + migs := []sqlmigrate.Migration{ + {Name: "001_new-name", ID: "aa11bb22", Up: "CREATE TABLE a;", Down: "DROP TABLE a;"}, + } + rolled, err := sqlmigrate.Down(ctx, m, migs, 1) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(rolled, []string{"001_old-name"}) { + t.Errorf("rolled = %v, want [001_old-name]", rolled) + } + }) } // --- GetStatus --- @@ -363,7 +428,7 @@ func TestGetStatus(t *testing.T) { }) t.Run("partial", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init"}} + m := &mockMigrator{applied: applied("001_init")} status, err := sqlmigrate.GetStatus(ctx, m, migrations) if err != nil { t.Fatal(err) @@ -377,7 +442,7 @@ func TestGetStatus(t *testing.T) { }) t.Run("all applied", func(t *testing.T) { - m := &mockMigrator{applied: []string{"001_init", "002_users", "003_posts"}} + m := &mockMigrator{applied: applied("001_init", "002_users", "003_posts")} status, err := sqlmigrate.GetStatus(ctx, m, migrations) if err != nil { t.Fatal(err)