ec36278c7b
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>
112 lines
3.1 KiB
Go
112 lines
3.1 KiB
Go
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
|
|
}
|