优化结构
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package migration
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// CreateMissingTables creates only schemas that are absent. Existing tables
|
||||
// are intentionally left untouched: GORM's MySQL column diff path can panic
|
||||
// when older servers return incomplete information_schema metadata, and this
|
||||
// project does not support implicit legacy-schema upgrades through AutoMigrate.
|
||||
func CreateMissingTables(db *gorm.DB, models ...any) error {
|
||||
missing := make([]any, 0, len(models))
|
||||
for _, model := range models {
|
||||
if model != nil && !db.Migrator().HasTable(model) {
|
||||
missing = append(missing, model)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().CreateTable(missing...)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type existingSchema struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string
|
||||
}
|
||||
|
||||
func (existingSchema) TableName() string { return "existing_schema" }
|
||||
|
||||
func TestCreateMissingTablesDoesNotAlterExistingTables(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 = db.Exec("CREATE TABLE existing_schema (id integer primary key)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = CreateMissingTables(db, &existingSchema{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.Migrator().HasColumn(&existingSchema{}, "name") {
|
||||
t.Fatal("existing schema was altered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMissingTablesCreatesAbsentTables(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 = CreateMissingTables(db, &existingSchema{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&existingSchema{}) || !db.Migrator().HasColumn(&existingSchema{}, "name") {
|
||||
t.Fatal("missing schema was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRepairsInvalidMigrationMetadataTable(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 = db.Exec("CREATE TABLE sys_schema_migrations (id integer primary key, title text)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
if err = Run(db, []Step{{ID: "202608200001_test", Migrate: func(*gorm.DB) error {
|
||||
called = true
|
||||
return nil
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("migration was not rerun after metadata repair")
|
||||
}
|
||||
var dataType string
|
||||
if err = db.Raw("SELECT type FROM pragma_table_info(?) WHERE name = 'id'", TableName).Scan(&dataType).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(dataType), "text") {
|
||||
t.Fatalf("migration id type = %q", dataType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotLeakStatementState(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 = Run(db, []Step{{ID: "202608200001_test", Migrate: func(tx *gorm.DB) error {
|
||||
return CreateMissingTables(tx, &existingSchema{})
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&existingSchema{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("shared database retained migration statement state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUnknownAppliedMigration(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 = db.Migrator().CreateTable(&record{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&record{ID: "unknown"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = Run(db, []Step{{ID: "known", Migrate: func(*gorm.DB) error { return nil }}}); err == nil {
|
||||
t.Fatal("unknown migration was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user