package model import ( "os" "path/filepath" "strings" "testing" "github.com/QuantumNous/new-api/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) type MigrationIdentityFields struct { ID int `gorm:"primaryKey"` Name string `gorm:"size:64;unique"` Reference string `gorm:"size:64;uniqueIndex"` Provider string `gorm:"size:32;uniqueIndex:,composite:provider_subject"` Subject string `gorm:"size:64;uniqueIndex:,composite:provider_subject"` } type migrationIdentityV1 struct { MigrationIdentityFields Digest string `gorm:"type:char(32)"` } type migrationIdentityV2 struct { MigrationIdentityFields Digest string `gorm:"type:char(64)"` Note string `gorm:"size:128"` } type migrationConstraintV1 struct { ID int `gorm:"primaryKey"` Name string `gorm:"size:64"` } type migrationConstraintV2 struct { ID int `gorm:"primaryKey"` Name string `gorm:"size:64;unique"` } type migrationDecimalV1 struct { ID int `gorm:"primaryKey"` Price float64 `gorm:"type:decimal(10,6);default:0"` } type migrationDecimalV2 struct { ID int `gorm:"primaryKey"` Price float64 `gorm:"type:decimal(12,6);not null;default:0"` } type migrationDecimalV3 struct { ID int `gorm:"primaryKey"` Price float64 `gorm:"type:decimal(12,6);not null;default:1.25"` } func TestMigrationSchemaStability(t *testing.T) { for _, dialect := range []string{"sqlite", "mysql", "postgres"} { t.Run(dialect, func(t *testing.T) { var dsn string switch dialect { case "sqlite": dsn = "local" previousPath := common.SQLitePath common.SQLitePath = filepath.Join(t.TempDir(), "migration.db") t.Cleanup(func() { common.SQLitePath = previousPath }) case "mysql": dsn = os.Getenv("TEST_MYSQL_DSN") case "postgres": dsn = os.Getenv("TEST_POSTGRES_DSN") } if dsn == "" { t.Skip("test database DSN is not configured") } t.Setenv("MIGRATION_TEST_DSN", dsn) db, _, err := chooseDB("MIGRATION_TEST_DSN", false) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() }) recorder := &migrationSQLRecorder{} db = db.Session(&gorm.Session{Logger: recorder}) t.Run("identity_and_indexes", func(t *testing.T) { const table = "migration_identity_test" t.Cleanup(func() { _ = db.Migrator().DropTable(table) }) require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV1{})) row := migrationIdentityV1{ MigrationIdentityFields: MigrationIdentityFields{ID: 1, Name: "root", Reference: "token-reference", Provider: "oidc", Subject: "subject"}, Digest: "old-digest", } require.NoError(t, db.Table(table).Create(&row).Error) recorder.reset() require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV1{})) assert.Empty(t, recorder.schemaMutations()) require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV2{})) columns, err := db.Table(table).Migrator().ColumnTypes(&migrationIdentityV2{}) require.NoError(t, err) for _, column := range columns { if column.Name() == "digest" { length, ok := column.Length() require.True(t, ok) assert.EqualValues(t, 64, length) } } assert.True(t, db.Table(table).Migrator().HasColumn(&migrationIdentityV2{}, "note")) recorder.reset() require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV2{})) assert.Empty(t, recorder.schemaMutations()) var saved migrationIdentityV2 require.NoError(t, db.Table(table).First(&saved, 1).Error) assert.Equal(t, row.MigrationIdentityFields, saved.MigrationIdentityFields) expectedDigest := row.Digest if dialect == "postgres" { expectedDigest += strings.Repeat(" ", 64-len(row.Digest)) } assert.Equal(t, expectedDigest, saved.Digest) for _, duplicate := range []migrationIdentityV2{ {MigrationIdentityFields: MigrationIdentityFields{Name: "root", Reference: "other-1", Provider: "other", Subject: "1"}}, {MigrationIdentityFields: MigrationIdentityFields{Name: "other-2", Reference: "token-reference", Provider: "other", Subject: "2"}}, {MigrationIdentityFields: MigrationIdentityFields{Name: "other-3", Reference: "other-3", Provider: "oidc", Subject: "subject"}}, } { assert.Error(t, db.Table(table).Create(&duplicate).Error) } }) t.Run("unique_constraint_changes", func(t *testing.T) { const table = "migration_constraint_test" t.Cleanup(func() { _ = db.Migrator().DropTable(table) }) require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV1{})) require.NoError(t, db.Table(table).Create(&migrationConstraintV1{Name: "existing"}).Error) require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV2{})) assert.Error(t, db.Table(table).Create(&migrationConstraintV2{Name: "existing"}).Error) recorder.reset() require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV2{})) assert.Empty(t, recorder.schemaMutations()) require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV1{})) require.NoError(t, db.Table(table).Create(&migrationConstraintV1{Name: "existing"}).Error) }) if dialect == "mysql" { t.Run("decimal_default_and_real_changes", func(t *testing.T) { const table = "migration_decimal_test" t.Cleanup(func() { _ = db.Migrator().DropTable(table) }) require.NoError(t, db.Table(table).AutoMigrate(&migrationDecimalV1{})) require.NoError(t, db.Table(table).Create(&migrationDecimalV1{ID: 1, Price: 12.345678}).Error) for _, target := range []any{&migrationDecimalV1{}, &migrationDecimalV2{}, &migrationDecimalV3{}} { require.NoError(t, db.Table(table).AutoMigrate(target)) recorder.reset() require.NoError(t, db.Table(table).AutoMigrate(target)) assert.Empty(t, recorder.schemaMutations()) } columns, err := db.Table(table).Migrator().ColumnTypes(&migrationDecimalV3{}) require.NoError(t, err) for _, column := range columns { if column.Name() == "price" { precision, scale, ok := column.DecimalSize() require.True(t, ok) assert.EqualValues(t, 12, precision) assert.EqualValues(t, 6, scale) nullable, ok := column.Nullable() require.True(t, ok) assert.False(t, nullable) } } require.NoError(t, db.Table(table).Create(&map[string]any{"id": 2}).Error) var prices []float64 require.NoError(t, db.Table(table).Order("id").Pluck("price", &prices).Error) assert.Equal(t, []float64{12.345678, 1.25}, prices) }) } }) } }