Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/cmd/platform-migrate/main.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
}