diff --git a/functions/infra/migration.go b/functions/infra/migration.go new file mode 100644 index 00000000..82609162 --- /dev/null +++ b/functions/infra/migration.go @@ -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) +} diff --git a/functions/infra/migration_parse.go b/functions/infra/migration_parse.go new file mode 100644 index 00000000..fba0bfef --- /dev/null +++ b/functions/infra/migration_parse.go @@ -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 +} diff --git a/functions/infra/migration_parse.md b/functions/infra/migration_parse.md new file mode 100644 index 00000000..9a921355 --- /dev/null +++ b/functions/infra/migration_parse.md @@ -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). diff --git a/functions/infra/migration_parse_test.go b/functions/infra/migration_parse_test.go new file mode 100644 index 00000000..38a56aa7 --- /dev/null +++ b/functions/infra/migration_parse_test.go @@ -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") + } + }) +} diff --git a/functions/infra/migration_validate.go b/functions/infra/migration_validate.go new file mode 100644 index 00000000..58331977 --- /dev/null +++ b/functions/infra/migration_validate.go @@ -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 +} diff --git a/functions/infra/migration_validate.md b/functions/infra/migration_validate.md new file mode 100644 index 00000000..9054ff91 --- /dev/null +++ b/functions/infra/migration_validate.md @@ -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. diff --git a/functions/infra/migration_validate_test.go b/functions/infra/migration_validate_test.go new file mode 100644 index 00000000..7f051fe4 --- /dev/null +++ b/functions/infra/migration_validate_test.go @@ -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 + }()) +} diff --git a/types/infra/migration.md b/types/infra/migration.md new file mode 100644 index 00000000..50fada94 --- /dev/null +++ b/types/infra/migration.md @@ -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. diff --git a/types/infra/migration_status.md b/types/infra/migration_status.md new file mode 100644 index 00000000..4d11fb16 --- /dev/null +++ b/types/infra/migration_status.md @@ -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`.