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 }