merge: issue/0015-db-migrations — SQL migration system
# Conflicts: # registry.db
This commit is contained in:
@@ -0,0 +1,316 @@
|
|||||||
|
# 0015 — Database Migrations
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| **ID** | 0015 |
|
||||||
|
| **Estado** | pendiente |
|
||||||
|
| **Prioridad** | media |
|
||||||
|
| **Tipo** | feature |
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
Ninguna.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Sistema de migraciones para evolucionar schemas SQLite de forma controlada y reproducible. Reemplaza los `ALTER TABLE` manuales y la creacion ad-hoc de tablas con `db_create_table_go_infra` por un flujo formal: crear migracion, aplicar, revertir, verificar estado.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
- Cada app en `apps/*/` tiene su `operations.db` con tablas creadas al inicio usando `db_create_table_go_infra` o SQL inline.
|
||||||
|
- Cuando el schema necesita cambiar (nueva columna, nuevo indice, renombrar tabla), se ejecutan `ALTER TABLE` manuales sin control de version ni trazabilidad.
|
||||||
|
- No existe forma de saber que version de schema tiene una BD concreta, ni de reproducir el estado actual desde cero de forma incremental.
|
||||||
|
- `deploy_server`, `pipeline_launcher`, `sqlite_api` y cualquier app futura con SQLite necesitan lo mismo: migraciones secuenciales, reversibles, con estado.
|
||||||
|
- `registry.db` se regenera con `fn index` asi que no necesita migraciones. El sistema es exclusivamente para `operations.db` y otras BDs de apps.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
functions/infra/
|
||||||
|
├── migration_create.go — NEW: crear archivo de migracion
|
||||||
|
├── migration_create.md — NEW
|
||||||
|
├── migration_up.go — NEW: aplicar migraciones pendientes
|
||||||
|
├── migration_up.md — NEW
|
||||||
|
├── migration_down.go — NEW: revertir ultimas N migraciones
|
||||||
|
├── migration_down.md — NEW
|
||||||
|
├── migration_status.go — NEW: listar estado de migraciones
|
||||||
|
├── migration_status.md — NEW
|
||||||
|
├── migration_validate.go — NEW: validar secuencia de archivos
|
||||||
|
├── migration_validate.md — NEW
|
||||||
|
├── migration_parse.go — NEW: parsear archivo de migracion
|
||||||
|
├── migration_parse.md — NEW
|
||||||
|
|
||||||
|
types/infra/
|
||||||
|
├── migration.md — NEW: metadata del tipo Migration
|
||||||
|
├── migration_status.md — NEW: metadata del tipo MigrationStatus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patron pure core / impure shell
|
||||||
|
|
||||||
|
- **Pure:** `migration_validate` (verifica secuencia de archivos sin tocar BD ni disco), `migration_parse` (extrae SQL de un string, sin I/O)
|
||||||
|
- **Impure:** `migration_create` (escribe archivo en disco), `migration_up` (ejecuta SQL contra BD), `migration_down` (ejecuta SQL contra BD), `migration_status` (lee BD y disco)
|
||||||
|
|
||||||
|
## Diseno
|
||||||
|
|
||||||
|
### Formato de archivo de migracion
|
||||||
|
|
||||||
|
Cada migracion es un unico archivo `.sql` con marcadores `-- +up` y `-- +down` que separan los bloques de aplicacion y reversion:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 001_create_entities.sql
|
||||||
|
|
||||||
|
-- +up
|
||||||
|
CREATE TABLE entities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type_ref TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_entities_type ON entities(type_ref);
|
||||||
|
CREATE INDEX idx_entities_status ON entities(status);
|
||||||
|
|
||||||
|
-- +down
|
||||||
|
DROP INDEX IF EXISTS idx_entities_status;
|
||||||
|
DROP INDEX IF EXISTS idx_entities_type;
|
||||||
|
DROP TABLE IF EXISTS entities;
|
||||||
|
```
|
||||||
|
|
||||||
|
Nomenclatura: `{NNN}_{nombre_descriptivo}.sql` donde `NNN` es un numero secuencial con padding de 3 digitos (001, 002, ..., 999).
|
||||||
|
|
||||||
|
### Tabla `_migrations`
|
||||||
|
|
||||||
|
Creada automaticamente por `migration_up` en la primera ejecucion. Almacena las migraciones aplicadas:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Se guarda el SQL completo (`up_sql`, `down_sql`) en la tabla para que el rollback funcione incluso si el archivo de migracion se modifica o elimina despues de aplicarse. Esto hace la BD autocontenida.
|
||||||
|
|
||||||
|
### Tipos
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Migration representa una migracion parseada desde un archivo .sql
|
||||||
|
type Migration struct {
|
||||||
|
Version int // Numero secuencial (1, 2, 3...)
|
||||||
|
Name string // Nombre descriptivo (create_entities, add_status_column)
|
||||||
|
UpSQL string // Bloque SQL de aplicacion
|
||||||
|
DownSQL string // Bloque SQL de reversion
|
||||||
|
AppliedAt time.Time // Zero value si no aplicada
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationStatus representa el estado de una migracion respecto a una BD
|
||||||
|
type MigrationStatus struct {
|
||||||
|
Version int // Numero secuencial
|
||||||
|
Name string // Nombre descriptivo
|
||||||
|
Applied bool // true si ya aplicada en la BD
|
||||||
|
AppliedAt time.Time // Cuando se aplico (zero value si pending)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funciones
|
||||||
|
|
||||||
|
| Funcion | Purity | Firma (simplificada) | Descripcion |
|
||||||
|
|---------|--------|---------------------|-------------|
|
||||||
|
| `migration_create` | impure | `(dir string, name string) (string, error)` | Crea archivo `.sql` con template `-- +up` / `-- +down`. Calcula siguiente version. Retorna path creado. |
|
||||||
|
| `migration_up` | impure | `(db *sql.DB, dir string) ([]Migration, error)` | Lee archivos del directorio, compara con `_migrations`, ejecuta pendientes en orden dentro de transacciones individuales. Retorna las aplicadas. |
|
||||||
|
| `migration_down` | impure | `(db *sql.DB, n int) ([]Migration, error)` | Revierte las ultimas `n` migraciones usando el `down_sql` guardado en `_migrations`. Cada reversion en su propia transaccion. Retorna las revertidas. |
|
||||||
|
| `migration_status` | impure | `(db *sql.DB, dir string) ([]MigrationStatus, error)` | Cruza archivos en disco con registros en `_migrations`. Retorna lista ordenada con estado de cada migracion. |
|
||||||
|
| `migration_validate` | pure | `(migrations []Migration) []string` | Verifica que las versiones sean secuenciales sin huecos (1,2,3...), sin duplicados, y que cada migracion tenga up y down no vacios. Retorna lista de errores (vacia si todo OK). |
|
||||||
|
| `migration_parse` | pure | `(filename string, content string) (Migration, error)` | Extrae version y nombre del filename, separa bloques up/down por marcadores. Error si el formato es invalido. |
|
||||||
|
|
||||||
|
### Directorio de migraciones por app
|
||||||
|
|
||||||
|
Cada app que use migraciones tendra un directorio `migrations/` dentro de su carpeta:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/deploy_server/
|
||||||
|
├── main.go
|
||||||
|
├── app.md
|
||||||
|
├── operations.db
|
||||||
|
└── migrations/
|
||||||
|
├── 001_create_deploy_targets.sql
|
||||||
|
├── 002_create_deploy_logs.sql
|
||||||
|
└── 003_add_env_to_targets.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
### Fase 1: Tipos y funciones puras
|
||||||
|
|
||||||
|
- [ ] **1.1** Crear tipo `Migration` en `functions/infra/migration.go` con `.md` en `types/infra/migration.md`
|
||||||
|
- [ ] **1.2** Crear tipo `MigrationStatus` en `functions/infra/migration_status.go` con `.md` en `types/infra/migration_status.md`
|
||||||
|
- [ ] **1.3** `migration_parse` — parsear filename + contenido, extraer version/nombre/up/down. Tests con archivos validos e invalidos (sin marcador, version no numerica, down vacio).
|
||||||
|
- [ ] **1.4** `migration_validate` — verificar secuencia, huecos, duplicados, bloques vacios. Tests con secuencias validas, con huecos, con duplicados.
|
||||||
|
|
||||||
|
### Fase 2: Funciones impuras
|
||||||
|
|
||||||
|
- [ ] **2.1** `migration_create` — calcular siguiente version leyendo archivos existentes, escribir template. Test con directorio vacio y con migraciones existentes.
|
||||||
|
- [ ] **2.2** `migration_up` — crear tabla `_migrations` si no existe, leer archivos, filtrar pendientes, ejecutar en transacciones. Tests con BD vacia, con migraciones parcialmente aplicadas, con migracion que falla a mitad.
|
||||||
|
- [ ] **2.3** `migration_down` — leer ultimas N de `_migrations`, ejecutar `down_sql` en orden inverso, borrar registros. Tests con rollback de 1 y de N, con BD sin migraciones.
|
||||||
|
- [ ] **2.4** `migration_status` — cruzar disco con BD, marcar applied/pending. Test con migraciones en disco pero no en BD, en BD pero no en disco (huerfanas).
|
||||||
|
|
||||||
|
### Fase 3: Integracion y cleanup
|
||||||
|
|
||||||
|
- [ ] **3.1** Tests de integracion: ciclo completo create -> up -> status -> down -> status
|
||||||
|
- [ ] **3.2** `fn index` y verificar que las 6 funciones y 2 tipos aparecen en registry.db
|
||||||
|
- [ ] **3.3** Verificar `go vet -tags fts5` y `go test -tags fts5 ./functions/infra/`
|
||||||
|
- [ ] **3.4** Migrar al menos una app existente (`deploy_server`) para validar el flujo con un caso real: extraer los CREATE TABLE actuales como migracion 001
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo de uso
|
||||||
|
|
||||||
|
### Crear una migracion
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Crear primera migracion para una app
|
||||||
|
path, err := infra.MigrationCreate("apps/my_app/migrations", "create_users")
|
||||||
|
// path = "apps/my_app/migrations/001_create_users.sql"
|
||||||
|
// El archivo contiene el template:
|
||||||
|
// -- +up
|
||||||
|
//
|
||||||
|
// -- +down
|
||||||
|
```
|
||||||
|
|
||||||
|
Editar el archivo generado con el SQL concreto:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 001_create_users.sql
|
||||||
|
|
||||||
|
-- +up
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +down
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aplicar migraciones pendientes
|
||||||
|
|
||||||
|
```go
|
||||||
|
db, _ := infra.SqliteOpen("", "apps/my_app/operations.db")
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
applied, err := infra.MigrationUp(db, "apps/my_app/migrations")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range applied {
|
||||||
|
fmt.Printf("Applied: %03d_%s\n", m.Version, m.Name)
|
||||||
|
}
|
||||||
|
// Applied: 001_create_users
|
||||||
|
// Applied: 002_add_roles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar estado
|
||||||
|
|
||||||
|
```go
|
||||||
|
statuses, err := infra.MigrationStatus(db, "apps/my_app/migrations")
|
||||||
|
for _, s := range statuses {
|
||||||
|
status := "pending"
|
||||||
|
if s.Applied {
|
||||||
|
status = fmt.Sprintf("applied at %s", s.AppliedAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
fmt.Printf("%03d %-30s %s\n", s.Version, s.Name, status)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Revertir la ultima migracion
|
||||||
|
|
||||||
|
```go
|
||||||
|
reverted, err := infra.MigrationDown(db, 1)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range reverted {
|
||||||
|
fmt.Printf("Reverted: %03d_%s\n", m.Version, m.Name)
|
||||||
|
}
|
||||||
|
// Reverted: 002_add_roles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validar archivos antes de aplicar
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Parsear todos los archivos del directorio
|
||||||
|
files, _ := os.ReadDir("apps/my_app/migrations")
|
||||||
|
var migrations []infra.Migration
|
||||||
|
for _, f := range files {
|
||||||
|
content, _ := os.ReadFile(filepath.Join("apps/my_app/migrations", f.Name()))
|
||||||
|
m, err := infra.MigrationParse(f.Name(), string(content))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Parse error in %s: %v", f.Name(), err)
|
||||||
|
}
|
||||||
|
migrations = append(migrations, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar secuencia
|
||||||
|
errors := infra.MigrationValidate(migrations)
|
||||||
|
if len(errors) > 0 {
|
||||||
|
for _, e := range errors {
|
||||||
|
fmt.Println("ERROR:", e)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("All migrations valid")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso tipico en el main.go de una app
|
||||||
|
|
||||||
|
```go
|
||||||
|
func main() {
|
||||||
|
db, err := infra.SqliteOpen("", "operations.db")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Aplicar migraciones pendientes al arrancar
|
||||||
|
applied, err := infra.MigrationUp(db, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Migration failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(applied) > 0 {
|
||||||
|
log.Printf("Applied %d migration(s)", len(applied))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... resto de la app
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisiones de diseno
|
||||||
|
|
||||||
|
- **Archivo unico con marcadores `-- +up` / `-- +down`:** Mantener up y down juntos en el mismo archivo facilita la revision y evita desincronizacion entre archivos separados. El formato es simple de parsear (split por marcadores) y legible como SQL plano. Alternativa descartada: archivos separados `001_create_users.up.sql` / `001_create_users.down.sql` — mas archivos, mas facil olvidar el down.
|
||||||
|
- **Versiones numericas secuenciales (no timestamps):** Para un proyecto personal con un solo desarrollador, los numeros secuenciales son mas legibles y evitan colisiones de merge que no aplican aqui. Un archivo `003_add_index.sql` es mas claro que `20260413103045_add_index.sql`. Si el proyecto creciera a multiples desarrolladores, se podria migrar a timestamps.
|
||||||
|
- **SQL almacenado en `_migrations`:** Guardar `up_sql` y `down_sql` en la tabla permite revertir migraciones incluso si el archivo original fue modificado o eliminado. La BD es autocontenida para rollbacks. Tradeoff: ocupa mas espacio en la tabla, pero las migraciones son texto pequeno y SQLite lo maneja sin problema.
|
||||||
|
- **Transaccion por migracion, no global:** Cada migracion se ejecuta en su propia transaccion. Si la migracion 003 falla, las 001 y 002 ya aplicadas quedan. Esto permite diagnosticar y corregir la migracion fallida sin tener que reaplicar todo desde cero. SQLite no soporta DDL transaccional completo (CREATE TABLE si, pero ALTER TABLE tiene limitaciones), asi que una transaccion global daria falsa seguridad.
|
||||||
|
- **Funciones del registry, no CLI separado:** Las migraciones son funciones reutilizables en `functions/infra/`, no una herramienta CLI standalone. Cualquier app las importa y las llama desde su `main.go`. Si en el futuro se necesita un subcomando `fn migrate`, se compone sobre estas funciones.
|
||||||
|
- **Sin migracion automatica de schema (auto-diff):** El sistema no compara el schema actual con un schema deseado para generar migraciones. Las migraciones se escriben a mano. Esto es deliberado: el SQL explicito es mas predecible y debuggeable que un diff automatico, especialmente con SQLite que tiene limitaciones en ALTER TABLE.
|
||||||
|
- **Directorio `migrations/` por app:** Cada app tiene su propio directorio de migraciones. No hay un directorio global. Esto es coherente con la regla de que cada `operations.db` es independiente.
|
||||||
|
|
||||||
|
## Riesgos
|
||||||
|
|
||||||
|
- **ALTER TABLE limitado en SQLite:** SQLite no soporta `DROP COLUMN` (antes de 3.35.0), `RENAME COLUMN` (antes de 3.25.0), ni `ALTER COLUMN`. Migraciones que necesiten estos cambios requieren el patron de recrear tabla (CREATE new, INSERT INTO new SELECT FROM old, DROP old, ALTER TABLE new RENAME TO old). Documentar este patron en el template generado por `migration_create`.
|
||||||
|
- **Migraciones con datos en produccion:** Un `DROP TABLE` en el down destruye datos. El down es para desarrollo, no para revertir en produccion con datos vivos. Documentar que `migration_down` es destructivo y no debe usarse a la ligera en BDs con datos importantes.
|
||||||
|
- **Archivos editados despues de aplicarse:** Si alguien modifica un archivo `.sql` despues de que la migracion fue aplicada, el estado queda inconsistente (el archivo dice una cosa, la BD tiene otra). `migration_status` podria detectar esto comparando hashes, pero la v1 no lo incluye para mantener simplicidad. Mitigado por el hecho de que el `up_sql` guardado en `_migrations` es el que realmente se ejecuto.
|
||||||
|
- **Scope creep hacia ORM o schema manager:** El sistema es intencionalmente minimo: archivos SQL, tabla de tracking, funciones para aplicar y revertir. No se agregan features como generacion automatica de SQL, migracion de datos programatica, ni integracion con modelos Go. Si se necesita algo asi, se construye como capa superior.
|
||||||
@@ -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
|
||||||
|
}())
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Migration
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
UpSQL string
|
||||||
|
DownSQL string
|
||||||
|
AppliedAt time.Time
|
||||||
|
}
|
||||||
|
description: "Migracion SQL parseada desde un archivo .sql con marcadores -- +up / -- +down. Version es el numero secuencial, AppliedAt es zero value si pendiente."
|
||||||
|
tags: [migration, database, sql, schema, sqlite]
|
||||||
|
uses_types: []
|
||||||
|
file_path: "functions/infra/migration.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Tipo producto — todos los campos siempre presentes. `AppliedAt` es `time.Time{}` (zero value) si la migracion aun no fue aplicada. `UpSQL` y `DownSQL` son el contenido de los bloques delimitados por `-- +up` y `-- +down` en el archivo .sql.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: MigrationStatus
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
type MigrationStatus struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
Applied bool
|
||||||
|
AppliedAt time.Time
|
||||||
|
}
|
||||||
|
description: "Estado de una migracion respecto a una base de datos concreta. Applied=true si ya fue ejecutada, AppliedAt indica cuando."
|
||||||
|
tags: [migration, database, sql, schema, sqlite, status]
|
||||||
|
uses_types: []
|
||||||
|
file_path: "functions/infra/migration.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Tipo producto. `Applied` es `false` y `AppliedAt` es zero value para migraciones pendientes. Se usa como resultado de `migration_status` para cruzar archivos en disco con registros en `_migrations`.
|
||||||
Reference in New Issue
Block a user