merge: issue/0015-db-migrations — SQL migration system
# Conflicts: # registry.db
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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: [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.
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
Reference in New Issue
Block a user