fix: 兼容旧媒体路由约束迁移 (#95)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -19,6 +21,9 @@ func init() {
|
||||
|
||||
func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := prepareLegacyMediaRouteSchema(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&media.Route{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
type mediaPathConstraint struct {
|
||||
Name string
|
||||
Columns string
|
||||
}
|
||||
|
||||
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
|
||||
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
|
||||
// the runtime-state columns now required by the Route model.
|
||||
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
var tableCount int64
|
||||
if err := tx.Raw(`SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
|
||||
return fmt.Errorf("inspect legacy media route table: %w", err)
|
||||
}
|
||||
if tableCount == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
|
||||
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
|
||||
}
|
||||
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
|
||||
var constraints []mediaPathConstraint
|
||||
if err := tx.Raw(`SELECT c.conname AS name,
|
||||
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'sense_media_routes'
|
||||
AND c.contype = 'u'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(c.conkey) AS key(attnum)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
|
||||
WHERE a.attname = 'path'
|
||||
)
|
||||
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
|
||||
return fmt.Errorf("inspect legacy media path constraints: %w", err)
|
||||
}
|
||||
if len(constraints) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(constraints) != 1 || constraints[0].Columns != "path" {
|
||||
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
|
||||
}
|
||||
const expectedName = "uni_sense_media_routes_path"
|
||||
if constraints[0].Name == expectedName {
|
||||
return nil
|
||||
}
|
||||
var conflictingNameCount int64
|
||||
if err := tx.Raw(`SELECT COUNT(*)
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'sense_media_routes'
|
||||
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
|
||||
return fmt.Errorf("inspect target media path constraint name: %w", err)
|
||||
}
|
||||
if conflictingNameCount != 0 {
|
||||
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
|
||||
}
|
||||
rename := fmt.Sprintf(
|
||||
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
|
||||
quotePostgresIdentifier(constraints[0].Name),
|
||||
quotePostgresIdentifier(expectedName),
|
||||
)
|
||||
if err := tx.Exec(rename).Error; err != nil {
|
||||
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
|
||||
for _, statement := range []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
|
||||
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
|
||||
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
|
||||
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
|
||||
SET "source_ready" = COALESCE("source_ready", false),
|
||||
"failure_count" = COALESCE("failure_count", 0),
|
||||
"last_error_code" = COALESCE("last_error_code", '')
|
||||
WHERE "source_ready" IS NULL
|
||||
OR "failure_count" IS NULL
|
||||
OR "last_error_code" IS NULL`},
|
||||
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
|
||||
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
|
||||
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
|
||||
} {
|
||||
if err := tx.Exec(statement.sql).Error; err != nil {
|
||||
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quotePostgresIdentifier(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
const schema = "sense_media_95_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
|
||||
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
|
||||
if err = db.Exec(`INSERT INTO sense_media_routes
|
||||
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
|
||||
VALUES
|
||||
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
|
||||
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const migrationVersion = "2026081419000_media.go"
|
||||
if err = migrateSenseMedia(db, migrationVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes, menus, policies, applied int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
|
||||
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||
}
|
||||
assertMediaPathUniqueIndex(t, db)
|
||||
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
|
||||
t.Fatal("expected path uniqueness violation")
|
||||
}
|
||||
var runtimeState struct {
|
||||
SourceReady bool
|
||||
FailureCount int64
|
||||
LastError string
|
||||
}
|
||||
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
|
||||
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
|
||||
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
|
||||
t.Fatalf("repeat compatibility migration: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does nothing when the table is absent", func(t *testing.T) {
|
||||
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates a fresh media table", func(t *testing.T) {
|
||||
resetEmptyMediaMigration(t, db)
|
||||
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 0 {
|
||||
t.Fatalf("fresh route count=%d", routes)
|
||||
}
|
||||
assertMediaPathUniqueIndex(t, db)
|
||||
})
|
||||
|
||||
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
|
||||
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
|
||||
t.Fatalf("expected unsupported uniqueness error, got %v", err)
|
||||
}
|
||||
var constraints int64
|
||||
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if constraints != 1 {
|
||||
t.Fatalf("constraint rollback count=%d", constraints)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
|
||||
}
|
||||
|
||||
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes, menus, policies, applied int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
createRouteTable := `CREATE TABLE sense_media_routes (
|
||||
id text PRIMARY KEY,
|
||||
device_id text NOT NULL,
|
||||
profile_token text NOT NULL,
|
||||
path text NOT NULL,
|
||||
desired text NOT NULL,
|
||||
actual text NOT NULL,
|
||||
readers integer NOT NULL DEFAULT 0,
|
||||
detail text NOT NULL DEFAULT '',
|
||||
version bigint NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
` + pathConstraint + `
|
||||
)`
|
||||
if err := db.Exec(createRouteTable).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
|
||||
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var indexes []struct {
|
||||
IndexName string
|
||||
IndexDef string
|
||||
}
|
||||
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'sense_media_routes'
|
||||
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, index := range indexes {
|
||||
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing unique path index: %#v", indexes)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user