Files
egutierrez 35a49174ca 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>
2026-04-13 02:01:40 +02:00

144 lines
4.0 KiB
Go

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()
}