2026-05-02 13:02:38 +08:00

244 lines
5.8 KiB
Go

package migration
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func Apply(ctx context.Context, db *sql.DB, dir string) error {
if strings.TrimSpace(dir) == "" {
return errors.New("migration dir is required")
}
if _, err := os.Stat(dir); err != nil {
return err
}
if err := ensureSchemaTable(ctx, db); err != nil {
return err
}
files, err := migrationFiles(dir)
if err != nil {
return err
}
for _, file := range files {
version := filepath.Base(file)
applied, err := isApplied(ctx, db, version, file)
if err != nil {
return err
}
if applied {
continue
}
if err := applyFile(ctx, db, version, file); err != nil {
return err
}
}
return nil
}
func ensureSchemaTable(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(128) PRIMARY KEY,
checksum CHAR(64) NOT NULL DEFAULT '',
dirty BOOLEAN NOT NULL DEFAULT FALSE,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil {
return err
}
if err := ensureColumn(ctx, db, "checksum", "ALTER TABLE schema_migrations ADD COLUMN checksum CHAR(64) NOT NULL DEFAULT '' AFTER version"); err != nil {
return err
}
return ensureColumn(ctx, db, "dirty", "ALTER TABLE schema_migrations ADD COLUMN dirty BOOLEAN NOT NULL DEFAULT FALSE AFTER checksum")
}
func ensureColumn(ctx context.Context, db *sql.DB, column string, alter string) error {
var count int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'schema_migrations' AND column_name = ?`, column).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return nil
}
_, err = db.ExecContext(ctx, alter)
return err
}
func migrationFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
continue
}
files = append(files, filepath.Join(dir, entry.Name()))
}
sort.Strings(files)
return files, nil
}
func isApplied(ctx context.Context, db *sql.DB, version string, file string) (bool, error) {
var row migrationRow
err := db.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&row.Version, &row.Checksum, &row.Dirty)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
if row.Dirty {
return false, fmt.Errorf("migration %s is dirty; repair it before continuing", version)
}
body, err := os.ReadFile(file)
if err != nil {
return false, err
}
sum := checksum(body)
if row.Checksum != "" && row.Checksum != sum {
return false, fmt.Errorf("migration %s checksum changed", version)
}
if row.Checksum == "" {
if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET checksum = ? WHERE version = ?", sum, version); err != nil {
return false, err
}
}
return true, nil
}
func applyFile(ctx context.Context, db *sql.DB, version string, file string) error {
body, err := os.ReadFile(file)
if err != nil {
return err
}
sum := checksum(body)
var existing migrationRow
err = db.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&existing.Version, &existing.Checksum, &existing.Dirty)
if err == nil {
if existing.Dirty {
return fmt.Errorf("migration %s is dirty; repair it before continuing", version)
}
if existing.Checksum != "" && existing.Checksum != sum {
return fmt.Errorf("migration %s checksum changed", version)
}
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations(version, checksum, dirty) VALUES (?, ?, TRUE)", version, sum); err != nil {
return err
}
for _, statement := range splitSQL(string(body)) {
if _, err := db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("apply migration %s: %w", version, err)
}
}
if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at = CURRENT_TIMESTAMP WHERE version = ?", sum, version); err != nil {
return err
}
return nil
}
func splitSQL(raw string) []string {
statements := make([]string, 0)
var b strings.Builder
var quote rune
var inLineComment bool
var inBlockComment bool
escaped := false
for i, r := range raw {
next := rune(0)
if i+1 < len(raw) {
next = rune(raw[i+1])
}
if inLineComment {
b.WriteRune(r)
if r == '\n' {
inLineComment = false
}
continue
}
if inBlockComment {
b.WriteRune(r)
if r == '*' && next == '/' {
inBlockComment = false
}
continue
}
if quote == 0 && r == '-' && next == '-' {
inLineComment = true
b.WriteRune(r)
continue
}
if quote == 0 && r == '/' && next == '*' {
inBlockComment = true
b.WriteRune(r)
continue
}
if quote != 0 {
b.WriteRune(r)
if escaped {
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == quote {
quote = 0
}
continue
}
switch r {
case '\'', '"', '`':
quote = r
b.WriteRune(r)
case ';':
appendStatement(&statements, b.String())
b.Reset()
default:
b.WriteRune(r)
}
}
appendStatement(&statements, b.String())
return statements
}
type migrationRow struct {
Version string
Checksum string
Dirty bool
}
func checksum(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
func appendStatement(statements *[]string, raw string) {
statement := strings.TrimSpace(raw)
if statement == "" {
return
}
*statements = append(*statements, statement)
}