Commit 9a867442 by CaIon

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.
parent d8cb1774
...@@ -135,6 +135,8 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag ...@@ -135,6 +135,8 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
- Avoid hand-written assertion helpers unless they encode a reusable project-specific invariant. - Avoid hand-written assertion helpers unless they encode a reusable project-specific invariant.
- When cleaning tests, preserve meaningful regression coverage. If a deleted test covered a real contract indirectly, replace it with a smaller test that asserts that contract directly. - When cleaning tests, preserve meaningful regression coverage. If a deleted test covered a real contract indirectly, replace it with a smaller test that asserts that contract directly.
**Documentation files:** Do NOT add new files under `docs/` or any of its subdirectories unless the user explicitly requests it.
### Frontend Rules ### Frontend Rules
- Use `bun` as the preferred package manager and script runner for the frontend (`web/`): - Use `bun` as the preferred package manager and script runner for the frontend (`web/`):
......
...@@ -18,7 +18,7 @@ require ( ...@@ -18,7 +18,7 @@ require (
github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/static v0.0.1 github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0 github.com/glebarez/sqlite v1.11.0
github.com/go-audio/aiff v1.1.0 github.com/go-audio/aiff v1.1.0
github.com/go-audio/wav v1.1.0 github.com/go-audio/wav v1.1.0
github.com/go-playground/validator/v10 v10.20.0 github.com/go-playground/validator/v10 v10.20.0
......
...@@ -1118,6 +1118,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g ...@@ -1118,6 +1118,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-audio/aiff v1.1.0 h1:m2LYgu/2BarpF2yZnFPWtY3Tp41k0A4y51gDRZZsEuU= github.com/go-audio/aiff v1.1.0 h1:m2LYgu/2BarpF2yZnFPWtY3Tp41k0A4y51gDRZZsEuU=
github.com/go-audio/aiff v1.1.0/go.mod h1:sDik1muYvhPiccClfri0fv6U2fyH/dy4VRWmUz0cz9Q= github.com/go-audio/aiff v1.1.0/go.mod h1:sDik1muYvhPiccClfri0fv6U2fyH/dy4VRWmUz0cz9Q=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
......
...@@ -153,10 +153,10 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, common.DatabaseType, error) ...@@ -153,10 +153,10 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, common.DatabaseType, error)
common.SysLog("using PostgreSQL as database") common.SysLog("using PostgreSQL as database")
// 同时关闭 pgx 隐式与 GORM 显式预处理语句:命名 prepared statement 与 // 同时关闭 pgx 隐式与 GORM 显式预处理语句:命名 prepared statement 与
// 事务池代理(PgBouncer/Neon/Supabase)不兼容,会触发 FATAL 08P01/42P05。 // 事务池代理(PgBouncer/Neon/Supabase)不兼容,会触发 FATAL 08P01/42P05。
db, err := gorm.Open(postgres.New(postgres.Config{ db, err := gorm.Open(postgresMigrationDialector{postgres.Dialector{Config: &postgres.Config{
DSN: dsn, DSN: dsn,
PreferSimpleProtocol: true, PreferSimpleProtocol: true,
}), newGormConfig(false)) }}}, newGormConfig(false))
return db, common.DatabaseTypePostgreSQL, err return db, common.DatabaseTypePostgreSQL, err
} }
if strings.HasPrefix(dsn, "local") { if strings.HasPrefix(dsn, "local") {
...@@ -174,7 +174,7 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, common.DatabaseType, error) ...@@ -174,7 +174,7 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, common.DatabaseType, error)
dsn += "?parseTime=true" dsn += "?parseTime=true"
} }
} }
db, err := gorm.Open(mysql.Open(dsn), newGormConfig(true)) db, err := gorm.Open(mysqlMigrationDialector{mysql.Dialector{Config: &mysql.Config{DSN: dsn}}}, newGormConfig(true))
return db, common.DatabaseTypeMySQL, err return db, common.DatabaseTypeMySQL, err
} }
// Use SQLite // Use SQLite
......
package model
import (
"strings"
"github.com/shopspring/decimal"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// Embed the concrete dialectors to retain their transaction/savepoint and other
// optional GORM interfaces. Only schema comparison needs normalization.
type mysqlMigrationDialector struct{ mysql.Dialector }
func (d mysqlMigrationDialector) Migrator(db *gorm.DB) gorm.Migrator {
return mysqlSchemaMigrator{d.Dialector.Migrator(db).(mysql.Migrator)}
}
type mysqlSchemaMigrator struct{ mysql.Migrator }
func (m mysqlSchemaMigrator) MigrateColumn(value any, field *schema.Field, column gorm.ColumnType) error {
if !field.HasDefaultValue || !strings.EqualFold(column.DatabaseTypeName(), "decimal") {
return m.Migrator.MigrateColumn(value, field, column)
}
stored, ok := column.DefaultValue()
if !ok {
return m.Migrator.MigrateColumn(value, field, column)
}
storedNumber, storedErr := decimal.NewFromString(stored)
modelNumber, modelErr := decimal.NewFromString(field.DefaultValue)
if storedErr != nil || modelErr != nil || !storedNumber.Equal(modelNumber) {
return m.Migrator.MigrateColumn(value, field, column)
}
// MySQL pads decimal defaults (0 -> 0.000000). Skip only the equivalent
// default comparison, retaining type/size/null checks. AlterColumn still
// reads the original model, including its default.
comparisonField := *field
comparisonField.HasDefaultValue = false
comparisonField.DefaultValue = ""
comparisonField.DefaultValueInterface = nil
return m.Migrator.MigrateColumn(value, &comparisonField, columnWithoutDefault{column})
}
type migrationColumnType interface{ gorm.ColumnType }
type columnWithoutDefault struct{ migrationColumnType }
func (columnWithoutDefault) DefaultValue() (string, bool) { return "", false }
type postgresMigrationDialector struct{ postgres.Dialector }
func (d postgresMigrationDialector) Migrator(db *gorm.DB) gorm.Migrator {
return postgresSchemaMigrator{d.Dialector.Migrator(db).(postgres.Migrator)}
}
type postgresSchemaMigrator struct{ postgres.Migrator }
func (m postgresSchemaMigrator) MigrateColumn(value any, field *schema.Field, column gorm.ColumnType) error {
if column.DatabaseTypeName() == "bpchar" && strings.HasPrefix(strings.ToLower(string(field.DataType)), "char(") {
// PostgreSQL reports CHAR(n) as bpchar. Normalize the name, retaining
// Length() so a real CHAR length change still triggers migration.
column = charColumnType{column}
}
return m.Migrator.MigrateColumn(value, field, column)
}
type charColumnType struct{ migrationColumnType }
func (charColumnType) DatabaseTypeName() string { return "char" }
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)
})
}
})
}
}
...@@ -60,7 +60,10 @@ func (recorder *migrationSQLRecorder) schemaMutations() []string { ...@@ -60,7 +60,10 @@ func (recorder *migrationSQLRecorder) schemaMutations() []string {
if strings.HasPrefix(normalized, "ALTER TABLE") || if strings.HasPrefix(normalized, "ALTER TABLE") ||
strings.HasPrefix(normalized, "CREATE TABLE") || strings.HasPrefix(normalized, "CREATE TABLE") ||
strings.HasPrefix(normalized, "DROP TABLE") || strings.HasPrefix(normalized, "DROP TABLE") ||
strings.HasPrefix(normalized, "RENAME TABLE") { strings.HasPrefix(normalized, "RENAME TABLE") ||
strings.HasPrefix(normalized, "CREATE INDEX") ||
strings.HasPrefix(normalized, "CREATE UNIQUE INDEX") ||
strings.HasPrefix(normalized, "DROP INDEX") {
mutations = append(mutations, statement) mutations = append(mutations, statement)
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment