fix: validate explicit migrations and preserve large business ids
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type checkedSchema struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Owner uint `gorm:"index:idx_checked_owner_state,priority:1"`
|
||||
State int `gorm:"index:idx_checked_owner_state,priority:2"`
|
||||
Code string `gorm:"uniqueIndex"`
|
||||
}
|
||||
|
||||
func TestSchemaCheckRejectsMissingColumnsAndIncorrectIndexes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckModels(db, &checkedSchema{}); err == nil {
|
||||
t.Fatal("missing table passed")
|
||||
}
|
||||
if err := db.Migrator().CreateTable(&checkedSchema{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckModels(db, &checkedSchema{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
"DROP INDEX idx_checked_owner_state",
|
||||
"CREATE INDEX idx_checked_owner_state ON checked_schemas(state,owner)",
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := CheckModels(db, &checkedSchema{}); err == nil || !strings.Contains(err.Error(), "idx_checked_owner_state") {
|
||||
t.Fatalf("reversed index passed: %v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE INDEX legacy_owner_state ON checked_schemas(owner,state,id)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckModels(db, &checkedSchema{}); err != nil {
|
||||
t.Fatalf("equivalent covering index rejected: %v", err)
|
||||
}
|
||||
if err := db.Exec("DROP INDEX idx_checked_schemas_code").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("CREATE INDEX idx_checked_schemas_code ON checked_schemas(code)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckModels(db, &checkedSchema{}); err == nil {
|
||||
t.Fatal("non-unique replacement passed")
|
||||
}
|
||||
if err := db.Migrator().DropColumn(&checkedSchema{}, "Owner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckModels(db, &checkedSchema{}); err == nil || !strings.Contains(err.Error(), "missing column") {
|
||||
t.Fatalf("missing column passed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAppliedDoesNotCreateOrApplyMigrations(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
steps := []Step{{ID: "required", Migrate: func(*gorm.DB) error { called = true; return nil }}}
|
||||
if err := CheckApplied(db, steps); err == nil {
|
||||
t.Fatal("missing ledger passed")
|
||||
}
|
||||
if db.Migrator().HasTable(TableName) || called {
|
||||
t.Fatal("read-only check mutated the database")
|
||||
}
|
||||
if err := db.Migrator().CreateTable(&record{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckApplied(db, steps); err == nil || !strings.Contains(err.Error(), "required") {
|
||||
t.Fatalf("pending migration passed: %v", err)
|
||||
}
|
||||
if err := db.Create(&record{ID: "required"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// openWithDriverConfig returns a settings-bearing handle for MySQL's
|
||||
// table options. Read-only checks must not retain their Model on it.
|
||||
configured := db.Set("gorm:table_options", "ENGINE=InnoDB")
|
||||
if err := CheckApplied(configured, steps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if configured.Statement.Model != nil {
|
||||
t.Fatal("schema check leaked the migration model into the shared handle")
|
||||
}
|
||||
if err := CheckApplied(db, steps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&record{ID: "unknown"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckApplied(db, steps); err == nil {
|
||||
t.Fatal("unknown version passed")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("read-only check ran a migration")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user