feat: funciones impuras migration_create, migration_up, migration_down, migration_status
Fase 2 del issue 0015. MigrationCreate (crea archivo .sql template con version auto-calculada), MigrationUp (aplica migraciones pendientes en transacciones individuales), MigrationDown (revierte ultimas N via down_sql de _migrations), MigrationGetStatus (cruza disco con BD, detecta orphaned). Tests de integracion: ciclo completo create->up->status->down->status. 26 tests, todos pasan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const createMigrationsTable = `
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
up_sql TEXT NOT NULL,
|
||||
down_sql TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`
|
||||
|
||||
// MigrationUp reads all .sql migration files from dir, creates the _migrations
|
||||
// table if it does not exist, and applies any pending migrations in version order.
|
||||
// Each migration runs in its own transaction. Returns the list of applied migrations.
|
||||
// If a migration fails, execution stops and the error is returned along with any
|
||||
// migrations that were successfully applied before the failure.
|
||||
func MigrationUp(db *sql.DB, dir string) ([]Migration, error) {
|
||||
// Ensure _migrations table exists
|
||||
if _, err := db.Exec(createMigrationsTable); err != nil {
|
||||
return nil, fmt.Errorf("migration_up: cannot create _migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Load files from directory
|
||||
allMigrations, err := loadMigrationsFromDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migration_up: %w", err)
|
||||
}
|
||||
|
||||
// Fetch already-applied versions
|
||||
applied, err := appliedVersions(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migration_up: %w", err)
|
||||
}
|
||||
|
||||
// Filter pending migrations
|
||||
var pending []Migration
|
||||
for _, m := range allMigrations {
|
||||
if !applied[m.Version] {
|
||||
pending = append(pending, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply each pending migration in its own transaction
|
||||
var result []Migration
|
||||
for _, m := range pending {
|
||||
if err := applyMigration(db, m); err != nil {
|
||||
return result, fmt.Errorf("migration_up: applying version %d (%s): %w", m.Version, m.Name, err)
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadMigrationsFromDir reads and parses all .sql migration files from dir,
|
||||
// returning them sorted by version ascending.
|
||||
func loadMigrationsFromDir(dir string) ([]Migration, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read migrations directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
var migrations []Migration
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read %q: %w", path, err)
|
||||
}
|
||||
|
||||
m, err := MigrationParse(name, string(content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error in %q: %w", name, err)
|
||||
}
|
||||
migrations = append(migrations, m)
|
||||
}
|
||||
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].Version < migrations[j].Version
|
||||
})
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// appliedVersions returns a set of version numbers already recorded in _migrations.
|
||||
func appliedVersions(db *sql.DB) (map[int]bool, error) {
|
||||
rows, err := db.Query("SELECT version FROM _migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query _migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[int]bool)
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("scan version: %w", err)
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
return applied, rows.Err()
|
||||
}
|
||||
|
||||
// applyMigration executes a migration's UpSQL within a transaction and records it
|
||||
// in _migrations. If UpSQL contains multiple statements, they are executed sequentially
|
||||
// using db.Exec (SQLite supports multiple statements via the C driver).
|
||||
func applyMigration(db *sql.DB, m Migration) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
// Execute the up SQL (may contain multiple statements)
|
||||
if _, err := tx.Exec(m.UpSQL); err != nil {
|
||||
return fmt.Errorf("exec up_sql: %w", err)
|
||||
}
|
||||
|
||||
// Record the migration
|
||||
const insertSQL = `INSERT INTO _migrations (version, name, up_sql, down_sql) VALUES (?, ?, ?, ?)`
|
||||
if _, err := tx.Exec(insertSQL, m.Version, m.Name, m.UpSQL, m.DownSQL); err != nil {
|
||||
return fmt.Errorf("record migration: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
Reference in New Issue
Block a user