// Package migration owns the application database migration runner. Each data // module declares its own steps; the root data package only orders and runs // them. package migration import ( "fmt" "strings" "gorm.io/gorm" ) const TableName = "sys_schema_migrations" type Step struct { ID string Migrate func(*gorm.DB) error } type record struct { ID string `gorm:"primaryKey;size:255"` } func (record) TableName() string { return TableName } func Run(db *gorm.DB, steps []Step) error { if db == nil { return fmt.Errorf("database is nil") } db = db.Session(&gorm.Session{NewDB: true}) if err := repairMigrationTable(db); err != nil { return err } if !db.Session(&gorm.Session{NewDB: true}).Migrator().HasTable(&record{}) { if err := db.Session(&gorm.Session{NewDB: true}).Migrator().CreateTable(&record{}); err != nil { return fmt.Errorf("create migration table: %w", err) } } var appliedRows []record if err := db.Session(&gorm.Session{NewDB: true}).Find(&appliedRows).Error; err != nil { return fmt.Errorf("load applied migrations: %w", err) } applied := make(map[string]struct{}, len(appliedRows)) known := make(map[string]struct{}, len(steps)) for _, row := range appliedRows { applied[row.ID] = struct{}{} } for _, step := range steps { if step.ID == "" { return fmt.Errorf("migration id is empty") } if _, duplicate := known[step.ID]; duplicate { return fmt.Errorf("duplicate migration id %q", step.ID) } known[step.ID] = struct{}{} } for id := range applied { if _, ok := known[id]; !ok { return fmt.Errorf("unknown applied migration %q", id) } } for _, step := range steps { if _, ok := applied[step.ID]; ok { continue } if step.Migrate == nil { return fmt.Errorf("migration %q has no function", step.ID) } if err := step.Migrate(db.Session(&gorm.Session{NewDB: true})); err != nil { return fmt.Errorf("apply migration %q: %w", step.ID, err) } if err := db.Session(&gorm.Session{NewDB: true}).Create(&record{ID: step.ID}).Error; err != nil { return fmt.Errorf("record migration %q: %w", step.ID, err) } } return nil } func repairMigrationTable(db *gorm.DB) error { clean := db.Session(&gorm.Session{NewDB: true}) if !clean.Migrator().HasTable(TableName) { return nil } var dataType string switch clean.Dialector.Name() { case "mysql": err := clean.Raw("SELECT DATA_TYPE FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?", TableName, "id").Scan(&dataType).Error if err != nil { return fmt.Errorf("inspect migration table: %w", err) } case "postgres": err := clean.Raw("SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", TableName, "id").Scan(&dataType).Error if err != nil { return fmt.Errorf("inspect migration table: %w", err) } case "sqlite": var rows []struct { Name string `gorm:"column:name"` Type string `gorm:"column:type"` } if err := clean.Raw("PRAGMA table_info(" + TableName + ")").Scan(&rows).Error; err != nil { return fmt.Errorf("inspect migration table: %w", err) } for _, row := range rows { if strings.EqualFold(row.Name, "id") { dataType = row.Type break } } default: return nil } normalized := strings.ToLower(dataType) if strings.Contains(normalized, "char") || strings.Contains(normalized, "text") || strings.Contains(normalized, "clob") { return nil } if err := clean.Migrator().DropTable(TableName); err != nil { return fmt.Errorf("repair invalid migration table: %w", err) } return nil }