Files
new-api/model/migration_dialector_test.go
T
CaIon 9a8674425c fix(db): avoid redundant schema migrations on restart
Upgrade the SQLite GORM driver to correctly distinguish unique indexes from constraints. Normalize equivalent MySQL decimal defaults and PostgreSQL CHAR metadata during migration comparison while preserving real schema changes.

Validation: 27 fresh-start and rc.26 upgrade scenarios using SQLite 3.50.4, MySQL 5.7.44, PostgreSQL 9.6.24, and ClickHouse 25.8.33.6; 15 upgraded databases passed uniqueness checks. Relational databases issue no DDL on unchanged restarts. Existing ClickHouse TTL synchronization remains unchanged.
2026-09-06 22:48:58 +08:00

178 lines
6.5 KiB
Go

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)
})
}
})
}
}