diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index f6857c01..0c6379be 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -1,5 +1,10 @@ package migration +import ( + "regexp" + "strings" +) + func DefaultMigrations() []Migration { return []Migration{ WithCompatibleChecksums( @@ -22,3 +27,27 @@ func DefaultMigrations() []Migration { DefineCustom(15, "expand_security_setup", "expand_security_setup_v1", applySecuritySetupSchema), } } + +var sqliteIndexDefinitionPattern = regexp.MustCompile(`(?i)^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`) + +// ManagedSQLiteIndexNames 返回版本化迁移允许存在的 SQLite 索引名称。 +// 自动生成的 sqlite_autoindex_* 不包含在列表中,由 SQLite 自身维护。 +func ManagedSQLiteIndexNames() map[string]struct{} { + managed := make(map[string]struct{}) + for _, item := range DefaultMigrations() { + for _, statement := range item.StatementsFor(DialectSQLite) { + matches := sqliteIndexDefinitionPattern.FindStringSubmatch(strings.TrimSpace(statement)) + if len(matches) == 2 { + managed[matches[1]] = struct{}{} + } + } + } + for _, name := range []string{ + "uq_user_email_address_canonical", + "idx_user_email_user_primary", + "idx_verification_event_expires_at", + } { + managed[name] = struct{}{} + } + return managed +} diff --git a/backend/services/dbmigration/portable_schema.go b/backend/services/dbmigration/portable_schema.go index 93a0fed6..2160c42a 100644 --- a/backend/services/dbmigration/portable_schema.go +++ b/backend/services/dbmigration/portable_schema.go @@ -2,16 +2,16 @@ package dbmigration import ( "context" + "database/sql" + "strings" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db/migration" ) // 仅迁移 Smail 版本化业务表;不能静默忽略自定义对象。 func validatePortableObjects(ctx context.Context, database portableQuery, kind config.DatabaseType) error { queries := []string{"SELECT COUNT(*) FROM sqlite_master WHERE type IN ('view','trigger')"} - if kind == config.DatabaseTypeSQLite { - queries = append(queries, "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND sql IS NOT NULL") - } switch kind { case config.DatabaseTypeMySQL: queries = []string{ @@ -42,5 +42,33 @@ func validatePortableObjects(ctx context.Context, database portableQuery, kind c return ErrSQLUnsupportedObject } } + if kind == config.DatabaseTypeSQLite { + if err := validateSQLiteIndexes(ctx, database); err != nil { + return err + } + } + return nil +} + +func validateSQLiteIndexes(ctx context.Context, database portableQuery) error { + rows, err := database.QueryContext(ctx, "SELECT name, sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL") + if err != nil { + return ErrSQLUnsupportedObject + } + defer rows.Close() + managed := migration.ManagedSQLiteIndexNames() + for rows.Next() { + var name string + var definition sql.NullString + if err := rows.Scan(&name, &definition); err != nil || !definition.Valid || strings.TrimSpace(definition.String) == "" { + return ErrSQLUnsupportedObject + } + if _, ok := managed[name]; !ok { + return ErrSQLUnsupportedObject + } + } + if err := rows.Err(); err != nil { + return ErrSQLUnsupportedObject + } return nil }