Files
fn_registry/functions/infra/migration_status.go
T
egutierrez 35a49174ca feat: funciones impuras migration_create, migration_up, migration_down, migration_status
Fase 2 del issue 0015. MigrationCreate (crea archivo .sql template con version
auto-calculada), MigrationUp (aplica migraciones pendientes en transacciones
individuales), MigrationDown (revierte ultimas N via down_sql de _migrations),
MigrationGetStatus (cruza disco con BD, detecta orphaned). Tests de integracion:
ciclo completo create->up->status->down->status. 26 tests, todos pasan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:40 +02:00

107 lines
3.1 KiB
Go

package infra
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// MigrationGetStatus crosses migration files on disk with records in _migrations,
// returning a sorted list of MigrationStatus entries ordered by version ascending.
// Migrations present in _migrations but not on disk are included with Applied=true
// and marked as orphaned in their Name (suffix " (orphaned)").
// If _migrations does not exist yet, all file-based migrations are returned as pending.
func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error) {
// Load files from disk (non-fatal if dir is empty or missing)
fileMigrations, err := loadMigrationsFromDir(dir)
if err != nil && !strings.Contains(err.Error(), "cannot read migrations directory") {
return nil, fmt.Errorf("migration_status: %w", err)
}
if err != nil {
// Directory doesn't exist — treat as empty
fileMigrations = nil
}
// Build a map from version to file migration
fileMap := make(map[int]Migration, len(fileMigrations))
for _, m := range fileMigrations {
fileMap[m.Version] = m
}
// Load applied migrations from DB (_migrations may not exist yet)
dbMap, err := loadAppliedMigrations(db)
if err != nil {
return nil, fmt.Errorf("migration_status: %w", err)
}
// Merge: collect all known versions
allVersions := make(map[int]struct{})
for v := range fileMap {
allVersions[v] = struct{}{}
}
for v := range dbMap {
allVersions[v] = struct{}{}
}
// Build status list
var statuses []MigrationStatus
for v := range allVersions {
dbEntry, inDB := dbMap[v]
fileEntry, inFile := fileMap[v]
name := fileEntry.Name
if !inFile && inDB {
// Orphaned: applied but no file on disk
name = dbEntry.Name + " (orphaned)"
}
statuses = append(statuses, MigrationStatus{
Version: v,
Name: name,
Applied: inDB,
AppliedAt: dbEntry.AppliedAt, // zero value if not in DB
})
}
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Version < statuses[j].Version
})
return statuses, nil
}
// migrationDBRecord holds data from the _migrations table.
type migrationDBRecord struct {
Name string
AppliedAt time.Time
}
// loadAppliedMigrations queries _migrations and returns a map of version -> record.
// If the table does not exist, returns an empty map without error.
func loadAppliedMigrations(db *sql.DB) (map[int]migrationDBRecord, error) {
result := make(map[int]migrationDBRecord)
rows, err := db.Query("SELECT version, name, applied_at FROM _migrations ORDER BY version ASC")
if err != nil {
// Table doesn't exist yet — return empty map
if strings.Contains(err.Error(), "no such table") {
return result, nil
}
return nil, fmt.Errorf("query _migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var version int
var name, appliedAtStr string
if err := rows.Scan(&version, &name, &appliedAtStr); err != nil {
return nil, fmt.Errorf("scan _migrations row: %w", err)
}
appliedAt, _ := time.Parse("2006-01-02 15:04:05", appliedAtStr)
result[version] = migrationDBRecord{Name: name, AppliedAt: appliedAt}
}
return result, rows.Err()
}