feat(sql-migrate): add option for _migrations table name

This commit is contained in:
AJ ONeal 2026-07-07 15:53:27 -06:00
parent 307ccec0bf
commit 7ecf3fbbbe
No known key found for this signature in database
2 changed files with 92 additions and 55 deletions

View File

@ -45,31 +45,35 @@ const (
defaultLogPath = "../migrations.log" defaultLogPath = "../migrations.log"
sqlCommandPSQL = `psql "$PG_URL" -v ON_ERROR_STOP=on --no-align --tuples-only --file %s` sqlCommandPSQL = `psql "$PG_URL" -v ON_ERROR_STOP=on --no-align --tuples-only --file %s`
sqlCommandMariaDB = `mariadb --defaults-extra-file="$MY_CNF" --silent --skip-column-names --raw < %s` sqlCommandMariaDB = `mariadb --defaults-extra-file="$MY_CNF" --silent --skip-column-names --raw < %s`
sqlCommandMariaDBAlt = `mariadb --defaults-extra-file="$MY_CNF" -s -N --raw < %s`
sqlCommandMySQL = `mysql --defaults-extra-file="$MY_CNF" --silent --skip-column-names --raw < %s` sqlCommandMySQL = `mysql --defaults-extra-file="$MY_CNF" --silent --skip-column-names --raw < %s`
sqlCommandMySQLAlt = `mysql --defaults-extra-file="$MY_CNF" -s -N --raw < %s`
sqlCommandSQLite = `sqlite3 "$SQLITE_PATH" < %s` sqlCommandSQLite = `sqlite3 "$SQLITE_PATH" < %s`
sqlCommandSQLCmd = `sqlcmd --exit-on-error --headers -1 --trim-spaces --encrypt-connection strict --input-file %s` sqlCommandSQLCmd = `sqlcmd --exit-on-error --headers -1 --trim-spaces --encrypt-connection strict --input-file %s`
LOG_QUERY_NAME = "_migrations.sql" LOG_QUERY_NAME = "_migrations.sql"
defaultTableName = "_migrations"
M_MIGRATOR_NAME = "0001-01-01-001000_init-migrations" M_MIGRATOR_NAME = "0001-01-01-001000_init-migrations"
M_MIGRATOR_UP_NAME = "0001-01-01-001000_init-migrations.up.sql" M_MIGRATOR_UP_NAME = "0001-01-01-001000_init-migrations.up.sql"
M_MIGRATOR_DOWN_NAME = "0001-01-01-001000_init-migrations.down.sql" M_MIGRATOR_DOWN_NAME = "0001-01-01-001000_init-migrations.down.sql"
defaultMigratorUpTmpl = `-- Config variables for sql-migrate (do not delete) defaultMigratorUpTmpl = `-- Config variables for sql-migrate (do not delete)
-- sql_command: %s -- sql_command: %s
-- migrations_log: %s -- migrations_log: %s
-- migrations_table: %s
-- --
CREATE TABLE IF NOT EXISTS _migrations ( CREATE TABLE IF NOT EXISTS __MIGRATIONS_TABLE__ (
id CHAR(8) PRIMARY KEY, id CHAR(8) PRIMARY KEY,
name VARCHAR(80) NULL UNIQUE, name VARCHAR(80) NULL UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- note: to enable text-based tools to grep and sort we put 'name' before 'id' -- note: to enable text-based tools to grep and sort we put 'name' before 'id'
-- grep -r 'INSERT INTO _migrations' ./sql/migrations/ | cut -d':' -f2 | sort -- grep -r 'INSERT INTO __MIGRATIONS_TABLE__' ./sql/migrations/ | cut -d':' -f2 | sort
INSERT INTO _migrations (name, id) VALUES ('0001-01-01-001000_init-migrations', '00000001'); INSERT INTO __MIGRATIONS_TABLE__ (name, id) VALUES ('0001-01-01-001000_init-migrations', '00000001');
` `
defaultMigratorDown = `DELETE FROM _migrations WHERE id = '00000001'; defaultMigratorDown = `DELETE FROM __MIGRATIONS_TABLE__ WHERE id = '00000001';
DROP TABLE IF EXISTS _migrations; DROP TABLE IF EXISTS __MIGRATIONS_TABLE__;
` `
// Used for detection during auto-upgrade. // Used for detection during auto-upgrade.
logMigrationsQueryPrev2_2_0 = `SELECT name FROM _migrations ORDER BY name;` logMigrationsQueryPrev2_2_0 = `SELECT name FROM _migrations ORDER BY name;`
@ -122,6 +126,13 @@ NOTES
The initial migration file contains configuration variables: The initial migration file contains configuration variables:
-- migrations_log: ./sql/migrations.log -- migrations_log: ./sql/migrations.log
-- sql_command: psql "$PG_URL" -v ON_ERROR_STOP=on --no-align --tuples-only --file %s -- sql_command: psql "$PG_URL" -v ON_ERROR_STOP=on --no-align --tuples-only --file %s
-- migrations_table: _migrations
Use --table on init to use a different table name (default: _migrations).
This is useful when you need multiple migration tables (e.g., per-tenant schemas
or shared databases with multiple apps).
The log is generated on each migration file contains a list of all migrations:
The log is generated on each migration file contains a list of all migrations: The log is generated on each migration file contains a list of all migrations:
0001-01-01-001000_init-migrations.up.sql 0001-01-01-001000_init-migrations.up.sql
@ -176,9 +187,9 @@ var (
commentStartRe = regexp.MustCompile(`(^|\s+)#.*`) commentStartRe = regexp.MustCompile(`(^|\s+)#.*`)
) )
// logMigrationsSelect returns the DB-specific SELECT line for _migrations. // logMigrationsSelect returns the DB-specific SELECT line for the migrations table.
// The output format is "id<tab>name" per row, compatible with shmigrate.Applied(). // The output format is "id<tab>name" per row, compatible with shmigrate.Applied().
func logMigrationsSelect(sqlCommand string) string { func logMigrationsSelect(sqlCommand, tableName string) string {
var selectExpr string var selectExpr string
switch { switch {
case strings.Contains(sqlCommand, "psql"): case strings.Contains(sqlCommand, "psql"):
@ -190,15 +201,16 @@ func logMigrationsSelect(sqlCommand string) string {
case strings.Contains(sqlCommand, "sqlcmd"): case strings.Contains(sqlCommand, "sqlcmd"):
selectExpr = "id + CHAR(9) + name" selectExpr = "id + CHAR(9) + name"
default: default:
fmt.Fprintf(os.Stderr, "Error: unrecognized --sql-command %q; cannot generate _migrations.sql\n", sqlCommand) fmt.Fprintf(os.Stderr, "Error: unrecognized --sql-command %q; cannot generate %s.sql\n", sqlCommand, tableName)
os.Exit(1) os.Exit(1)
} }
return fmt.Sprintf("SELECT %s FROM _migrations ORDER BY name;", selectExpr) return fmt.Sprintf("SELECT %s FROM %s ORDER BY name;", selectExpr, tableName)
} }
type State struct { type State struct {
Date time.Time Date time.Time
SQLCommand string SQLCommand string
TableName string
Lines []string Lines []string
Migrated []string Migrated []string
MigrationsDir string MigrationsDir string
@ -209,6 +221,7 @@ type MainConfig struct {
migrationsDir string migrationsDir string
logPath string logPath string
sqlCommand string sqlCommand string
tableName string
} }
func main() { func main() {
@ -258,6 +271,7 @@ func main() {
fsSub = flag.NewFlagSet("init", flag.ExitOnError) fsSub = flag.NewFlagSet("init", flag.ExitOnError)
fsSub.StringVar(&cfg.logPath, "migrations-log", "", fmt.Sprintf("migration log file (default: %s) relative to and saved in %s", defaultLogPath, M_MIGRATOR_NAME)) fsSub.StringVar(&cfg.logPath, "migrations-log", "", fmt.Sprintf("migration log file (default: %s) relative to and saved in %s", defaultLogPath, M_MIGRATOR_NAME))
fsSub.StringVar(&cfg.sqlCommand, "sql-command", sqlCommandPSQL, "construct scripts with this to execute SQL files: 'psql', 'mysql', 'mariadb', 'sqlite', 'sqlcmd', or custom arguments") fsSub.StringVar(&cfg.sqlCommand, "sql-command", sqlCommandPSQL, "construct scripts with this to execute SQL files: 'psql', 'mysql', 'mariadb', 'sqlite', 'sqlcmd', or custom arguments")
fsSub.StringVar(&cfg.tableName, "table", defaultTableName, "name of the migrations tracking table")
case "create", "sync", "up", "down", "status", "list": case "create", "sync", "up", "down", "status", "list":
fsSub = flag.NewFlagSet(subcmd, flag.ExitOnError) fsSub = flag.NewFlagSet(subcmd, flag.ExitOnError)
default: default:
@ -327,14 +341,14 @@ func main() {
Date: today, Date: today,
MigrationsDir: cfg.migrationsDir, MigrationsDir: cfg.migrationsDir,
} }
state.SQLCommand, state.LogPath, err = extractVars(mMigratorUpPath) state.SQLCommand, state.LogPath, state.TableName, err = extractVars(mMigratorUpPath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: couldn't read config from initial migration: %v\n", err) fmt.Fprintf(os.Stderr, "Error: couldn't read config from initial migration: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// auto-upgrade _migrations.sql to include id in output // auto-upgrade _migrations.sql to include id in output
maybeUpgradeLogQuery(logQueryPath, state.SQLCommand) maybeUpgradeLogQuery(logQueryPath, state.SQLCommand, state.TableName)
logText, err := os.ReadFile(state.LogPath) logText, err := os.ReadFile(state.LogPath)
if err != nil { if err != nil {
@ -538,6 +552,14 @@ func mustInit(cfg *MainConfig) {
fmt.Fprintf(os.Stderr, "Initializing %q ...\n", cfg.migrationsDir) fmt.Fprintf(os.Stderr, "Initializing %q ...\n", cfg.migrationsDir)
var resolvedLogPath = cfg.logPath var resolvedLogPath = cfg.logPath
tableName := cfg.tableName
if tableName == "" {
tableName = defaultTableName
}
if !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`).MatchString(tableName) {
fmt.Fprintf(os.Stderr, "Error: --table %q is not a valid SQL identifier\n", tableName)
os.Exit(1)
}
if cfg.sqlCommand != "" && !strings.Contains(cfg.sqlCommand, "%s") { if cfg.sqlCommand != "" && !strings.Contains(cfg.sqlCommand, "%s") {
fmt.Fprintf(os.Stderr, "Error: --sql-command must contain a literal '%%s' to accept the path to the SQL file\n") fmt.Fprintf(os.Stderr, "Error: --sql-command must contain a literal '%%s' to accept the path to the SQL file\n")
os.Exit(1) os.Exit(1)
@ -578,7 +600,10 @@ func mustInit(cfg *MainConfig) {
// } // }
} }
migratorUpQuery := fmt.Sprintf(defaultMigratorUpTmpl, cfg.sqlCommand, resolvedLogPath) migratorUpQuery := strings.ReplaceAll(
fmt.Sprintf(defaultMigratorUpTmpl, cfg.sqlCommand, resolvedLogPath, tableName),
"__MIGRATIONS_TABLE__", tableName,
)
if created, err := initFile(mMigratorUpPath, migratorUpQuery); err != nil { if created, err := initFile(mMigratorUpPath, migratorUpQuery); err != nil {
fmt.Fprintf(os.Stderr, "Error: init couldn't create initial up migration: %v\n", err) fmt.Fprintf(os.Stderr, "Error: init couldn't create initial up migration: %v\n", err)
os.Exit(1) os.Exit(1)
@ -590,7 +615,7 @@ func mustInit(cfg *MainConfig) {
state := State{ state := State{
MigrationsDir: cfg.migrationsDir, MigrationsDir: cfg.migrationsDir,
} }
state.SQLCommand, state.LogPath, err = extractVars(mMigratorUpPath) state.SQLCommand, state.LogPath, state.TableName, err = extractVars(mMigratorUpPath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: init couldn't read config from initial migration: %v\n", err) fmt.Fprintf(os.Stderr, "Error: init couldn't read config from initial migration: %v\n", err)
os.Exit(1) os.Exit(1)
@ -609,11 +634,18 @@ func mustInit(cfg *MainConfig) {
) )
os.Exit(1) os.Exit(1)
} }
if cfg.tableName != "" && cfg.tableName != state.TableName {
fmt.Fprintf(os.Stderr,
"--table %q does not match %q from %q\n(drop the --table flag, or update the init migrations file)\n",
cfg.tableName, state.TableName, mMigratorUpPath,
)
os.Exit(1)
}
if slices.Contains(downs, M_MIGRATOR_NAME) { if slices.Contains(downs, M_MIGRATOR_NAME) {
fmt.Fprintf(os.Stderr, " found %s\n", filepathUnclean(mMigratorDownPath)) fmt.Fprintf(os.Stderr, " found %s\n", filepathUnclean(mMigratorDownPath))
} else { } else {
migratorDownQuery := defaultMigratorDown migratorDownQuery := strings.ReplaceAll(defaultMigratorDown, "__MIGRATIONS_TABLE__", state.TableName)
if created, err := initFile(mMigratorDownPath, migratorDownQuery); err != nil { if created, err := initFile(mMigratorDownPath, migratorDownQuery); err != nil {
fmt.Fprintf(os.Stderr, "Error: init couldn't create initial up migration: %v\n", err) fmt.Fprintf(os.Stderr, "Error: init couldn't create initial up migration: %v\n", err)
os.Exit(1) os.Exit(1)
@ -627,7 +659,7 @@ func mustInit(cfg *MainConfig) {
if strings.Contains(state.SQLCommand, "sqlcmd") { if strings.Contains(state.SQLCommand, "sqlcmd") {
queryHeader += logMigrationsQuerySQLCmdNote queryHeader += logMigrationsQuerySQLCmdNote
} }
if created, err := initFile(logQueryPath, queryHeader+logMigrationsSelect(state.SQLCommand)+"\n"); err != nil { if created, err := initFile(logQueryPath, queryHeader+logMigrationsSelect(state.SQLCommand, state.TableName)+"\n"); err != nil {
fmt.Fprintf(os.Stderr, "Error: init couldn't create migrations query: %v\n", err) fmt.Fprintf(os.Stderr, "Error: init couldn't create migrations query: %v\n", err)
os.Exit(1) os.Exit(1)
} else if created { } else if created {
@ -643,10 +675,10 @@ func mustInit(cfg *MainConfig) {
} }
} }
// maybeUpgradeLogQuery replaces the old name-only SELECT in _migrations.sql // maybeUpgradeLogQuery replaces the old name-only SELECT in the log query file
// with the new id+name SELECT. Only the matching line is replaced; comments // with the new id+name SELECT. Only the matching line is replaced; comments
// and other customizations are preserved. // and other customizations are preserved.
func maybeUpgradeLogQuery(path, sqlCommand string) { func maybeUpgradeLogQuery(path, sqlCommand, tableName string) {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return return
@ -655,7 +687,7 @@ func maybeUpgradeLogQuery(path, sqlCommand string) {
var lines []string var lines []string
for line := range strings.SplitSeq(string(b), "\n") { for line := range strings.SplitSeq(string(b), "\n") {
if !replaced && strings.TrimSpace(line) == logMigrationsQueryPrev2_2_0 { if !replaced && strings.TrimSpace(line) == logMigrationsQueryPrev2_2_0 {
line = logMigrationsSelect(sqlCommand) line = logMigrationsSelect(sqlCommand, tableName)
replaced = true replaced = true
} }
lines = append(lines, line) lines = append(lines, line)
@ -683,10 +715,10 @@ func initFile(path, contents string) (bool, error) {
return true, nil return true, nil
} }
func extractVars(curMigrationPath string) (sqlCommand string, logPath string, err error) { func extractVars(curMigrationPath string) (sqlCommand string, logPath string, tableName string, err error) {
f, err := os.Open(curMigrationPath) f, err := os.Open(curMigrationPath)
if err != nil { if err != nil {
return "", "", err return "", "", "", err
} }
defer f.Close() defer f.Close()
@ -695,6 +727,7 @@ func extractVars(curMigrationPath string) (sqlCommand string, logPath string, er
var logPathRel string var logPathRel string
var logPathPrefix = "-- migrations_log:" var logPathPrefix = "-- migrations_log:"
var commandPrefix = "-- sql_command:" var commandPrefix = "-- sql_command:"
var tablePrefix = "-- migrations_table:"
for scanner.Scan() { for scanner.Scan() {
txt := scanner.Text() txt := scanner.Text()
txt = strings.TrimSpace(txt) txt = strings.TrimSpace(txt)
@ -704,20 +737,23 @@ func extractVars(curMigrationPath string) (sqlCommand string, logPath string, er
} else if strings.HasPrefix(txt, commandPrefix) { } else if strings.HasPrefix(txt, commandPrefix) {
sqlCommand = strings.TrimSpace(txt[len(commandPrefix):]) sqlCommand = strings.TrimSpace(txt[len(commandPrefix):])
continue continue
} else if strings.HasPrefix(txt, tablePrefix) {
tableName = strings.TrimSpace(txt[len(tablePrefix):])
continue
} }
} }
if logPathRel == "" { if logPathRel == "" {
return "", "", fmt.Errorf("Could not find '-- migrations_log: <relative-path>' in %q", curMigrationPath) return "", "", "", fmt.Errorf("Could not find '-- migrations_log: <relative-path>' in %q", curMigrationPath)
} }
if sqlCommand == "" { if sqlCommand == "" {
return "", "", fmt.Errorf("Could not find '-- sql_command: <args>' in %q", curMigrationPath) return "", "", "", fmt.Errorf("Could not find '-- sql_command: <args>' in %q", curMigrationPath)
}
if tableName == "" {
tableName = defaultTableName
} }
// migrationsDir := filepath.Dir(curMigrationPath) return sqlCommand, logPathRel, tableName, nil
// logPath = filepath.Join(migrationsDir, logPathRel)
// return sqlCommand, logPath, nil
return sqlCommand, logPathRel, nil
} }
func migrationsLogInit(state *State, subcmd string) error { func migrationsLogInit(state *State, subcmd string) error {
@ -764,7 +800,7 @@ func (state *State) parseAndFixupBatches(text string) error {
continue continue
} }
if migration != "" { if migration != "" {
up, down, warn, err := fixupMigration(state.MigrationsDir, migration) up, down, warn, err := fixupMigration(state.MigrationsDir, migration, state.TableName)
if warn != nil { if warn != nil {
fmt.Fprintf(os.Stderr, "Warn: %s\n", warn) fmt.Fprintf(os.Stderr, "Warn: %s\n", warn)
} }
@ -782,14 +818,14 @@ func (state *State) parseAndFixupBatches(text string) error {
} }
state.Lines[i] = line state.Lines[i] = line
} }
showFixes(fixedUp, fixedDown) showFixes(fixedUp, fixedDown, state.TableName)
return nil return nil
} }
func showFixes(fixedUp, fixedDown []string) { func showFixes(fixedUp, fixedDown []string, tableName string) {
if len(fixedUp) > 0 { if len(fixedUp) > 0 {
fmt.Fprintf(os.Stderr, "Fixup: appended missing 'INSERT INTO _migrations ...' to:\n") fmt.Fprintf(os.Stderr, "Fixup: appended missing 'INSERT INTO %s ...' to:\n", tableName)
for _, up := range fixedUp { for _, up := range fixedUp {
fmt.Fprintf(os.Stderr, " %s\n", up) fmt.Fprintf(os.Stderr, " %s\n", up)
} }
@ -797,7 +833,7 @@ func showFixes(fixedUp, fixedDown []string) {
} }
if len(fixedDown) > 0 { if len(fixedDown) > 0 {
fmt.Fprintf(os.Stderr, "Fixup: appended missing 'DELETE FROM _migrations ...' to:\n") fmt.Fprintf(os.Stderr, "Fixup: appended missing 'DELETE FROM %s ...' to:\n", tableName)
for _, down := range fixedDown { for _, down := range fixedDown {
fmt.Fprintf(os.Stderr, " %s\n", down) fmt.Fprintf(os.Stderr, " %s\n", down)
} }
@ -871,10 +907,11 @@ func create(state *State, desc string) error {
// Little Bobby Drop Tables says: // Little Bobby Drop Tables says:
// We trust the person running the migrations to not use malicious names. // We trust the person running the migrations to not use malicious names.
// (we don't want to embed db-specific logic here, and SQL doesn't define escaping) // (we don't want to embed db-specific logic here, and SQL doesn't define escaping)
migrationInsert := fmt.Sprintf("INSERT INTO _migrations (name, id) VALUES ('%s', '%s');", basename, id) tableName := state.TableName
migrationInsert := fmt.Sprintf("INSERT INTO %s (name, id) VALUES ('%s', '%s');", tableName, basename, id)
upContent := fmt.Appendf(nil, "-- %s (up)\nSELECT 'place your UP migration here';\n\n-- leave this as the last line\n%s\n", desc, migrationInsert) upContent := fmt.Appendf(nil, "-- %s (up)\nSELECT 'place your UP migration here';\n\n-- leave this as the last line\n%s\n", desc, migrationInsert)
_ = os.WriteFile(upPath, upContent, 0644) _ = os.WriteFile(upPath, upContent, 0644)
migrationDelete := fmt.Sprintf("DELETE FROM _migrations WHERE id = '%s';", id) migrationDelete := fmt.Sprintf("DELETE FROM %s WHERE id = '%s';", tableName, id)
downContent := fmt.Appendf(nil, "-- %s (down)\nSELECT 'place your DOWN migration here';\n\n-- leave this as the last line\n%s\n", desc, migrationDelete) downContent := fmt.Appendf(nil, "-- %s (down)\nSELECT 'place your DOWN migration here';\n\n-- leave this as the last line\n%s\n", desc, migrationDelete)
_ = os.WriteFile(downPath, downContent, 0644) _ = os.WriteFile(downPath, downContent, 0644)
@ -901,7 +938,7 @@ func RandomHex(n int) (string, error) {
} }
// attempts to add missing INSERT and DELETE without breaking what already works // attempts to add missing INSERT and DELETE without breaking what already works
func fixupMigration(dir string, basename string) (up, down bool, warn error, err error) { func fixupMigration(dir string, basename, tableName string) (up, down bool, warn error, err error) {
var id string var id string
var insertsOnUp bool var insertsOnUp bool
@ -916,7 +953,7 @@ func fixupMigration(dir string, basename string) (up, down bool, warn error, err
txt := scanner.Text() txt := scanner.Text()
txt = strings.TrimSpace(txt) txt = strings.TrimSpace(txt)
txt = strings.ToLower(txt) txt = strings.ToLower(txt)
if strings.HasPrefix(txt, "insert into _migrations") { if strings.HasPrefix(txt, "insert into "+strings.ToLower(tableName)) {
insertsOnUp = true insertsOnUp = true
break break
} }
@ -926,14 +963,14 @@ func fixupMigration(dir string, basename string) (up, down bool, warn error, err
upScan.Close() upScan.Close()
upBytes, err := os.ReadFile(upPath) upBytes, err := os.ReadFile(upPath)
if err != nil { if err != nil {
warn = fmt.Errorf("failed to add 'INSERT INTO _migrations ...' to %s: %w", upPath, err) warn = fmt.Errorf("failed to add 'INSERT INTO %s ...' to %s: %w", tableName, upPath, err)
return false, false, warn, nil return false, false, warn, nil
} }
migrationInsertLn := fmt.Sprintf("\n-- leave this as the last line\nINSERT INTO _migrations (name, id) VALUES ('%s', '%s');\n", basename, id) migrationInsertLn := fmt.Sprintf("\n-- leave this as the last line\nINSERT INTO %s (name, id) VALUES ('%s', '%s');\n", tableName, basename, id)
upBytes = append(upBytes, []byte(migrationInsertLn)...) upBytes = append(upBytes, []byte(migrationInsertLn)...)
if err = os.WriteFile(upPath, upBytes, 0644); err != nil { if err = os.WriteFile(upPath, upBytes, 0644); err != nil {
warn = fmt.Errorf("failed to append 'INSERT INTO _migrations ...' to %s: %w", upPath, err) warn = fmt.Errorf("failed to append 'INSERT INTO %s ...' to %s: %w", tableName, upPath, err)
return false, false, warn, nil return false, false, warn, nil
} }
up = true up = true
@ -951,27 +988,27 @@ func fixupMigration(dir string, basename string) (up, down bool, warn error, err
txt := scanner.Text() txt := scanner.Text()
txt = strings.TrimSpace(txt) txt = strings.TrimSpace(txt)
txt = strings.ToLower(txt) txt = strings.ToLower(txt)
if strings.HasPrefix(txt, "delete from _migrations") { if strings.HasPrefix(txt, "delete from "+strings.ToLower(tableName)) {
deletesOnDown = true deletesOnDown = true
break break
} }
} }
if !deletesOnDown { if !deletesOnDown {
if id == "" { if id == "" {
return false, false, fmt.Errorf("must manually append \"DELETE FROM _migrations WHERE id = '<id>'\" to %s with id from %s", downPath, basename+".up.sql"), nil return false, false, fmt.Errorf("must manually append \"DELETE FROM %s WHERE id = '<id>'\" to %s with id from %s", tableName, downPath, basename+".up.sql"), nil
} }
downScan.Close() downScan.Close()
downFile, err := os.OpenFile(downPath, os.O_APPEND|os.O_WRONLY, 0o644) downFile, err := os.OpenFile(downPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
warn = fmt.Errorf("failed to append 'DELETE FROM _migrations ...' to %s: %v", downPath, err) warn = fmt.Errorf("failed to append 'DELETE FROM %s ...' to %s: %v", tableName, downPath, err)
return false, false, warn, nil return false, false, warn, nil
} }
defer downFile.Close() defer downFile.Close()
migrationInsertLn := fmt.Sprintf("\nDELETE FROM _migrations WHERE id = '%s';\n", id) migrationInsertLn := fmt.Sprintf("\nDELETE FROM %s WHERE id = '%s';\n", tableName, id)
_, err = downFile.Write(([]byte(migrationInsertLn))) _, err = downFile.Write(([]byte(migrationInsertLn)))
if err != nil { if err != nil {
warn = fmt.Errorf("failed to add 'DELETE FROM _migrations ...' to %s: %w", downPath, err) warn = fmt.Errorf("failed to add 'DELETE FROM %s ...' to %s: %w", tableName, downPath, err)
return false, false, warn, nil return false, false, warn, nil
} }
down = true down = true
@ -993,7 +1030,7 @@ func syncLog(runner *shmigrate.Migrator) {
func cmdUp(ctx context.Context, state *State, runner *shmigrate.Migrator, migrations []sqlmigrate.Script, n int) error { func cmdUp(ctx context.Context, state *State, runner *shmigrate.Migrator, migrations []sqlmigrate.Script, n int) error {
// fixup pending migrations before generating the script // fixup pending migrations before generating the script
fixedUp, fixedDown := fixupAll(state.MigrationsDir, state.Migrated, migrations) fixedUp, fixedDown := fixupAll(state.MigrationsDir, state.Migrated, migrations, state.TableName)
status, err := sqlmigrate.GetStatus(ctx, runner, migrations) status, err := sqlmigrate.GetStatus(ctx, runner, migrations)
if err != nil { if err != nil {
@ -1022,13 +1059,13 @@ func cmdUp(ctx context.Context, state *State, runner *shmigrate.Migrator, migrat
fmt.Println("cat", filepathUnclean(runner.LogPath)) fmt.Println("cat", filepathUnclean(runner.LogPath))
showFixes(fixedUp, fixedDown) showFixes(fixedUp, fixedDown, state.TableName)
return nil return nil
} }
func cmdDown(ctx context.Context, state *State, runner *shmigrate.Migrator, migrations []sqlmigrate.Script, n int) error { func cmdDown(ctx context.Context, state *State, runner *shmigrate.Migrator, migrations []sqlmigrate.Script, n int) error {
// fixup applied migrations before generating the script // fixup applied migrations before generating the script
fixedUp, fixedDown := fixupAll(state.MigrationsDir, state.Migrated, migrations) fixedUp, fixedDown := fixupAll(state.MigrationsDir, state.Migrated, migrations, state.TableName)
status, err := sqlmigrate.GetStatus(ctx, runner, migrations) status, err := sqlmigrate.GetStatus(ctx, runner, migrations)
if err != nil { if err != nil {
@ -1076,7 +1113,7 @@ func cmdDown(ctx context.Context, state *State, runner *shmigrate.Migrator, migr
fmt.Println("cat", filepathUnclean(runner.LogPath)) fmt.Println("cat", filepathUnclean(runner.LogPath))
showFixes(fixedUp, fixedDown) showFixes(fixedUp, fixedDown, state.TableName)
return nil return nil
} }
@ -1115,7 +1152,7 @@ func cmdStatus(ctx context.Context, state *State, runner *shmigrate.Migrator, mi
} }
// fixupAll runs fixupMigration on all known migrations (applied + pending). // fixupAll runs fixupMigration on all known migrations (applied + pending).
func fixupAll(migrationsDir string, applied []string, migrations []sqlmigrate.Script) (fixedUp, fixedDown []string) { func fixupAll(migrationsDir string, applied []string, migrations []sqlmigrate.Script, tableName string) (fixedUp, fixedDown []string) {
seen := map[string]bool{} seen := map[string]bool{}
var all []string var all []string
for _, name := range applied { for _, name := range applied {
@ -1132,7 +1169,7 @@ func fixupAll(migrationsDir string, applied []string, migrations []sqlmigrate.Sc
} }
for _, name := range all { for _, name := range all {
up, down, warn, err := fixupMigration(migrationsDir, name) up, down, warn, err := fixupMigration(migrationsDir, name, tableName)
if warn != nil { if warn != nil {
fmt.Fprintf(os.Stderr, "Warn: %s\n", warn) fmt.Fprintf(os.Stderr, "Warn: %s\n", warn)
} }

View File

@ -30,7 +30,7 @@ var (
// Migration identifies a migration by its name and optional hex ID. // Migration identifies a migration by its name and optional hex ID.
type Migration struct { type Migration struct {
ID string // 8-char hex from INSERT INTO _migrations, parsed by Collect ID string // hex ID from INSERT INTO migrations table, parsed by Collect
Name string // e.g. "2026-04-05-001000_create-todos" Name string // e.g. "2026-04-05-001000_create-todos"
} }
@ -58,22 +58,22 @@ type Migrator interface {
// ExecDown runs the down migration SQL. // ExecDown runs the down migration SQL.
ExecDown(ctx context.Context, m Migration, sql string) error ExecDown(ctx context.Context, m Migration, sql string) error
// Applied returns all applied migrations from the _migrations table, // Applied returns all applied migrations from the migrations table,
// sorted lexicographically by name. Returns an empty slice (not an // sorted lexicographically by name. Returns an empty slice (not an
// error) if the migrations table or log does not exist yet. // error) if the migrations table or log does not exist yet.
Applied(ctx context.Context) ([]Migration, error) Applied(ctx context.Context) ([]Migration, error)
} }
// idFromInsert extracts the hex ID from an INSERT INTO _migrations line. // idFromInsert extracts the hex ID from an INSERT INTO migrations line.
// Matches: INSERT INTO [schema.]_migrations (name, id) VALUES ('...', '<hex>'); // Matches: INSERT INTO [schema.]<table> (name, id) VALUES ('...', '<hex>');
var idFromInsert = regexp.MustCompile( var idFromInsert = regexp.MustCompile(
`(?i)INSERT\s+INTO\s+(?:\w+\.)?_migrations\s*\(\s*name\s*,\s*id\s*\)\s*VALUES\s*\(\s*'[^']*'\s*,\s*'([0-9a-fA-F]+)'\s*\)`, `(?i)INSERT\s+INTO\s+(?:\w+\.)?\w+\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 under subpath, // Collect reads .up.sql and .down.sql files from fsys under subpath,
// pairs them by basename, and returns them sorted lexicographically by name. // pairs them by basename, and returns them sorted lexicographically by name.
// If subpath is "" or ".", the root of fsys is used. // If subpath is "" or ".", the root of fsys is used.
// If the up SQL contains an INSERT INTO _migrations line, the hex ID // If the up SQL contains an INSERT INTO migrations table line, the hex ID
// is extracted and stored in Script.ID. // is extracted and stored in Script.ID.
func Collect(fsys fs.FS, subpath string) ([]Script, error) { func Collect(fsys fs.FS, subpath string) ([]Script, error) {
if subpath != "" && subpath != "." { if subpath != "" && subpath != "." {