diff --git a/functions/infra/migration_create.go b/functions/infra/migration_create.go new file mode 100644 index 00000000..865fea26 --- /dev/null +++ b/functions/infra/migration_create.go @@ -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 +} diff --git a/functions/infra/migration_create.md b/functions/infra/migration_create.md new file mode 100644 index 00000000..2a9cb34d --- /dev/null +++ b/functions/infra/migration_create.md @@ -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. diff --git a/functions/infra/migration_create_test.go b/functions/infra/migration_create_test.go new file mode 100644 index 00000000..56123bc0 --- /dev/null +++ b/functions/infra/migration_create_test.go @@ -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) + } + }) +} diff --git a/functions/infra/migration_down.go b/functions/infra/migration_down.go new file mode 100644 index 00000000..d7519fab --- /dev/null +++ b/functions/infra/migration_down.go @@ -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() +} diff --git a/functions/infra/migration_down.md b/functions/infra/migration_down.md new file mode 100644 index 00000000..8115d7b6 --- /dev/null +++ b/functions/infra/migration_down.md @@ -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. diff --git a/functions/infra/migration_down_test.go b/functions/infra/migration_down_test.go new file mode 100644 index 00000000..e38fb869 --- /dev/null +++ b/functions/infra/migration_down_test.go @@ -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)) + } + }) +} diff --git a/functions/infra/migration_integration_test.go b/functions/infra/migration_integration_test.go new file mode 100644 index 00000000..95853cc2 --- /dev/null +++ b/functions/infra/migration_integration_test.go @@ -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) + } + }) +} diff --git a/functions/infra/migration_status.go b/functions/infra/migration_status.go new file mode 100644 index 00000000..45ce1f3d --- /dev/null +++ b/functions/infra/migration_status.go @@ -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() +} diff --git a/functions/infra/migration_status.md b/functions/infra/migration_status.md new file mode 100644 index 00000000..c8352d10 --- /dev/null +++ b/functions/infra/migration_status.md @@ -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. diff --git a/functions/infra/migration_status_test.go b/functions/infra/migration_status_test.go new file mode 100644 index 00000000..064c7e15 --- /dev/null +++ b/functions/infra/migration_status_test.go @@ -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") + } + }) +} diff --git a/functions/infra/migration_up.go b/functions/infra/migration_up.go new file mode 100644 index 00000000..83fa0c60 --- /dev/null +++ b/functions/infra/migration_up.go @@ -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() +} diff --git a/functions/infra/migration_up.md b/functions/infra/migration_up.md new file mode 100644 index 00000000..1aa5f4dd --- /dev/null +++ b/functions/infra/migration_up.md @@ -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`. diff --git a/functions/infra/migration_up_test.go b/functions/infra/migration_up_test.go new file mode 100644 index 00000000..f0ea377e --- /dev/null +++ b/functions/infra/migration_up_test.go @@ -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)) + } + }) +}