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:
2026-04-13 02:01:40 +02:00
parent ec36278c7b
commit 35a49174ca
13 changed files with 1166 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
package infra
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
var migrationFilePattern = regexp.MustCompile(`^(\d+)_[a-zA-Z0-9_]+\.sql$`)
var migrationNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
// MigrationCreate creates a new migration file in dir with the given name.
// It calculates the next version by scanning existing .sql files in dir.
// The filename follows the pattern NNN_name.sql (e.g. 003_add_index.sql).
// Returns the absolute path of the created file.
func MigrationCreate(dir, name string) (string, error) {
if !migrationNamePattern.MatchString(name) {
return "", fmt.Errorf("migration_create: name %q must match [a-zA-Z][a-zA-Z0-9_]*", name)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("migration_create: cannot create directory %q: %w", dir, err)
}
next, err := nextMigrationVersion(dir)
if err != nil {
return "", fmt.Errorf("migration_create: %w", err)
}
filename := fmt.Sprintf("%03d_%s.sql", next, name)
path := filepath.Join(dir, filename)
template := fmt.Sprintf("-- %s\n\n-- +up\n\n\n-- +down\n\n", filename)
if err := os.WriteFile(path, []byte(template), 0o644); err != nil {
return "", fmt.Errorf("migration_create: cannot write file %q: %w", path, err)
}
return path, nil
}
// nextMigrationVersion returns the next version number by scanning .sql files in dir.
// Returns 1 if the directory is empty or has no migration files.
func nextMigrationVersion(dir string) (int, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 1, nil
}
return 0, fmt.Errorf("cannot read directory: %w", err)
}
max := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
continue
}
if !migrationFilePattern.MatchString(name) {
continue
}
idx := strings.Index(name, "_")
if idx < 0 {
continue
}
v, err := strconv.Atoi(name[:idx])
if err != nil {
continue
}
if v > max {
max = v
}
}
return max + 1, nil
}
+55
View File
@@ -0,0 +1,55 @@
---
name: migration_create
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MigrationCreate(dir string, name string) (string, error)"
description: "Crea un archivo .sql de migracion con template -- +up / -- +down en el directorio indicado. Calcula automaticamente el siguiente numero de version escaneando archivos .sql existentes. Retorna el path absoluto del archivo creado."
tags: [migration, database, sql, schema, sqlite, create, file]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "os", "path/filepath", "regexp", "strconv", "strings"]
params:
- name: dir
desc: "directorio donde crear el archivo de migracion (se crea si no existe, ej: apps/my_app/migrations)"
- name: name
desc: "nombre descriptivo de la migracion en snake_case (ej: create_users, add_email_column). Solo letras, numeros y underscore."
output: "path absoluto del archivo .sql creado (ej: apps/my_app/migrations/001_create_users.sql)"
tested: true
tests:
- "directorio vacio crea archivo con version 001"
- "directorio con migraciones existentes calcula siguiente version"
- "nombre invalido retorna error"
- "directorio inexistente se crea automaticamente"
test_file_path: "functions/infra/migration_create_test.go"
file_path: "functions/infra/migration_create.go"
---
## Ejemplo
```go
// Primera migracion
path, err := MigrationCreate("apps/my_app/migrations", "create_users")
// path = "apps/my_app/migrations/001_create_users.sql"
// Contenido del archivo:
// -- 001_create_users.sql
//
// -- +up
//
//
// -- +down
//
// Segunda migracion (ya existe 001)
path2, err := MigrationCreate("apps/my_app/migrations", "add_email")
// path2 = "apps/my_app/migrations/002_add_email.sql"
```
## Notas
Crea el directorio si no existe (`os.MkdirAll`). La version se calcula encontrando el maximo numero existente entre los archivos `NNN_*.sql` del directorio y sumando 1. El template generado tiene los marcadores vacios para que el desarrollador complete el SQL. Nombre valido: `^[a-zA-Z][a-zA-Z0-9_]*$` — no puede empezar con numero ni contener espacios o guiones.
+78
View File
@@ -0,0 +1,78 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestMigrationCreate(t *testing.T) {
t.Run("directorio vacio crea archivo con version 001", func(t *testing.T) {
dir := t.TempDir()
path, err := MigrationCreate(dir, "create_users")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
base := filepath.Base(path)
if base != "001_create_users.sql" {
t.Errorf("filename: got %q, want %q", base, "001_create_users.sql")
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("cannot read created file: %v", err)
}
if !strings.Contains(string(content), "-- +up") {
t.Errorf("file missing -- +up marker")
}
if !strings.Contains(string(content), "-- +down") {
t.Errorf("file missing -- +down marker")
}
})
t.Run("directorio con migraciones existentes calcula siguiente version", func(t *testing.T) {
dir := t.TempDir()
// Create existing files
os.WriteFile(filepath.Join(dir, "001_create_users.sql"), []byte("-- +up\nCREATE TABLE users (id TEXT);\n-- +down\nDROP TABLE users;\n"), 0o644)
os.WriteFile(filepath.Join(dir, "002_add_email.sql"), []byte("-- +up\nALTER TABLE users ADD COLUMN email TEXT;\n-- +down\n\n"), 0o644)
path, err := MigrationCreate(dir, "add_index")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
base := filepath.Base(path)
if base != "003_add_index.sql" {
t.Errorf("filename: got %q, want %q", base, "003_add_index.sql")
}
})
t.Run("nombre invalido retorna error", func(t *testing.T) {
dir := t.TempDir()
_, err := MigrationCreate(dir, "123invalid")
if err == nil {
t.Fatal("expected error for invalid name, got nil")
}
})
t.Run("directorio inexistente se crea automaticamente", func(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "nested", "migrations")
path, err := MigrationCreate(dir, "init")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
t.Errorf("directory was not created: %s", dir)
}
if filepath.Dir(path) != dir {
t.Errorf("file not in expected dir: %s", path)
}
})
}
+79
View File
@@ -0,0 +1,79 @@
package infra
import (
"database/sql"
"fmt"
"time"
)
// MigrationDown reverts the last n applied migrations by executing their down_sql
// from the _migrations table. Migrations are reverted in reverse version order
// (highest version first). Each reversion runs in its own transaction.
// Returns the list of reverted migrations. If n <= 0, no migrations are reverted.
func MigrationDown(db *sql.DB, n int) ([]Migration, error) {
if n <= 0 {
return nil, nil
}
// Fetch last n applied migrations in descending order
const query = `
SELECT version, name, up_sql, down_sql, applied_at
FROM _migrations
ORDER BY version DESC
LIMIT ?`
rows, err := db.Query(query, n)
if err != nil {
return nil, fmt.Errorf("migration_down: query _migrations: %w", err)
}
defer rows.Close()
var toRevert []Migration
for rows.Next() {
var m Migration
var appliedAtStr string
if err := rows.Scan(&m.Version, &m.Name, &m.UpSQL, &m.DownSQL, &appliedAtStr); err != nil {
return nil, fmt.Errorf("migration_down: scan row: %w", err)
}
m.AppliedAt, _ = time.Parse("2006-01-02 15:04:05", appliedAtStr)
toRevert = append(toRevert, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("migration_down: rows error: %w", err)
}
// Revert each migration in its own transaction (already in DESC order)
var reverted []Migration
for _, m := range toRevert {
if err := revertMigration(db, m); err != nil {
return reverted, fmt.Errorf("migration_down: reverting version %d (%s): %w", m.Version, m.Name, err)
}
reverted = append(reverted, m)
}
return reverted, nil
}
// revertMigration executes a migration's DownSQL within a transaction and removes it
// from _migrations. If DownSQL is empty, only the record is removed.
func revertMigration(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 down SQL if present
if m.DownSQL != "" {
if _, err := tx.Exec(m.DownSQL); err != nil {
return fmt.Errorf("exec down_sql: %w", err)
}
}
// Remove the migration record
if _, err := tx.Exec("DELETE FROM _migrations WHERE version = ?", m.Version); err != nil {
return fmt.Errorf("delete from _migrations: %w", err)
}
return tx.Commit()
}
+52
View File
@@ -0,0 +1,52 @@
---
name: migration_down
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MigrationDown(db *sql.DB, n int) ([]Migration, error)"
description: "Revierte las ultimas n migraciones aplicadas ejecutando su down_sql guardado en la tabla _migrations. Las reversiones ocurren en orden inverso de version (la mas alta primero). Cada reversion corre en su propia transaccion. Retorna las migraciones revertidas."
tags: [migration, database, sql, schema, sqlite, rollback, down]
uses_functions: []
uses_types: [migration_go_infra]
returns: [migration_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "fmt", "time"]
params:
- name: db
desc: "conexion *sql.DB abierta a la base de datos SQLite con la tabla _migrations"
- name: "n"
desc: "numero de migraciones a revertir (las ultimas n en orden descendente de version). Si n <= 0 no hace nada."
output: "slice de Migration con las migraciones que fueron revertidas (en orden descendente de version)"
tested: true
tests:
- "revertir ultima migracion elimina registro y ejecuta down_sql"
- "revertir n migraciones revierte en orden descendente"
- "n cero no revierte nada"
- "base de datos sin migraciones retorna slice vacio"
test_file_path: "functions/infra/migration_down_test.go"
file_path: "functions/infra/migration_down.go"
---
## Ejemplo
```go
db, _ := SQLiteOpen("", "apps/my_app/operations.db")
defer db.Close()
// Revertir la ultima migracion aplicada
reverted, err := MigrationDown(db, 1)
if err != nil {
log.Fatalf("rollback failed: %v", err)
}
for _, m := range reverted {
fmt.Printf("Reverted: %03d_%s\n", m.Version, m.Name)
}
// Reverted: 003_add_audit_log
```
## Notas
Usa el `down_sql` almacenado en `_migrations`, no el archivo en disco. Esto garantiza que el rollback funciona aunque el archivo haya sido modificado o eliminado. Si `down_sql` esta vacio, solo se elimina el registro de `_migrations` sin ejecutar SQL. ATENCION: `MigrationDown` es destructiva — un `DROP TABLE` en el down elimina datos. Diseñada para desarrollo y no para revertir en produccion con datos vivos.
+115
View File
@@ -0,0 +1,115 @@
package infra
import (
"testing"
)
func TestMigrationDown(t *testing.T) {
t.Run("revertir ultima migracion elimina registro y ejecuta down_sql", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_create_roles.sql",
"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
if _, err := MigrationUp(db, dir); err != nil {
t.Fatalf("up failed: %v", err)
}
reverted, err := MigrationDown(db, 1)
if err != nil {
t.Fatalf("down failed: %v", err)
}
if len(reverted) != 1 {
t.Errorf("expected 1 reverted, got %d", len(reverted))
}
if reverted[0].Version != 2 {
t.Errorf("expected version 2 reverted, got %d", reverted[0].Version)
}
// roles table should be gone
if err := db.QueryRow("SELECT COUNT(*) FROM roles").Err(); err == nil {
t.Error("roles table should not exist after down")
}
// users table should still exist
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
t.Errorf("users table should still exist: %v", err)
}
})
t.Run("revertir n migraciones revierte en orden descendente", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_create_roles.sql",
"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
writeMigrationFile(t, dir, "003_create_logs.sql",
"-- +up\nCREATE TABLE logs (id INTEGER PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS logs;\n")
if _, err := MigrationUp(db, dir); err != nil {
t.Fatalf("up failed: %v", err)
}
reverted, err := MigrationDown(db, 2)
if err != nil {
t.Fatalf("down 2 failed: %v", err)
}
if len(reverted) != 2 {
t.Errorf("expected 2 reverted, got %d", len(reverted))
}
// Should be reverted in descending order: 3, 2
if reverted[0].Version != 3 || reverted[1].Version != 2 {
t.Errorf("reverted order wrong: got %d, %d", reverted[0].Version, reverted[1].Version)
}
// users table should still exist (migration 1 not reverted)
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
t.Errorf("users table should still exist: %v", err)
}
})
t.Run("n cero no revierte nada", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
if _, err := MigrationUp(db, dir); err != nil {
t.Fatalf("up failed: %v", err)
}
reverted, err := MigrationDown(db, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reverted) != 0 {
t.Errorf("expected 0 reverted for n=0, got %d", len(reverted))
}
})
t.Run("base de datos sin migraciones retorna slice vacio", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
// Apply to create _migrations table
if _, err := MigrationUp(db, dir); err != nil {
t.Fatalf("up failed: %v", err)
}
reverted, err := MigrationDown(db, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reverted) != 0 {
t.Errorf("expected 0 reverted from empty DB, got %d", len(reverted))
}
})
}
@@ -0,0 +1,114 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
// TestMigrationIntegration covers the full create -> up -> status -> down -> status cycle.
func TestMigrationIntegration(t *testing.T) {
t.Run("ciclo completo create up status down status", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
// Step 1: Create migration files using MigrationCreate
path1, err := MigrationCreate(dir, "create_users")
if err != nil {
t.Fatalf("create 001 failed: %v", err)
}
path2, err := MigrationCreate(dir, "create_roles")
if err != nil {
t.Fatalf("create 002 failed: %v", err)
}
// Fill in actual SQL
sql1 := "-- 001_create_users.sql\n\n-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);\n\n-- +down\nDROP TABLE IF EXISTS users;\n"
if err := os.WriteFile(path1, []byte(sql1), 0o644); err != nil {
t.Fatalf("write sql1: %v", err)
}
sql2 := "-- 002_create_roles.sql\n\n-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY, label TEXT NOT NULL);\n\n-- +down\nDROP TABLE IF EXISTS roles;\n"
if err := os.WriteFile(path2, []byte(sql2), 0o644); err != nil {
t.Fatalf("write sql2: %v", err)
}
// Step 2: Parse and validate
content1, _ := os.ReadFile(filepath.Join(dir, "001_create_users.sql"))
content2, _ := os.ReadFile(filepath.Join(dir, "002_create_roles.sql"))
m1, err := MigrationParse("001_create_users.sql", string(content1))
if err != nil {
t.Fatalf("parse 001: %v", err)
}
m2, err := MigrationParse("002_create_roles.sql", string(content2))
if err != nil {
t.Fatalf("parse 002: %v", err)
}
validationErrs := MigrationValidate([]Migration{m1, m2})
if len(validationErrs) > 0 {
t.Fatalf("validate failed: %v", validationErrs)
}
// Step 3: Apply up
applied, err := MigrationUp(db, dir)
if err != nil {
t.Fatalf("up failed: %v", err)
}
if len(applied) != 2 {
t.Errorf("up: expected 2 applied, got %d", len(applied))
}
// Step 4: Check status — all applied
statuses, err := MigrationGetStatus(db, dir)
if err != nil {
t.Fatalf("status after up failed: %v", err)
}
if len(statuses) != 2 {
t.Errorf("status: expected 2, got %d", len(statuses))
}
for _, s := range statuses {
if !s.Applied {
t.Errorf("version %d should be applied", s.Version)
}
}
// Step 5: Revert the last migration
reverted, err := MigrationDown(db, 1)
if err != nil {
t.Fatalf("down failed: %v", err)
}
if len(reverted) != 1 || reverted[0].Version != 2 {
t.Errorf("down: expected version 2, got %v", reverted)
}
// Step 6: Check status — one applied, one pending
statuses2, err := MigrationGetStatus(db, dir)
if err != nil {
t.Fatalf("status after down failed: %v", err)
}
if len(statuses2) != 2 {
t.Errorf("status2: expected 2, got %d", len(statuses2))
}
// Version 1 should be applied, version 2 pending
for _, s := range statuses2 {
switch s.Version {
case 1:
if !s.Applied {
t.Errorf("version 1 should still be applied")
}
case 2:
if s.Applied {
t.Errorf("version 2 should be pending after down")
}
}
}
// Step 7: Re-apply — should apply version 2 again
applied2, err := MigrationUp(db, dir)
if err != nil {
t.Fatalf("second up failed: %v", err)
}
if len(applied2) != 1 || applied2[0].Version != 2 {
t.Errorf("second up: expected version 2, got %v", applied2)
}
})
}
+106
View File
@@ -0,0 +1,106 @@
package infra
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// MigrationGetStatus crosses migration files on disk with records in _migrations,
// returning a sorted list of MigrationStatus entries ordered by version ascending.
// Migrations present in _migrations but not on disk are included with Applied=true
// and marked as orphaned in their Name (suffix " (orphaned)").
// If _migrations does not exist yet, all file-based migrations are returned as pending.
func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error) {
// Load files from disk (non-fatal if dir is empty or missing)
fileMigrations, err := loadMigrationsFromDir(dir)
if err != nil && !strings.Contains(err.Error(), "cannot read migrations directory") {
return nil, fmt.Errorf("migration_status: %w", err)
}
if err != nil {
// Directory doesn't exist — treat as empty
fileMigrations = nil
}
// Build a map from version to file migration
fileMap := make(map[int]Migration, len(fileMigrations))
for _, m := range fileMigrations {
fileMap[m.Version] = m
}
// Load applied migrations from DB (_migrations may not exist yet)
dbMap, err := loadAppliedMigrations(db)
if err != nil {
return nil, fmt.Errorf("migration_status: %w", err)
}
// Merge: collect all known versions
allVersions := make(map[int]struct{})
for v := range fileMap {
allVersions[v] = struct{}{}
}
for v := range dbMap {
allVersions[v] = struct{}{}
}
// Build status list
var statuses []MigrationStatus
for v := range allVersions {
dbEntry, inDB := dbMap[v]
fileEntry, inFile := fileMap[v]
name := fileEntry.Name
if !inFile && inDB {
// Orphaned: applied but no file on disk
name = dbEntry.Name + " (orphaned)"
}
statuses = append(statuses, MigrationStatus{
Version: v,
Name: name,
Applied: inDB,
AppliedAt: dbEntry.AppliedAt, // zero value if not in DB
})
}
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Version < statuses[j].Version
})
return statuses, nil
}
// migrationDBRecord holds data from the _migrations table.
type migrationDBRecord struct {
Name string
AppliedAt time.Time
}
// loadAppliedMigrations queries _migrations and returns a map of version -> record.
// If the table does not exist, returns an empty map without error.
func loadAppliedMigrations(db *sql.DB) (map[int]migrationDBRecord, error) {
result := make(map[int]migrationDBRecord)
rows, err := db.Query("SELECT version, name, applied_at FROM _migrations ORDER BY version ASC")
if err != nil {
// Table doesn't exist yet — return empty map
if strings.Contains(err.Error(), "no such table") {
return result, nil
}
return nil, fmt.Errorf("query _migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var version int
var name, appliedAtStr string
if err := rows.Scan(&version, &name, &appliedAtStr); err != nil {
return nil, fmt.Errorf("scan _migrations row: %w", err)
}
appliedAt, _ := time.Parse("2006-01-02 15:04:05", appliedAtStr)
result[version] = migrationDBRecord{Name: name, AppliedAt: appliedAt}
}
return result, rows.Err()
}
+57
View File
@@ -0,0 +1,57 @@
---
name: migration_status
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error)"
description: "Cruza los archivos .sql del directorio con los registros en _migrations y retorna una lista ordenada por version con el estado de cada migracion (applied/pending). Migraciones en BD pero sin archivo en disco se marcan como orphaned. Si _migrations no existe aun, todas las migraciones del directorio aparecen como pending."
tags: [migration, database, sql, schema, sqlite, status, list]
uses_functions: [migration_parse_go_infra]
uses_types: [migration_status_go_infra]
returns: [migration_status_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "fmt", "sort", "strings", "time"]
params:
- name: db
desc: "conexion *sql.DB abierta a la base de datos SQLite (puede no tener _migrations aun)"
- name: dir
desc: "path al directorio con los archivos .sql de migracion (puede no existir)"
output: "slice de MigrationStatus ordenado por version ascendente con Applied y AppliedAt para cada migracion"
tested: true
tests:
- "migraciones en disco pero no en BD aparecen como pending"
- "migraciones aplicadas aparecen con Applied=true y AppliedAt"
- "migraciones aplicadas sin archivo en disco aparecen como orphaned"
- "base de datos sin tabla _migrations retorna todas como pending"
test_file_path: "functions/infra/migration_status_test.go"
file_path: "functions/infra/migration_status.go"
---
## Ejemplo
```go
db, _ := SQLiteOpen("", "apps/my_app/operations.db")
defer db.Close()
statuses, err := MigrationGetStatus(db, "apps/my_app/migrations")
if err != nil {
log.Fatal(err)
}
for _, s := range statuses {
if s.Applied {
fmt.Printf("%03d %-30s applied at %s\n", s.Version, s.Name, s.AppliedAt.Format(time.RFC3339))
} else {
fmt.Printf("%03d %-30s pending\n", s.Version, s.Name)
}
}
// 001 create_users applied at 2026-04-13T10:30:00Z
// 002 add_roles applied at 2026-04-13T10:30:00Z
// 003 add_audit_log pending
```
## Notas
Combina informacion de disco (archivos .sql) y BD (tabla _migrations) para dar una vision completa del estado. Las migraciones "orphaned" son aquellas que aparecen en `_migrations` pero ya no tienen archivo en disco — esto puede indicar que el archivo fue eliminado despues de aplicarse. La tabla `_migrations` se crea con `MigrationUp`; si no existe aun, `MigrationStatus` las trata todas como pending.
+110
View File
@@ -0,0 +1,110 @@
package infra
import (
"testing"
)
func TestMigrationStatus(t *testing.T) {
t.Run("migraciones en disco pero no en BD aparecen como pending", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_create_roles.sql",
"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
statuses, err := MigrationGetStatus(db, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 2 {
t.Fatalf("expected 2 statuses, got %d", len(statuses))
}
for _, s := range statuses {
if s.Applied {
t.Errorf("version %d should be pending, got applied", s.Version)
}
}
})
t.Run("migraciones aplicadas aparecen con Applied=true y AppliedAt", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_create_roles.sql",
"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
if _, err := MigrationUp(db, dir); err != nil {
t.Fatalf("up failed: %v", err)
}
statuses, err := MigrationGetStatus(db, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 2 {
t.Fatalf("expected 2 statuses, got %d", len(statuses))
}
for _, s := range statuses {
if !s.Applied {
t.Errorf("version %d should be applied, got pending", s.Version)
}
if s.AppliedAt.IsZero() {
t.Errorf("version %d AppliedAt should not be zero", s.Version)
}
}
})
t.Run("migraciones aplicadas sin archivo en disco aparecen como orphaned", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
if _, err := MigrationUp(db, dir); err != nil {
t.Fatalf("up failed: %v", err)
}
// Remove file from disk but migration is applied in DB
// Now check status with an empty dir
emptyDir := t.TempDir()
statuses, err := MigrationGetStatus(db, emptyDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 1 {
t.Fatalf("expected 1 status (orphaned), got %d", len(statuses))
}
s := statuses[0]
if !s.Applied {
t.Errorf("orphaned migration should have Applied=true")
}
if !containsStr(s.Name, "orphaned") {
t.Errorf("orphaned migration name should contain 'orphaned', got %q", s.Name)
}
})
t.Run("base de datos sin tabla _migrations retorna todas como pending", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
// Do NOT call MigrationUp — _migrations table doesn't exist
statuses, err := MigrationGetStatus(db, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
if statuses[0].Applied {
t.Errorf("migration should be pending when _migrations does not exist")
}
})
}
+143
View File
@@ -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()
}
+52
View File
@@ -0,0 +1,52 @@
---
name: migration_up
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func MigrationUp(db *sql.DB, dir string) ([]Migration, error)"
description: "Lee los archivos .sql del directorio, crea la tabla _migrations si no existe, y ejecuta las migraciones pendientes en orden de version. Cada migracion corre en su propia transaccion. Retorna la lista de migraciones aplicadas en esta llamada."
tags: [migration, database, sql, schema, sqlite, apply, up]
uses_functions: [migration_parse_go_infra]
uses_types: [migration_go_infra]
returns: [migration_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "fmt", "os", "path/filepath", "sort", "strings"]
params:
- name: db
desc: "conexion *sql.DB abierta a la base de datos SQLite donde aplicar las migraciones"
- name: dir
desc: "path al directorio que contiene los archivos .sql de migracion (ej: apps/my_app/migrations)"
output: "slice de Migration con las migraciones que fueron aplicadas en esta llamada (puede estar vacio si todo ya estaba aplicado)"
tested: true
tests:
- "base de datos vacia aplica todas las migraciones"
- "migraciones ya aplicadas se omiten"
- "migracion con SQL invalido retorna error y deja las anteriores aplicadas"
- "directorio sin archivos sql no aplica nada"
test_file_path: "functions/infra/migration_up_test.go"
file_path: "functions/infra/migration_up.go"
---
## Ejemplo
```go
db, _ := SQLiteOpen("", "apps/my_app/operations.db")
defer db.Close()
applied, err := MigrationUp(db, "apps/my_app/migrations")
if err != nil {
log.Fatalf("migration failed: %v", err)
}
for _, m := range applied {
fmt.Printf("Applied: %03d_%s\n", m.Version, m.Name)
}
// Applied: 001_create_users
// Applied: 002_add_roles
```
## Notas
Crea `_migrations` con `CREATE TABLE IF NOT EXISTS` — es idempotente. Cada migracion se ejecuta en una transaccion independiente: si falla la migracion 3, las 1 y 2 ya aplicadas permanecen. El `up_sql` y `down_sql` se guardan en `_migrations` para que el rollback funcione aunque el archivo sea modificado o eliminado posteriormente. SQLite con el driver mattn/go-sqlite3 soporta multiples sentencias en un solo `Exec`.
+122
View File
@@ -0,0 +1,122 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("cannot open test DB: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func writeMigrationFile(t *testing.T, dir, filename, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644); err != nil {
t.Fatalf("cannot write migration file %s: %v", filename, err)
}
}
func TestMigrationUp(t *testing.T) {
t.Run("base de datos vacia aplica todas las migraciones", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_create_roles.sql",
"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
applied, err := MigrationUp(db, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(applied) != 2 {
t.Errorf("applied count: got %d, want 2", len(applied))
}
if applied[0].Version != 1 || applied[1].Version != 2 {
t.Errorf("applied versions: got %v", []int{applied[0].Version, applied[1].Version})
}
// Verify tables were created
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
t.Errorf("users table not created: %v", err)
}
if err := db.QueryRow("SELECT COUNT(*) FROM roles").Scan(&count); err != nil {
t.Errorf("roles table not created: %v", err)
}
})
t.Run("migraciones ya aplicadas se omiten", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_create_roles.sql",
"-- +up\nCREATE TABLE roles (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS roles;\n")
// Apply all
_, err := MigrationUp(db, dir)
if err != nil {
t.Fatalf("first up failed: %v", err)
}
// Apply again — should apply nothing
applied, err := MigrationUp(db, dir)
if err != nil {
t.Fatalf("second up failed: %v", err)
}
if len(applied) != 0 {
t.Errorf("expected 0 applied on second run, got %d", len(applied))
}
})
t.Run("migracion con SQL invalido retorna error y deja las anteriores aplicadas", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
writeMigrationFile(t, dir, "001_create_users.sql",
"-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE IF EXISTS users;\n")
writeMigrationFile(t, dir, "002_bad_sql.sql",
"-- +up\nTHIS IS NOT VALID SQL!!!;\n-- +down\n\n")
applied, err := MigrationUp(db, dir)
if err == nil {
t.Fatal("expected error for invalid SQL, got nil")
}
// Version 1 should be applied
if len(applied) != 1 || applied[0].Version != 1 {
t.Errorf("expected [1] applied before failure, got versions: %v", applied)
}
// users table should still exist (migration 1 committed)
var count int
if err2 := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err2 != nil {
t.Errorf("users table not present after partial apply: %v", err2)
}
})
t.Run("directorio sin archivos sql no aplica nada", func(t *testing.T) {
db := openTestDB(t)
dir := t.TempDir()
applied, err := MigrationUp(db, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(applied) != 0 {
t.Errorf("expected 0 applied for empty dir, got %d", len(applied))
}
})
}