mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
feat(sqlmigrate): match applied migrations by name or ID
Add ID field to Migration (parsed from INSERT INTO _migrations in Collect), AppliedMigration type with ID+Name, and update Migrator.Applied() to return []AppliedMigration across all 5 backends. Matching in Up/Down/GetStatus now checks both name and ID, so renamed migration files are still recognized as applied if the 8-char hex ID matches.
This commit is contained in:
parent
657b899f1a
commit
d0d0b11dc2
@ -55,10 +55,10 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error {
|
|||||||
return nil
|
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.
|
// Returns an empty slice if the table does not exist.
|
||||||
func (m *Migrator) Applied(ctx context.Context) ([]string, error) {
|
func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) {
|
||||||
rows, err := m.DB.QueryContext(ctx, "SELECT name FROM _migrations ORDER BY name")
|
rows, err := m.DB.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// SQLite reports "no such table: _migrations" — stable across versions
|
// SQLite reports "no such table: _migrations" — stable across versions
|
||||||
if strings.Contains(err.Error(), "no such table") {
|
if strings.Contains(err.Error(), "no such table") {
|
||||||
@ -68,17 +68,17 @@ func (m *Migrator) Applied(ctx context.Context) ([]string, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var names []string
|
var applied []sqlmigrate.AppliedMigration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var a sqlmigrate.AppliedMigration
|
||||||
if err := rows.Scan(&name); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name); err != nil {
|
||||||
return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return names, nil
|
return applied, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,10 +55,10 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error {
|
|||||||
return nil
|
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).
|
// Returns an empty slice if the table does not exist (SQL Server error 208).
|
||||||
func (m *Migrator) Applied(ctx context.Context) ([]string, error) {
|
func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) {
|
||||||
rows, err := m.DB.QueryContext(ctx, "SELECT name FROM _migrations ORDER BY name")
|
rows, err := m.DB.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// SQL Server error 208: "Invalid object name '_migrations'"
|
// SQL Server error 208: "Invalid object name '_migrations'"
|
||||||
if msErr, ok := errors.AsType[mssql.Error](err); ok && msErr.Number == 208 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
var names []string
|
var applied []sqlmigrate.AppliedMigration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var a sqlmigrate.AppliedMigration
|
||||||
if err := rows.Scan(&name); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name); err != nil {
|
||||||
return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return names, nil
|
return applied, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,10 +58,10 @@ func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error {
|
|||||||
return nil
|
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).
|
// Returns an empty slice if the table does not exist (MySQL error 1146).
|
||||||
func (m *Migrator) Applied(ctx context.Context) ([]string, error) {
|
func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) {
|
||||||
rows, err := m.DB.QueryContext(ctx, "SELECT name FROM _migrations ORDER BY name")
|
rows, err := m.DB.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 mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok && mysqlErr.Number == 1146 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -70,17 +70,17 @@ func (m *Migrator) Applied(ctx context.Context) ([]string, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var names []string
|
var applied []sqlmigrate.AppliedMigration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var a sqlmigrate.AppliedMigration
|
||||||
if err := rows.Scan(&name); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name); err != nil {
|
||||||
return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return names, nil
|
return applied, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
@ -54,10 +53,10 @@ func (r *Migrator) execInTx(ctx context.Context, sql string) error {
|
|||||||
return nil
|
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).
|
// Returns an empty slice if the table does not exist (PG error 42P01).
|
||||||
func (r *Migrator) Applied(ctx context.Context) ([]string, error) {
|
func (r *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) {
|
||||||
rows, err := r.Pool.Query(ctx, "SELECT name FROM _migrations ORDER BY name")
|
rows, err := r.Pool.Query(ctx, "SELECT id, name FROM _migrations ORDER BY name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "42P01" {
|
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "42P01" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -66,10 +65,17 @@ func (r *Migrator) Applied(ctx context.Context) ([]string, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
names, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
var applied []sqlmigrate.AppliedMigration
|
||||||
if err != nil {
|
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 nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return names, nil
|
return applied, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,10 +87,11 @@ func (r *Migrator) exec(name, suffix, label string) error {
|
|||||||
return nil
|
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
|
// Returns an empty slice if the file does not exist. When FS is set, reads
|
||||||
// from that filesystem; otherwise reads from the OS filesystem.
|
// 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 f io.ReadCloser
|
||||||
var err error
|
var err error
|
||||||
if r.FS != nil {
|
if r.FS != nil {
|
||||||
@ -106,7 +107,7 @@ func (r *Migrator) Applied(ctx context.Context) ([]string, error) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
var names []string
|
var applied []sqlmigrate.AppliedMigration
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
@ -115,11 +116,11 @@ func (r *Migrator) Applied(ctx context.Context) ([]string, error) {
|
|||||||
line = strings.TrimSpace(line[:idx])
|
line = strings.TrimSpace(line[:idx])
|
||||||
}
|
}
|
||||||
if line != "" {
|
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
|
// Reset resets the migration counter. Call between Up and Down
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -29,10 +30,17 @@ var (
|
|||||||
// Migration represents a paired up/down migration.
|
// Migration represents a paired up/down migration.
|
||||||
type Migration struct {
|
type Migration struct {
|
||||||
Name string // e.g. "2026-04-05-001000_create-todos"
|
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
|
Up string // SQL content of the .up.sql file
|
||||||
Down string // SQL content of the .down.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.
|
// Status represents the current migration state.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Applied []string
|
Applied []string
|
||||||
@ -50,14 +58,22 @@ type Migrator interface {
|
|||||||
// ExecDown runs the down migration.
|
// ExecDown runs the down migration.
|
||||||
ExecDown(ctx context.Context, m Migration) error
|
ExecDown(ctx context.Context, m Migration) error
|
||||||
|
|
||||||
// Applied returns the names of all applied migrations, sorted
|
// Applied returns all applied migrations from the _migrations table,
|
||||||
// lexicographically. Returns an empty slice (not an error) if the
|
// sorted lexicographically by name. Returns an empty slice (not an
|
||||||
// migrations table or log does not exist yet.
|
// error) if the migrations table or log does not exist yet.
|
||||||
Applied(ctx context.Context) ([]string, error)
|
Applied(ctx context.Context) ([]AppliedMigration, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idFromInsert extracts the hex ID from an INSERT INTO _migrations line.
|
||||||
|
// Matches: INSERT INTO _migrations (name, id) VALUES ('...', '<hex>');
|
||||||
|
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
|
// Collect reads .up.sql and .down.sql files from fsys, pairs them by
|
||||||
// basename, and returns them sorted lexicographically by name.
|
// 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) {
|
func Collect(fsys fs.FS) ([]Migration, error) {
|
||||||
ups := map[string]string{}
|
ups := map[string]string{}
|
||||||
downs := map[string]string{}
|
downs := map[string]string{}
|
||||||
@ -100,8 +116,13 @@ func Collect(fsys fs.FS) ([]Migration, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("%w: %s", ErrMissingDown, name)
|
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{
|
migrations = append(migrations, Migration{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
ID: id,
|
||||||
Up: upSQL,
|
Up: upSQL,
|
||||||
Down: downSQL,
|
Down: downSQL,
|
||||||
})
|
})
|
||||||
@ -130,17 +151,43 @@ func NamesOnly(names []string) []Migration {
|
|||||||
return migrations
|
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.
|
// Up applies up to n pending migrations using the given Runner.
|
||||||
// If n <= 0, applies all pending. Returns the names of applied migrations.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var pending []Migration
|
var pending []Migration
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
if !slices.Contains(appliedNames, m.Name) {
|
if !isApplied(m, applied) {
|
||||||
pending = append(pending, m)
|
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.
|
// 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.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
byName := map[string]Migration{}
|
byName := map[string]Migration{}
|
||||||
|
byID := map[string]Migration{}
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
byName[m.Name] = m
|
byName[m.Name] = m
|
||||||
|
if m.ID != "" {
|
||||||
|
byID[m.ID] = m
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reversed := make([]string, len(appliedNames))
|
reversed := make([]AppliedMigration, len(applied))
|
||||||
copy(reversed, appliedNames)
|
copy(reversed, applied)
|
||||||
slices.Reverse(reversed)
|
slices.Reverse(reversed)
|
||||||
|
|
||||||
if n <= 0 || n > len(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
|
var ran []string
|
||||||
for _, name := range reversed[:n] {
|
for _, a := range reversed[:n] {
|
||||||
m, ok := byName[name]
|
m, ok := findMigration(a, byName, byID)
|
||||||
if !ok {
|
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 {
|
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
|
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.
|
// GetStatus returns applied and pending migration lists.
|
||||||
func GetStatus(ctx context.Context, r Migrator, migrations []Migration) (*Status, error) {
|
func GetStatus(ctx context.Context, r Migrator, migrations []Migration) (*Status, error) {
|
||||||
appliedNames, err := r.Applied(ctx)
|
applied, err := r.Applied(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appliedNames := make([]string, len(applied))
|
||||||
|
for i, a := range applied {
|
||||||
|
appliedNames[i] = a.Name
|
||||||
|
}
|
||||||
|
|
||||||
var pending []string
|
var pending []string
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
if !slices.Contains(appliedNames, m.Name) {
|
if !isApplied(m, applied) {
|
||||||
pending = append(pending, m.Name)
|
pending = append(pending, m.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,25 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/therootcompany/golib/database/sqlmigrate"
|
"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.
|
// mockMigrator tracks applied migrations in memory.
|
||||||
type mockMigrator struct {
|
type mockMigrator struct {
|
||||||
applied []string
|
applied []sqlmigrate.AppliedMigration
|
||||||
execErr error // if set, ExecUp/ExecDown return this on every call
|
execErr error // if set, ExecUp/ExecDown return this on every call
|
||||||
upCalls []string
|
upCalls []string
|
||||||
downCalls []string
|
downCalls []string
|
||||||
@ -23,8 +33,10 @@ func (m *mockMigrator) ExecUp(_ context.Context, mig sqlmigrate.Migration) error
|
|||||||
if m.execErr != nil {
|
if m.execErr != nil {
|
||||||
return m.execErr
|
return m.execErr
|
||||||
}
|
}
|
||||||
m.applied = append(m.applied, mig.Name)
|
m.applied = append(m.applied, sqlmigrate.AppliedMigration{Name: mig.Name, ID: mig.ID})
|
||||||
slices.Sort(m.applied)
|
slices.SortFunc(m.applied, func(a, b sqlmigrate.AppliedMigration) int {
|
||||||
|
return strings.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,11 +45,11 @@ func (m *mockMigrator) ExecDown(_ context.Context, mig sqlmigrate.Migration) err
|
|||||||
if m.execErr != nil {
|
if m.execErr != nil {
|
||||||
return m.execErr
|
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
|
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
|
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) {
|
t.Run("missing down", func(t *testing.T) {
|
||||||
fsys := fstest.MapFS{
|
fsys := fstest.MapFS{
|
||||||
"001_only-up.up.sql": {Data: []byte("CREATE TABLE x;")},
|
"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) {
|
t.Run("apply all", func(t *testing.T) {
|
||||||
m := &mockMigrator{}
|
m := &mockMigrator{}
|
||||||
applied, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
ran, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(applied) != 3 {
|
if !slices.Equal(ran, []string{"001_init", "002_users", "003_posts"}) {
|
||||||
t.Fatalf("applied %d, want 3", len(applied))
|
t.Errorf("applied = %v", ran)
|
||||||
}
|
|
||||||
if !slices.Equal(applied, []string{"001_init", "002_users", "003_posts"}) {
|
|
||||||
t.Errorf("applied = %v", applied)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("apply n", func(t *testing.T) {
|
t.Run("apply n", func(t *testing.T) {
|
||||||
m := &mockMigrator{}
|
m := &mockMigrator{}
|
||||||
applied, err := sqlmigrate.Up(ctx, m, migrations, 2)
|
ran, err := sqlmigrate.Up(ctx, m, migrations, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(applied) != 2 {
|
if !slices.Equal(ran, []string{"001_init", "002_users"}) {
|
||||||
t.Fatalf("applied %d, want 2", len(applied))
|
t.Errorf("applied = %v", ran)
|
||||||
}
|
|
||||||
if applied[0] != "001_init" || applied[1] != "002_users" {
|
|
||||||
t.Errorf("applied = %v", applied)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("none pending", func(t *testing.T) {
|
t.Run("none pending", func(t *testing.T) {
|
||||||
m := &mockMigrator{applied: []string{"001_init", "002_users", "003_posts"}}
|
m := &mockMigrator{applied: applied("001_init", "002_users", "003_posts")}
|
||||||
applied, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
ran, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(applied) != 0 {
|
if len(ran) != 0 {
|
||||||
t.Fatalf("applied %d, want 0", len(applied))
|
t.Fatalf("applied %d, want 0", len(ran))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("partial pending", func(t *testing.T) {
|
t.Run("partial pending", func(t *testing.T) {
|
||||||
m := &mockMigrator{applied: []string{"001_init"}}
|
m := &mockMigrator{applied: applied("001_init")}
|
||||||
applied, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
ran, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !slices.Equal(applied, []string{"002_users", "003_posts"}) {
|
if !slices.Equal(ran, []string{"002_users", "003_posts"}) {
|
||||||
t.Errorf("applied = %v", applied)
|
t.Errorf("applied = %v", ran)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("n exceeds pending", func(t *testing.T) {
|
t.Run("n exceeds pending", func(t *testing.T) {
|
||||||
m := &mockMigrator{applied: []string{"001_init"}}
|
m := &mockMigrator{applied: applied("001_init")}
|
||||||
applied, err := sqlmigrate.Up(ctx, m, migrations, 99)
|
ran, err := sqlmigrate.Up(ctx, m, migrations, 99)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(applied) != 2 {
|
if len(ran) != 2 {
|
||||||
t.Fatalf("applied %d, want 2", len(applied))
|
t.Fatalf("applied %d, want 2", len(ran))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("exec error stops and returns partial", func(t *testing.T) {
|
t.Run("exec error stops and returns partial", func(t *testing.T) {
|
||||||
callCount := 0
|
m := &failOnNthMigrator{failAt: 1}
|
||||||
m := &mockMigrator{}
|
ran, err := sqlmigrate.Up(ctx, m, migrations, 0)
|
||||||
// fail on second call
|
|
||||||
origExecUp := m.ExecUp
|
|
||||||
_ = origExecUp
|
|
||||||
m2 := &failOnNthMigrator{failAt: 1}
|
|
||||||
applied, err := sqlmigrate.Up(ctx, m2, migrations, 0)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
if len(applied) != 1 {
|
if len(ran) != 1 {
|
||||||
t.Errorf("applied %d before error, want 1", len(applied))
|
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).
|
// failOnNthMigrator fails on the Nth ExecUp call (0-indexed).
|
||||||
type failOnNthMigrator struct {
|
type failOnNthMigrator struct {
|
||||||
applied []string
|
applied []sqlmigrate.AppliedMigration
|
||||||
calls int
|
calls int
|
||||||
failAt int
|
failAt int
|
||||||
}
|
}
|
||||||
@ -240,17 +287,19 @@ func (m *failOnNthMigrator) ExecUp(_ context.Context, mig sqlmigrate.Migration)
|
|||||||
return errors.New("connection lost")
|
return errors.New("connection lost")
|
||||||
}
|
}
|
||||||
m.calls++
|
m.calls++
|
||||||
m.applied = append(m.applied, mig.Name)
|
m.applied = append(m.applied, sqlmigrate.AppliedMigration{Name: mig.Name, ID: mig.ID})
|
||||||
slices.Sort(m.applied)
|
slices.SortFunc(m.applied, func(a, b sqlmigrate.AppliedMigration) int {
|
||||||
|
return strings.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *failOnNthMigrator) ExecDown(_ context.Context, mig sqlmigrate.Migration) error {
|
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
|
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
|
return slices.Clone(m.applied), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,7 +314,7 @@ func TestDown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("rollback all", func(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)
|
rolled, err := sqlmigrate.Down(ctx, m, migrations, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -273,7 +322,6 @@ func TestDown(t *testing.T) {
|
|||||||
if len(rolled) != 3 {
|
if len(rolled) != 3 {
|
||||||
t.Fatalf("rolled %d, want 3", len(rolled))
|
t.Fatalf("rolled %d, want 3", len(rolled))
|
||||||
}
|
}
|
||||||
// most recent first
|
|
||||||
if rolled[0] != "003_posts" {
|
if rolled[0] != "003_posts" {
|
||||||
t.Errorf("first rollback = %q, want 003_posts", rolled[0])
|
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) {
|
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)
|
rolled, err := sqlmigrate.Down(ctx, m, migrations, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -306,7 +354,7 @@ func TestDown(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("exec error", func(t *testing.T) {
|
t.Run("exec error", func(t *testing.T) {
|
||||||
m := &mockMigrator{
|
m := &mockMigrator{
|
||||||
applied: []string{"001_init", "002_users"},
|
applied: applied("001_init", "002_users"),
|
||||||
execErr: errors.New("permission denied"),
|
execErr: errors.New("permission denied"),
|
||||||
}
|
}
|
||||||
rolled, err := sqlmigrate.Down(ctx, m, migrations, 1)
|
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) {
|
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)
|
_, err := sqlmigrate.Down(ctx, m, migrations, 0)
|
||||||
if !errors.Is(err, sqlmigrate.ErrMissingDown) {
|
if !errors.Is(err, sqlmigrate.ErrMissingDown) {
|
||||||
t.Errorf("got %v, want ErrMissingDown", err)
|
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) {
|
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)
|
rolled, err := sqlmigrate.Down(ctx, m, migrations, 99)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -336,6 +384,23 @@ func TestDown(t *testing.T) {
|
|||||||
t.Fatalf("rolled %d, want 1", len(rolled))
|
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 ---
|
// --- GetStatus ---
|
||||||
@ -363,7 +428,7 @@ func TestGetStatus(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("partial", func(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)
|
status, err := sqlmigrate.GetStatus(ctx, m, migrations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -377,7 +442,7 @@ func TestGetStatus(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("all applied", func(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)
|
status, err := sqlmigrate.GetStatus(ctx, m, migrations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user