merge: issue/0015-db-migrations — SQL migration system

# Conflicts:
#	registry.db
This commit is contained in:
2026-04-13 02:05:31 +02:00
23 changed files with 2091 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
package infra
import "time"
// Migration represents a migration parsed from a .sql file.
type Migration struct {
Version int // sequential number (1, 2, 3...)
Name string // descriptive name (create_entities, add_status_column)
UpSQL string // SQL block to apply the migration
DownSQL string // SQL block to revert the migration
AppliedAt time.Time // zero value if not yet applied
}
// MigrationStatus represents the state of a migration relative to a database.
type MigrationStatus struct {
Version int // sequential number
Name string // descriptive name
Applied bool // true if already applied in the database
AppliedAt time.Time // when it was applied (zero value if pending)
}
+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)
}
})
}
+111
View File
@@ -0,0 +1,111 @@
package infra
import (
"fmt"
"strconv"
"strings"
)
// MigrationParse parses a migration filename and its SQL content into a Migration.
// The filename must follow the pattern NNN_name.sql (e.g. 001_create_users.sql).
// The content must contain a -- +up marker; -- +down is optional but recommended.
// Returns error if the filename format is invalid or the -- +up block is missing.
func MigrationParse(filename, content string) (Migration, error) {
// Strip path prefix if any
base := filename
if idx := strings.LastIndex(base, "/"); idx >= 0 {
base = base[idx+1:]
}
if idx := strings.LastIndex(base, "\\"); idx >= 0 {
base = base[idx+1:]
}
// Remove .sql extension
name := base
if strings.HasSuffix(strings.ToLower(name), ".sql") {
name = name[:len(name)-4]
}
// Split on first underscore to get version and descriptive name
idx := strings.Index(name, "_")
if idx < 0 {
return Migration{}, fmt.Errorf("migration_parse: filename %q must follow pattern NNN_name.sql", filename)
}
versionStr := name[:idx]
descriptiveName := name[idx+1:]
version, err := strconv.Atoi(versionStr)
if err != nil || version <= 0 {
return Migration{}, fmt.Errorf("migration_parse: filename %q version %q must be a positive integer", filename, versionStr)
}
if descriptiveName == "" {
return Migration{}, fmt.Errorf("migration_parse: filename %q must have a non-empty descriptive name after the version", filename)
}
// Parse up/down blocks
upSQL, downSQL, err := parseMigrationBlocks(content)
if err != nil {
return Migration{}, fmt.Errorf("migration_parse: %w", err)
}
return Migration{
Version: version,
Name: descriptiveName,
UpSQL: upSQL,
DownSQL: downSQL,
}, nil
}
// parseMigrationBlocks splits SQL content by -- +up and -- +down markers.
// Returns (upSQL, downSQL, error). The -- +up block is required.
func parseMigrationBlocks(content string) (string, string, error) {
const markerUp = "-- +up"
const markerDown = "-- +down"
// Normalize line endings
content = strings.ReplaceAll(content, "\r\n", "\n")
upIdx := indexMarker(content, markerUp)
if upIdx < 0 {
return "", "", fmt.Errorf("missing -- +up marker in content")
}
downIdx := indexMarker(content, markerDown)
var upSQL, downSQL string
if downIdx < 0 {
// No down block
upSQL = strings.TrimSpace(content[upIdx+len(markerUp):])
} else if downIdx > upIdx {
// Normal order: up first, then down
upSQL = strings.TrimSpace(content[upIdx+len(markerUp) : downIdx])
downSQL = strings.TrimSpace(content[downIdx+len(markerDown):])
} else {
// Down before up — still valid, just unusual
downSQL = strings.TrimSpace(content[downIdx+len(markerDown) : upIdx])
upSQL = strings.TrimSpace(content[upIdx+len(markerUp):])
}
if upSQL == "" {
return "", "", fmt.Errorf("-- +up block is empty")
}
return upSQL, downSQL, nil
}
// indexMarker finds the index of a marker at the start of any line in content.
func indexMarker(content, marker string) int {
lines := strings.Split(content, "\n")
pos := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == marker {
return pos
}
pos += len(line) + 1 // +1 for the newline
}
return -1
}
+59
View File
@@ -0,0 +1,59 @@
---
name: migration_parse
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func MigrationParse(filename string, content string) (Migration, error)"
description: "Parsea el nombre de archivo y el contenido SQL de una migracion. Extrae version y nombre del filename (patron NNN_nombre.sql) y separa bloques up/down por marcadores -- +up / -- +down. Error si el formato es invalido o falta el bloque up."
tags: [migration, database, sql, schema, sqlite, parse]
uses_functions: []
uses_types: [Migration_go_infra]
returns: [Migration_go_infra]
returns_optional: false
error_type: ""
imports: ["fmt", "strconv", "strings"]
params:
- name: filename
desc: "nombre del archivo de migracion (ej: 001_create_users.sql). Puede incluir path completo."
- name: content
desc: "contenido completo del archivo .sql con marcadores -- +up y -- +down"
output: "Migration con version, nombre, up_sql y down_sql extraidos del archivo"
tested: true
tests:
- "archivo valido con up y down retorna Migration correcta"
- "archivo sin bloque down retorna DownSQL vacio sin error"
- "filename sin separador underscore retorna error"
- "version no numerica retorna error"
- "bloque up vacio retorna error"
- "version cero retorna error"
- "nombre descriptivo vacio retorna error"
- "filename con path completo extrae nombre base"
test_file_path: "functions/infra/migration_parse_test.go"
file_path: "functions/infra/migration_parse.go"
---
## Ejemplo
```go
content := `
-- +up
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
-- +down
DROP TABLE IF EXISTS users;
`
m, err := MigrationParse("001_create_users.sql", content)
// m.Version = 1
// m.Name = "create_users"
// m.UpSQL = "CREATE TABLE users (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL\n);"
// m.DownSQL = "DROP TABLE IF EXISTS users;"
```
## Notas
Funcion pura — no hace I/O. El marcador `-- +up` es obligatorio; `-- +down` es opcional (retorna DownSQL vacio). Si el archivo tiene down antes que up, se parsea igualmente. Los bloques se recortan con `strings.TrimSpace`. El formato de version es un entero positivo con cualquier numero de digitos (001, 01, 1 son equivalentes).
+95
View File
@@ -0,0 +1,95 @@
package infra
import (
"strings"
"testing"
)
func TestMigrationParse(t *testing.T) {
t.Run("archivo valido con up y down retorna Migration correcta", func(t *testing.T) {
content := "\n-- +up\nCREATE TABLE users (id TEXT PRIMARY KEY);\n\n-- +down\nDROP TABLE IF EXISTS users;\n"
m, err := MigrationParse("001_create_users.sql", content)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.Version != 1 {
t.Errorf("Version: got %d, want 1", m.Version)
}
if m.Name != "create_users" {
t.Errorf("Name: got %q, want %q", m.Name, "create_users")
}
if !strings.Contains(m.UpSQL, "CREATE TABLE users") {
t.Errorf("UpSQL missing CREATE TABLE: %q", m.UpSQL)
}
if !strings.Contains(m.DownSQL, "DROP TABLE") {
t.Errorf("DownSQL missing DROP TABLE: %q", m.DownSQL)
}
})
t.Run("archivo sin bloque down retorna DownSQL vacio sin error", func(t *testing.T) {
content := "-- +up\nCREATE TABLE logs (id INTEGER PRIMARY KEY);\n"
m, err := MigrationParse("002_create_logs.sql", content)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.Version != 2 {
t.Errorf("Version: got %d, want 2", m.Version)
}
if m.Name != "create_logs" {
t.Errorf("Name: got %q, want %q", m.Name, "create_logs")
}
if m.DownSQL != "" {
t.Errorf("DownSQL: got %q, want empty", m.DownSQL)
}
})
t.Run("filename sin separador underscore retorna error", func(t *testing.T) {
_, err := MigrationParse("001.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("version no numerica retorna error", func(t *testing.T) {
_, err := MigrationParse("abc_create_users.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("bloque up vacio retorna error", func(t *testing.T) {
content := "-- +up\n\n-- +down\nDROP TABLE users;\n"
_, err := MigrationParse("001_create_users.sql", content)
if err == nil {
t.Fatal("expected error for empty up block, got nil")
}
})
t.Run("version cero retorna error", func(t *testing.T) {
_, err := MigrationParse("000_something.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
if err == nil {
t.Fatal("expected error for version 0, got nil")
}
})
t.Run("nombre descriptivo vacio retorna error", func(t *testing.T) {
_, err := MigrationParse("001_.sql", "-- +up\nCREATE TABLE x (id TEXT);\n")
if err == nil {
t.Fatal("expected error for empty descriptive name, got nil")
}
})
t.Run("filename con path completo extrae nombre base", func(t *testing.T) {
content := "-- +up\nCREATE TABLE x (id TEXT PRIMARY KEY);\n-- +down\nDROP TABLE x;\n"
m, err := MigrationParse("apps/my_app/migrations/003_add_index.sql", content)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.Version != 3 {
t.Errorf("Version: got %d, want 3", m.Version)
}
if m.Name != "add_index" {
t.Errorf("Name: got %q, want %q", m.Name, "add_index")
}
})
}
+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: [MigrationStatus_go_infra]
returns: [MigrationStatus_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))
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package infra
import (
"fmt"
"sort"
)
// MigrationValidate checks a slice of migrations for consistency errors.
// It verifies that:
// - Versions are sequential starting from 1 with no gaps (1, 2, 3...)
// - No duplicate versions exist
// - Each migration has non-empty UpSQL
// - Each migration has a non-empty Name
//
// Returns a slice of human-readable error strings. An empty slice means all
// migrations are valid. The function does not mutate the input slice.
func MigrationValidate(migrations []Migration) []string {
var errs []string
if len(migrations) == 0 {
return errs
}
// Work on a sorted copy to detect gaps and duplicates
sorted := make([]Migration, len(migrations))
copy(sorted, migrations)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Version < sorted[j].Version
})
// Check individual fields and collect duplicates
seen := make(map[int]int) // version -> count
for _, m := range sorted {
seen[m.Version]++
if m.Name == "" {
errs = append(errs, fmt.Sprintf("version %d has empty name", m.Version))
}
if m.UpSQL == "" {
errs = append(errs, fmt.Sprintf("version %d (%s) has empty up_sql", m.Version, m.Name))
}
}
// Report duplicates
for v, count := range seen {
if count > 1 {
errs = append(errs, fmt.Sprintf("duplicate version %d appears %d times", v, count))
}
}
// Check sequential numbering starting from 1 (no gaps)
// Build unique sorted versions
versions := make([]int, 0, len(seen))
for v := range seen {
versions = append(versions, v)
}
sort.Ints(versions)
if len(versions) > 0 && versions[0] != 1 {
errs = append(errs, fmt.Sprintf("versions must start at 1, got %d", versions[0]))
}
for i := 1; i < len(versions); i++ {
expected := versions[i-1] + 1
if versions[i] != expected {
errs = append(errs, fmt.Sprintf("gap in versions: missing %d (have %d then %d)", expected, versions[i-1], versions[i]))
}
}
return errs
}
+55
View File
@@ -0,0 +1,55 @@
---
name: migration_validate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func MigrationValidate(migrations []Migration) []string"
description: "Verifica que una secuencia de migraciones sea valida: versiones secuenciales sin huecos comenzando en 1, sin duplicados, con up_sql y nombre no vacios. Retorna lista de errores (vacia si todo OK)."
tags: [migration, database, sql, schema, sqlite, validate]
uses_functions: []
uses_types: [Migration_go_infra]
returns: []
returns_optional: false
error_type: ""
imports: ["fmt", "sort"]
params:
- name: migrations
desc: "slice de Migration a validar, puede estar desordenado"
output: "slice de strings con mensajes de error; slice vacio si todas las migraciones son validas"
tested: true
tests:
- "secuencia valida retorna sin errores"
- "secuencia vacia retorna sin errores"
- "version duplicada reporta error"
- "hueco en versiones reporta version faltante"
- "up_sql vacio reporta error"
- "nombre vacio reporta error"
- "versiones que no empiezan en 1 reportan error"
- "multiple errores se reportan todos"
test_file_path: "functions/infra/migration_validate_test.go"
file_path: "functions/infra/migration_validate.go"
---
## Ejemplo
```go
migrations := []Migration{
{Version: 1, Name: "create_users", UpSQL: "CREATE TABLE users (...);", DownSQL: "DROP TABLE users;"},
{Version: 3, Name: "add_roles", UpSQL: "CREATE TABLE roles (...);", DownSQL: "DROP TABLE roles;"},
}
errs := MigrationValidate(migrations)
// errs = ["gap in versions: missing 2 (have 1 then 3)"]
valid := []Migration{
{Version: 1, Name: "create_users", UpSQL: "CREATE TABLE users (id TEXT PRIMARY KEY);"},
{Version: 2, Name: "add_email", UpSQL: "ALTER TABLE users ADD COLUMN email TEXT;"},
}
errs = MigrationValidate(valid)
// errs = [] (empty — no errors)
```
## Notas
Funcion pura — no hace I/O, no modifica el slice de entrada. Ordena internamente una copia para detectar huecos. Todos los errores encontrados se acumulan y retornan juntos (no falla al primer error). Util antes de llamar a `MigrationUp` para detectar problemas de forma anticipada.
+153
View File
@@ -0,0 +1,153 @@
package infra
import (
"testing"
)
func makeMig(version int, name, upSQL string) Migration {
return Migration{Version: version, Name: name, UpSQL: upSQL, DownSQL: "DROP TABLE IF EXISTS t;"}
}
func TestMigrationValidate(t *testing.T) {
t.Run("secuencia valida retorna sin errores", func(t *testing.T) {
migrations := []Migration{
makeMig(1, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
makeMig(2, "add_email", "ALTER TABLE users ADD COLUMN email TEXT;"),
makeMig(3, "create_roles", "CREATE TABLE roles (id TEXT PRIMARY KEY);"),
}
errs := MigrationValidate(migrations)
if len(errs) != 0 {
t.Errorf("expected no errors, got: %v", errs)
}
})
t.Run("secuencia vacia retorna sin errores", func(t *testing.T) {
errs := MigrationValidate([]Migration{})
if len(errs) != 0 {
t.Errorf("expected no errors for empty slice, got: %v", errs)
}
})
t.Run("version duplicada reporta error", func(t *testing.T) {
migrations := []Migration{
makeMig(1, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
makeMig(1, "create_users_dup", "CREATE TABLE users2 (id TEXT PRIMARY KEY);"),
makeMig(2, "add_email", "ALTER TABLE users ADD COLUMN email TEXT;"),
}
errs := MigrationValidate(migrations)
if len(errs) == 0 {
t.Fatal("expected error for duplicate version, got none")
}
found := false
for _, e := range errs {
if containsStr(e, "duplicate") {
found = true
}
}
if !found {
t.Errorf("expected 'duplicate' in errors, got: %v", errs)
}
})
t.Run("hueco en versiones reporta version faltante", func(t *testing.T) {
migrations := []Migration{
makeMig(1, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
makeMig(3, "create_roles", "CREATE TABLE roles (id TEXT PRIMARY KEY);"),
}
errs := MigrationValidate(migrations)
if len(errs) == 0 {
t.Fatal("expected error for gap in versions, got none")
}
found := false
for _, e := range errs {
if containsStr(e, "gap") || containsStr(e, "missing 2") {
found = true
}
}
if !found {
t.Errorf("expected gap error in errors, got: %v", errs)
}
})
t.Run("up_sql vacio reporta error", func(t *testing.T) {
migrations := []Migration{
{Version: 1, Name: "create_users", UpSQL: "", DownSQL: "DROP TABLE users;"},
}
errs := MigrationValidate(migrations)
if len(errs) == 0 {
t.Fatal("expected error for empty up_sql, got none")
}
found := false
for _, e := range errs {
if containsStr(e, "empty up_sql") {
found = true
}
}
if !found {
t.Errorf("expected 'empty up_sql' in errors, got: %v", errs)
}
})
t.Run("nombre vacio reporta error", func(t *testing.T) {
migrations := []Migration{
{Version: 1, Name: "", UpSQL: "CREATE TABLE users (id TEXT PRIMARY KEY);"},
}
errs := MigrationValidate(migrations)
if len(errs) == 0 {
t.Fatal("expected error for empty name, got none")
}
found := false
for _, e := range errs {
if containsStr(e, "empty name") {
found = true
}
}
if !found {
t.Errorf("expected 'empty name' in errors, got: %v", errs)
}
})
t.Run("versiones que no empiezan en 1 reportan error", func(t *testing.T) {
migrations := []Migration{
makeMig(2, "create_users", "CREATE TABLE users (id TEXT PRIMARY KEY);"),
makeMig(3, "add_email", "ALTER TABLE users ADD COLUMN email TEXT;"),
}
errs := MigrationValidate(migrations)
if len(errs) == 0 {
t.Fatal("expected error for versions not starting at 1, got none")
}
found := false
for _, e := range errs {
if containsStr(e, "start at 1") {
found = true
}
}
if !found {
t.Errorf("expected 'start at 1' error, got: %v", errs)
}
})
t.Run("multiple errores se reportan todos", func(t *testing.T) {
migrations := []Migration{
{Version: 2, Name: "", UpSQL: ""},
{Version: 4, Name: "something", UpSQL: "CREATE TABLE x (id TEXT);"},
}
errs := MigrationValidate(migrations)
// Expect: not starting at 1, gap between 2 and 4, empty name for v2, empty up_sql for v2
if len(errs) < 3 {
t.Errorf("expected at least 3 errors, got %d: %v", len(errs), errs)
}
})
}
func containsStr(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}