mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 20:58:00 +00:00
Migration{ID, Name} is the identity type returned by Up/Down/Latest/Drop.
Script{Migration, Up, Down} holds collected SQL content from Collect().
Migrator interface now takes SQL as a separate parameter:
ExecUp(ctx, Migration, sql string)
ExecDown(ctx, Migration, sql string)
This separates identity from content — callers that track what ran
don't need to carry around SQL strings they'll never use.
Updates shmigrate to match (ignores the sql parameter, references
files on disk instead).
300 lines
7.7 KiB
Go
300 lines
7.7 KiB
Go
// Package sqlmigrate provides a database-agnostic SQL migration interface.
|
|
//
|
|
// Backend implementations (each a separate Go module):
|
|
// - pgmigrate: PostgreSQL via pgx/v5
|
|
// - mymigrate: MySQL/MariaDB via go-sql-driver/mysql
|
|
// - litemigrate: SQLite via database/sql
|
|
// - msmigrate: SQL Server via go-mssqldb
|
|
// - shmigrate: POSIX shell script generation
|
|
package sqlmigrate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// Sentinel errors for migration operations.
|
|
var (
|
|
ErrMissingUp = errors.New("missing up migration")
|
|
ErrMissingDown = errors.New("missing down migration")
|
|
ErrWalkFailed = errors.New("walking migrations")
|
|
ErrExecFailed = errors.New("migration exec failed")
|
|
ErrQueryApplied = errors.New("querying applied migrations")
|
|
ErrInvalidN = errors.New("n must be positive or -1 for all")
|
|
)
|
|
|
|
// Migration identifies a migration by its name and optional hex ID.
|
|
type Migration struct {
|
|
ID string // 8-char hex from INSERT INTO _migrations, parsed by Collect
|
|
Name string // e.g. "2026-04-05-001000_create-todos"
|
|
}
|
|
|
|
// Script is a Migration with its up and down SQL content, as returned by Collect.
|
|
type Script struct {
|
|
Migration
|
|
Up string // SQL content of the .up.sql file
|
|
Down string // SQL content of the .down.sql file
|
|
}
|
|
|
|
// Status represents the current migration state.
|
|
type Status struct {
|
|
Applied []Migration
|
|
Pending []Migration
|
|
}
|
|
|
|
// Migrator executes migrations. Implementations handle the
|
|
// database-specific or output-specific details.
|
|
type Migrator interface {
|
|
// ExecUp runs the up migration SQL. For database migrators this
|
|
// executes the SQL in a transaction. For shell migrators this
|
|
// outputs a command referencing the .up.sql file.
|
|
ExecUp(ctx context.Context, m Migration, sql string) error
|
|
|
|
// ExecDown runs the down migration SQL.
|
|
ExecDown(ctx context.Context, m Migration, sql 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) ([]Migration, 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 under subpath,
|
|
// pairs them by basename, and returns them sorted lexicographically by name.
|
|
// If subpath is "" or ".", the root of fsys is used.
|
|
// If the up SQL contains an INSERT INTO _migrations line, the hex ID
|
|
// is extracted and stored in Script.ID.
|
|
func Collect(fsys fs.FS, subpath string) ([]Script, error) {
|
|
if subpath != "" && subpath != "." {
|
|
var err error
|
|
fsys, err = fs.Sub(fsys, subpath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrWalkFailed, err)
|
|
}
|
|
}
|
|
|
|
ups := map[string]string{}
|
|
downs := map[string]string{}
|
|
|
|
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
name := d.Name()
|
|
if base, ok := strings.CutSuffix(name, ".up.sql"); ok {
|
|
b, readErr := fs.ReadFile(fsys, path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
ups[base] = string(b)
|
|
return nil
|
|
}
|
|
if base, ok := strings.CutSuffix(name, ".down.sql"); ok {
|
|
b, readErr := fs.ReadFile(fsys, path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
downs[base] = string(b)
|
|
return nil
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrWalkFailed, err)
|
|
}
|
|
|
|
var ddls []Script
|
|
for name, upSQL := range ups {
|
|
downSQL, ok := downs[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrMissingDown, name)
|
|
}
|
|
var id string
|
|
if m := idFromInsert.FindStringSubmatch(upSQL); m != nil {
|
|
id = m[1]
|
|
}
|
|
ddls = append(ddls, Script{
|
|
Migration: Migration{ID: id, Name: name},
|
|
Up: upSQL,
|
|
Down: downSQL,
|
|
})
|
|
}
|
|
for name := range downs {
|
|
if _, ok := ups[name]; !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrMissingUp, name)
|
|
}
|
|
}
|
|
|
|
slices.SortFunc(ddls, func(a, b Script) int {
|
|
return strings.Compare(a.Name, b.Name)
|
|
})
|
|
|
|
return ddls, nil
|
|
}
|
|
|
|
// NamesOnly builds a Script slice from a list of names, with empty
|
|
// Up/Down content. Useful for shell-based runners that reference files
|
|
// on disk rather than executing SQL directly.
|
|
func NamesOnly(names []string) []Script {
|
|
ddls := make([]Script, len(names))
|
|
for i, name := range names {
|
|
ddls[i] = Script{Migration: Migration{Name: name}}
|
|
}
|
|
return ddls
|
|
}
|
|
|
|
// isApplied returns true if the Script matches any applied entry by name or ID.
|
|
func isApplied(d Script, applied []Migration) bool {
|
|
for _, a := range applied {
|
|
if a.Name == d.Name {
|
|
return true
|
|
}
|
|
if d.ID != "" && a.ID != "" && a.ID == d.ID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// findScript looks up a Script by the applied entry's name or ID.
|
|
func findScript(a Migration, byName map[string]Script, byID map[string]Script) (Script, bool) {
|
|
if d, ok := byName[a.Name]; ok {
|
|
return d, true
|
|
}
|
|
if a.ID != "" {
|
|
if d, ok := byID[a.ID]; ok {
|
|
return d, true
|
|
}
|
|
}
|
|
return Script{}, false
|
|
}
|
|
|
|
// Up applies up to n pending migrations using the given Migrator.
|
|
// If n < 0, applies all pending. If n == 0, returns ErrInvalidN.
|
|
// Returns the applied migrations.
|
|
func Up(ctx context.Context, r Migrator, ddls []Script, n int) ([]Migration, error) {
|
|
if n == 0 {
|
|
return nil, ErrInvalidN
|
|
}
|
|
|
|
applied, err := r.Applied(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var pending []Script
|
|
for _, d := range ddls {
|
|
if !isApplied(d, applied) {
|
|
pending = append(pending, d)
|
|
}
|
|
}
|
|
|
|
if n < 0 {
|
|
n = len(pending)
|
|
}
|
|
if n > len(pending) {
|
|
n = len(pending)
|
|
}
|
|
|
|
var ran []Migration
|
|
for _, d := range pending[:n] {
|
|
if err := r.ExecUp(ctx, d.Migration, d.Up); err != nil {
|
|
return ran, fmt.Errorf("%s (up): %w", d.Name, err)
|
|
}
|
|
ran = append(ran, d.Migration)
|
|
}
|
|
|
|
return ran, nil
|
|
}
|
|
|
|
// Down rolls back up to n applied migrations, most recent first.
|
|
// If n < 0, rolls back all applied. If n == 0, returns ErrInvalidN.
|
|
// Returns the rolled-back migrations.
|
|
func Down(ctx context.Context, r Migrator, ddls []Script, n int) ([]Migration, error) {
|
|
if n == 0 {
|
|
return nil, ErrInvalidN
|
|
}
|
|
|
|
applied, err := r.Applied(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
byName := map[string]Script{}
|
|
byID := map[string]Script{}
|
|
for _, d := range ddls {
|
|
byName[d.Name] = d
|
|
if d.ID != "" {
|
|
byID[d.ID] = d
|
|
}
|
|
}
|
|
|
|
reversed := make([]Migration, len(applied))
|
|
copy(reversed, applied)
|
|
slices.Reverse(reversed)
|
|
|
|
if n < 0 || n > len(reversed) {
|
|
n = len(reversed)
|
|
}
|
|
|
|
var ran []Migration
|
|
for _, a := range reversed[:n] {
|
|
d, ok := findScript(a, byName, byID)
|
|
if !ok {
|
|
return ran, fmt.Errorf("%w: %s", ErrMissingDown, a.Name)
|
|
}
|
|
if err := r.ExecDown(ctx, a, d.Down); err != nil {
|
|
return ran, fmt.Errorf("%s (down): %w", a.Name, err)
|
|
}
|
|
ran = append(ran, a)
|
|
}
|
|
|
|
return ran, nil
|
|
}
|
|
|
|
// GetStatus returns applied and pending migration lists.
|
|
func GetStatus(ctx context.Context, r Migrator, ddls []Script) (*Status, error) {
|
|
applied, err := r.Applied(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var pending []Migration
|
|
for _, d := range ddls {
|
|
if !isApplied(d, applied) {
|
|
pending = append(pending, d.Migration)
|
|
}
|
|
}
|
|
|
|
return &Status{
|
|
Applied: applied,
|
|
Pending: pending,
|
|
}, nil
|
|
}
|
|
|
|
// Latest applies all pending migrations. Equivalent to Up(ctx, r, ddls, -1).
|
|
func Latest(ctx context.Context, r Migrator, ddls []Script) ([]Migration, error) {
|
|
return Up(ctx, r, ddls, -1)
|
|
}
|
|
|
|
// Drop rolls back all applied migrations. Equivalent to Down(ctx, r, ddls, -1).
|
|
func Drop(ctx context.Context, r Migrator, ddls []Script) ([]Migration, error) {
|
|
return Down(ctx, r, ddls, -1)
|
|
}
|