fix(database): 素材存储 - 扩容生成素材 ID 及关联字段
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -71,6 +75,9 @@ func MigrateSchema(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := widenPostgresAssetIDColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(Models()...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -83,3 +90,44 @@ func MigrateSchema(db *gorm.DB) error {
|
||||
}
|
||||
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error
|
||||
}
|
||||
|
||||
type varcharColumnMigration struct {
|
||||
table string
|
||||
column string
|
||||
statement string
|
||||
}
|
||||
|
||||
var assetIDColumnMigrations = []varcharColumnMigration{
|
||||
{table: "assets", column: "id", statement: `ALTER TABLE "assets" ALTER COLUMN "id" TYPE varchar(80)`},
|
||||
{table: "project_asset_links", column: "asset_id", statement: `ALTER TABLE "project_asset_links" ALTER COLUMN "asset_id" TYPE varchar(80)`},
|
||||
{table: "project_asset_candidates", column: "resolved_asset_id", statement: `ALTER TABLE "project_asset_candidates" ALTER COLUMN "resolved_asset_id" TYPE varchar(80)`},
|
||||
{table: "asset_versions", column: "asset_id", statement: `ALTER TABLE "asset_versions" ALTER COLUMN "asset_id" TYPE varchar(80)`},
|
||||
}
|
||||
|
||||
// PostgreSQL Migrator 跳过主键列变更,素材 ID 扩容必须在 AutoMigrate 前显式执行。
|
||||
func widenPostgresAssetIDColumns(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
for _, migration := range assetIDColumnMigrations {
|
||||
var currentLength sql.NullInt64
|
||||
err := db.Raw(
|
||||
"SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?",
|
||||
migration.table,
|
||||
migration.column,
|
||||
).Row().Scan(¤tLength)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err)
|
||||
}
|
||||
if !currentLength.Valid || currentLength.Int64 >= model.AssetIDMaxLength {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(migration.statement).Error; err != nil {
|
||||
return fmt.Errorf("扩容 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
106
backend/internal/database/schema_postgres_test.go
Normal file
106
backend/internal/database/schema_postgres_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestPostgresAssetIDMigration(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("CANVAS_TEST_POSTGRES_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured")
|
||||
}
|
||||
|
||||
base, err := Open(Config{Driver: "postgres", DSN: dsn})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
baseSQL, err := base.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("postgres sql db: %v", err)
|
||||
}
|
||||
defer baseSQL.Close()
|
||||
|
||||
schemaName := fmt.Sprintf("asset_id_migration_%d", time.Now().UnixNano())
|
||||
if err := base.Exec(`CREATE SCHEMA "` + schemaName + `"`).Error; err != nil {
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := base.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`).Error; err != nil {
|
||||
t.Errorf("drop test schema: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
testDSN, err := postgresDSNWithSearchPath(dsn, schemaName)
|
||||
if err != nil {
|
||||
t.Fatalf("test postgres dsn: %v", err)
|
||||
}
|
||||
db, err := Open(Config{Driver: "postgres", DSN: testDSN})
|
||||
if err != nil {
|
||||
t.Fatalf("open test schema: %v", err)
|
||||
}
|
||||
dbSQL, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("test schema sql db: %v", err)
|
||||
}
|
||||
defer dbSQL.Close()
|
||||
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE assets (id varchar(36) PRIMARY KEY)`,
|
||||
`CREATE TABLE project_asset_links (id varchar(36) PRIMARY KEY, asset_id varchar(36))`,
|
||||
`CREATE TABLE project_asset_candidates (id varchar(36) PRIMARY KEY, resolved_asset_id varchar(36))`,
|
||||
`CREATE TABLE asset_versions (id varchar(36) PRIMARY KEY, asset_id varchar(36))`,
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
}
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatalf("migrate schema: %v", err)
|
||||
}
|
||||
|
||||
for _, migration := range assetIDColumnMigrations {
|
||||
var length int64
|
||||
if err := db.Raw(
|
||||
"SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?",
|
||||
migration.table,
|
||||
migration.column,
|
||||
).Scan(&length).Error; err != nil {
|
||||
t.Fatalf("read migrated column %s.%s: %v", migration.table, migration.column, err)
|
||||
}
|
||||
if length != model.AssetIDMaxLength {
|
||||
t.Fatalf("%s.%s length = %d, want %d", migration.table, migration.column, length, model.AssetIDMaxLength)
|
||||
}
|
||||
}
|
||||
|
||||
assetID := "generation_" + strings.Repeat("a", 64)
|
||||
if err := db.Create(&model.Asset{ID: assetID, UserID: "user-1", Kind: "image"}).Error; err != nil {
|
||||
t.Fatalf("insert deterministic generation asset: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ProjectAssetLink{ID: "link-1", ProjectID: "project-1", AssetID: assetID}).Error; err != nil {
|
||||
t.Fatalf("insert project asset link: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", ResolvedAssetID: assetID}).Error; err != nil {
|
||||
t.Fatalf("insert project asset candidate: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.AssetVersion{ID: "version-1", AssetID: assetID, Version: 1}).Error; err != nil {
|
||||
t.Fatalf("insert asset version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func postgresDSNWithSearchPath(dsn string, schemaName string) (string, error) {
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schemaName)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
62
backend/internal/database/schema_test.go
Normal file
62
backend/internal/database/schema_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestAssetIDColumnsUseSharedLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
value any
|
||||
field string
|
||||
}{
|
||||
{value: &model.Asset{}, field: "ID"},
|
||||
{value: &model.ProjectAssetLink{}, field: "AssetID"},
|
||||
{value: &model.ProjectAssetCandidate{}, field: "ResolvedAssetID"},
|
||||
{value: &model.AssetVersion{}, field: "AssetID"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
parsed, err := schema.Parse(test.value, &sync.Map{}, schema.NamingStrategy{})
|
||||
if err != nil {
|
||||
t.Fatalf("parse schema: %v", err)
|
||||
}
|
||||
field := parsed.LookUpField(test.field)
|
||||
if field == nil {
|
||||
t.Fatalf("field %s not found in %s", test.field, parsed.Table)
|
||||
}
|
||||
if field.Size != model.AssetIDMaxLength {
|
||||
t.Fatalf("%s.%s size = %d, want %d", parsed.Table, field.DBName, field.Size, model.AssetIDMaxLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAssetIDMigrationsCoverEveryAssetIDColumn(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"assets.id": false,
|
||||
"project_asset_links.asset_id": false,
|
||||
"project_asset_candidates.resolved_asset_id": false,
|
||||
"asset_versions.asset_id": false,
|
||||
}
|
||||
for _, migration := range assetIDColumnMigrations {
|
||||
key := migration.table + "." + migration.column
|
||||
if _, exists := want[key]; !exists {
|
||||
t.Fatalf("unexpected asset ID migration %s", key)
|
||||
}
|
||||
if !strings.Contains(migration.statement, fmt.Sprintf("varchar(%d)", model.AssetIDMaxLength)) {
|
||||
t.Fatalf("asset ID migration %s does not use limit %d", key, model.AssetIDMaxLength)
|
||||
}
|
||||
want[key] = true
|
||||
}
|
||||
for key, covered := range want {
|
||||
if !covered {
|
||||
t.Fatalf("missing asset ID migration %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user