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:
@@ -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,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,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