package migration import ( "fmt" "sort" "strings" "gorm.io/gorm" ) // CheckApplied is read-only; a missing or corrupt ledger must not be repaired // by a normal service startup with automatic migrations disabled. func CheckApplied(db *gorm.DB, steps []Step) error { db = db.Session(&gorm.Session{NewDB: true}) var rows []record if err := db.Model(&record{}).Find(&rows).Error; err != nil { return fmt.Errorf("read migration versions: %w", err) } known := map[string]bool{} for _, step := range steps { if step.ID == "" || known[step.ID] { return fmt.Errorf("invalid or duplicate migration ID %q", step.ID) } known[step.ID] = true } for _, row := range rows { if _, ok := known[row.ID]; !ok { return fmt.Errorf("unknown applied migration %q", row.ID) } known[row.ID] = false } var pending []string for id, missing := range known { if missing { pending = append(pending, id) } } sort.Strings(pending) if len(pending) > 0 { return fmt.Errorf("pending migrations: %s", strings.Join(pending, ", ")) } return nil } // CheckModels verifies persistent columns and declared indexes without // changing data or schema. Extra legacy columns and indexes are retained. func CheckModels(db *gorm.DB, models ...any) error { db = db.Session(&gorm.Session{NewDB: true}) var problems []string for _, model := range models { stmt := &gorm.Statement{DB: db} if err := stmt.Parse(model); err != nil { return err } table := stmt.Schema.Table if !db.Migrator().HasTable(model) { problems = append(problems, "missing table "+table) continue } columns, err := db.Migrator().ColumnTypes(model) if err != nil { return err } found := map[string]bool{} for _, column := range columns { found[column.Name()] = true } for _, name := range stmt.Schema.DBNames { if !stmt.Schema.FieldsByDBName[name].IgnoreMigration && !found[name] { problems = append(problems, "missing column "+table+"."+name) } } indexes, err := readIndexes(db, model, table) if err != nil { return err } if db.Dialector.Name() == "mysql" && len(stmt.Schema.PrimaryFields) > 0 { var wanted []string for _, field := range stmt.Schema.PrimaryFields { wanted = append(wanted, field.DBName) } matched := false for _, actual := range indexes { if actual.primary && strings.Join(actual.columns, ",") == strings.Join(wanted, ",") { matched = true } } if !matched { problems = append(problems, "missing or incompatible primary key "+table) } } for _, index := range stmt.Schema.ParseIndexes() { wanted := make([]string, 0, len(index.Fields)) for _, field := range index.Fields { wanted = append(wanted, field.DBName) } covered := false for _, actual := range indexes { if actual.prefix || len(actual.columns) < len(wanted) { continue } if index.Class == "UNIQUE" && (!actual.unique || len(actual.columns) != len(wanted)) { continue } if strings.Join(actual.columns[:len(wanted)], ",") == strings.Join(wanted, ",") { covered = true break } } if !covered { problems = append(problems, "missing index "+table+"."+index.Name) } } } if len(problems) > 0 { return fmt.Errorf("%s", strings.Join(problems, "; ")) } return nil } type schemaIndex struct { columns []string unique, prefix, primary bool } func readIndexes(db *gorm.DB, model any, table string) ([]schemaIndex, error) { if db.Dialector.Name() == "mysql" { var rows []struct { IndexName, ColumnName string NonUnique int SubPart *int } if err := db.Raw("SELECT index_name,column_name,non_unique,sub_part FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? ORDER BY index_name,seq_in_index", table).Scan(&rows).Error; err != nil { return nil, err } var indexes []schemaIndex previous := "" for _, row := range rows { if row.IndexName != previous { indexes = append(indexes, schemaIndex{unique: row.NonUnique == 0, primary: row.IndexName == "PRIMARY"}) previous = row.IndexName } index := &indexes[len(indexes)-1] index.columns = append(index.columns, row.ColumnName) index.prefix = index.prefix || row.SubPart != nil } return indexes, nil } rows, err := db.Migrator().GetIndexes(model) if err != nil { return nil, err } indexes := make([]schemaIndex, 0, len(rows)) for _, row := range rows { unique, _ := row.Unique() indexes = append(indexes, schemaIndex{columns: row.Columns(), unique: unique}) } return indexes, nil }