feat: tipos Migration/MigrationStatus y funciones puras migration_parse + migration_validate

Fase 1 del issue 0015. Tipos Go en functions/infra/migration.go con metadata en
types/infra/. Funciones puras: MigrationParse (parsea filename NNN_name.sql +
bloques -- +up/-- +down) y MigrationValidate (verifica secuencia, huecos,
duplicados, bloques vacios). 16 tests, todos pasan.

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