fix(fn-run): propagar stdout/stderr de bash functions library-style #1
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user