130 lines
3.9 KiB
Go
130 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "platform migration failed:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
|
if dsn == "" {
|
|
return fmt.Errorf("MYSQL_DSN is required")
|
|
}
|
|
if len(os.Args) < 2 {
|
|
return fmt.Errorf("usage: platform-migrate migration.sql [migration.sql ...]")
|
|
}
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
return fmt.Errorf("connect mysql: %w", err)
|
|
}
|
|
if len(os.Args) == 2 && os.Args[1] == "--server-version" {
|
|
var version string
|
|
if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(version)
|
|
return nil
|
|
}
|
|
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS vehicle_platform_schema_migration (
|
|
version VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
checksum CHAR(64) NOT NULL,
|
|
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
|
)`); err != nil {
|
|
return fmt.Errorf("ensure migration journal: %w", err)
|
|
}
|
|
for _, path := range os.Args[1:] {
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
version := filepath.Base(path)
|
|
sum := sha256.Sum256(contents)
|
|
checksum := hex.EncodeToString(sum[:])
|
|
var recorded string
|
|
err = db.QueryRowContext(ctx, `SELECT checksum FROM vehicle_platform_schema_migration WHERE version=?`, version).Scan(&recorded)
|
|
if err == nil {
|
|
if recorded != checksum {
|
|
return fmt.Errorf("migration %s was changed after being applied", version)
|
|
}
|
|
fmt.Printf("skipped %s (already applied)\n", path)
|
|
continue
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return fmt.Errorf("check migration %s: %w", version, err)
|
|
}
|
|
statements := splitSQL(string(contents))
|
|
for index, statement := range statements {
|
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
|
if isResumableMigrationDDL(err, statement) {
|
|
fmt.Printf("skipped %s statement %d (schema object already exists)\n", path, index+1)
|
|
continue
|
|
}
|
|
return fmt.Errorf("apply %s statement %d: %w", path, index+1, err)
|
|
}
|
|
}
|
|
if _, err := db.ExecContext(ctx, `INSERT INTO vehicle_platform_schema_migration(version,checksum) VALUES(?,?)`, version, checksum); err != nil {
|
|
return fmt.Errorf("record migration %s: %w", version, err)
|
|
}
|
|
fmt.Printf("applied %s (%d statements)\n", path, len(statements))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isResumableMigrationDDL(err error, statement string) bool {
|
|
var mysqlErr *mysql.MySQLError
|
|
if !errors.As(err, &mysqlErr) {
|
|
return false
|
|
}
|
|
upper := strings.ToUpper(strings.TrimSpace(statement))
|
|
duplicateColumn := mysqlErr.Number == 1060 && strings.Contains(upper, "ALTER TABLE") && strings.Contains(upper, "ADD COLUMN")
|
|
duplicateIndex := mysqlErr.Number == 1061 && strings.HasPrefix(upper, "CREATE INDEX")
|
|
return duplicateColumn || duplicateIndex
|
|
}
|
|
|
|
// splitSQL intentionally supports the platform's forward-only DDL files. Those
|
|
// files contain no procedures or quoted semicolons; rejecting empty fragments
|
|
// keeps deployment output deterministic without enabling multiStatements in DSN.
|
|
func splitSQL(contents string) []string {
|
|
lines := strings.Split(contents, "\n")
|
|
withoutComments := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
|
continue
|
|
}
|
|
withoutComments = append(withoutComments, line)
|
|
}
|
|
contents = strings.Join(withoutComments, "\n")
|
|
fragments := strings.Split(contents, ";")
|
|
statements := make([]string, 0, len(fragments))
|
|
for _, fragment := range fragments {
|
|
statement := strings.TrimSpace(fragment)
|
|
if statement != "" {
|
|
statements = append(statements, statement)
|
|
}
|
|
}
|
|
return statements
|
|
}
|