From 686c310466b1f1033c4fdaeaccc1005a89b31bd2 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 5 Feb 2026 23:30:33 +0300 Subject: [PATCH] Auto-apply migrations on startup --- cmd/reanimator-api/main.go | 5 ++ internal/repository/migrate/apply.go | 112 +++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 internal/repository/migrate/apply.go diff --git a/cmd/reanimator-api/main.go b/cmd/reanimator-api/main.go index 541151b..b753645 100644 --- a/cmd/reanimator-api/main.go +++ b/cmd/reanimator-api/main.go @@ -13,6 +13,7 @@ import ( "reanimator/internal/api" "reanimator/internal/config" "reanimator/internal/repository" + "reanimator/internal/repository/migrate" ) func main() { @@ -28,6 +29,10 @@ func main() { defer func() { _ = db.Close() }() + if err := migrate.EnsureSchema(db, cfg.MigrationsDir); err != nil { + log.Fatalf("apply migrations: %v", err) + } + log.Printf("database schema ready") srv := api.NewServer(cfg.HTTPAddr, cfg.ReadTimeout, cfg.WriteTimeout, db) diff --git a/internal/repository/migrate/apply.go b/internal/repository/migrate/apply.go new file mode 100644 index 0000000..f902da0 --- /dev/null +++ b/internal/repository/migrate/apply.go @@ -0,0 +1,112 @@ +package migrate + +import ( + "database/sql" + "fmt" + "strings" + "time" +) + +// EnsureSchema applies all "up" migrations when the database has no tables. +func EnsureSchema(db *sql.DB, dir string) error { + if err := ensureSchemaMigrations(db); err != nil { + return err + } + + empty, err := isDatabaseEmpty(db) + if err != nil { + return err + } + + runner := Runner{Dir: dir} + migrations, err := runner.Load(Up) + if err != nil { + return err + } + if empty { + for _, migration := range migrations { + if err := execStatements(db, migration.SQL); err != nil { + return fmt.Errorf("apply %s: %w", migration.Name, err) + } + if err := recordMigration(db, migration.Name); err != nil { + return err + } + } + return nil + } + + applied, err := loadAppliedMigrations(db) + if err != nil { + return err + } + for _, migration := range migrations { + if applied[migration.Name] { + continue + } + if err := execStatements(db, migration.SQL); err != nil { + return fmt.Errorf("apply %s: %w", migration.Name, err) + } + if err := recordMigration(db, migration.Name); err != nil { + return err + } + } + + return nil +} + +func isDatabaseEmpty(db *sql.DB) (bool, error) { + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()`).Scan(&count); err != nil { + return false, err + } + return count == 0, nil +} + +func execStatements(db *sql.DB, sqlText string) error { + statements := strings.Split(sqlText, ";") + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func ensureSchemaMigrations(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMP NOT NULL + )`) + return err +} + +func loadAppliedMigrations(db *sql.DB) (map[string]bool, error) { + rows, err := db.Query(`SELECT name FROM schema_migrations`) + if err != nil { + return nil, err + } + defer rows.Close() + + applied := map[string]bool{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + applied[name] = true + } + if err := rows.Err(); err != nil { + return nil, err + } + return applied, nil +} + +func recordMigration(db *sql.DB, name string) error { + _, err := db.Exec(`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, name, time.Now().UTC()) + return err +}