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,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
|
||||
}
|
||||
Reference in New Issue
Block a user