fix: 兼容旧设备能力字段迁移 (#92)
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -34,6 +37,9 @@ func init() {
|
||||
|
||||
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := prepareLegacyDeviceCapabilities(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -89,6 +95,104 @@ func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||
})
|
||||
}
|
||||
|
||||
var supportedLegacyCapabilities = map[string]struct{}{
|
||||
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
|
||||
}
|
||||
|
||||
type legacyDeviceCapabilitiesRow struct {
|
||||
ID string
|
||||
Capabilities sql.NullString
|
||||
}
|
||||
|
||||
// prepareLegacyDeviceCapabilities upgrades the pre-GoAdmin text column before
|
||||
// GORM sees it. PostgreSQL cannot cast the old empty-string default to jsonb,
|
||||
// and old rows stored a single capability token rather than a JSON array.
|
||||
func prepareLegacyDeviceCapabilities(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
type columnMetadata struct {
|
||||
DataType string
|
||||
}
|
||||
var column columnMetadata
|
||||
result := tx.Raw(`SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_devices'
|
||||
AND column_name = 'capabilities'`).Scan(&column)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("inspect sense_devices.capabilities: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 || column.DataType == "json" || column.DataType == "jsonb" {
|
||||
return nil
|
||||
}
|
||||
if column.DataType != "text" && column.DataType != "character varying" {
|
||||
return fmt.Errorf("sense_devices.capabilities has unsupported legacy type %q", column.DataType)
|
||||
}
|
||||
if err := tx.Exec(`LOCK TABLE "sense_devices" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
|
||||
return fmt.Errorf("lock sense_devices for capabilities migration: %w", err)
|
||||
}
|
||||
var rows []legacyDeviceCapabilitiesRow
|
||||
if err := tx.Raw(`SELECT id, capabilities FROM "sense_devices" ORDER BY id`).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("read legacy device capabilities: %w", err)
|
||||
}
|
||||
canonical := make(map[string]string, len(rows))
|
||||
invalid := 0
|
||||
for _, row := range rows {
|
||||
value, err := canonicalLegacyCapabilities(row.Capabilities)
|
||||
if err != nil {
|
||||
invalid++
|
||||
continue
|
||||
}
|
||||
canonical[row.ID] = value
|
||||
}
|
||||
if invalid > 0 {
|
||||
return fmt.Errorf("sense_devices.capabilities contains unsupported legacy data in %d row(s); migration rolled back", invalid)
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" DROP DEFAULT`).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy capabilities default: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := tx.Exec(`UPDATE "sense_devices" SET "capabilities" = ? WHERE "id" = ?`, canonical[row.ID], row.ID).Error; err != nil {
|
||||
return fmt.Errorf("normalize legacy device capabilities: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" TYPE jsonb USING "capabilities"::jsonb`).Error; err != nil {
|
||||
return fmt.Errorf("convert capabilities to jsonb: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" SET DEFAULT '[]'::jsonb`).Error; err != nil {
|
||||
return fmt.Errorf("set jsonb capabilities default: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalLegacyCapabilities(value sql.NullString) (string, error) {
|
||||
if !value.Valid || strings.TrimSpace(value.String) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(value.String)
|
||||
if _, ok := supportedLegacyCapabilities[trimmed]; ok {
|
||||
encoded, _ := json.Marshal([]string{trimmed})
|
||||
return string(encoded), nil
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "[") {
|
||||
return "", fmt.Errorf("legacy capability value is not an array")
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &values); err != nil || values == nil || len(values) > 16 {
|
||||
return "", fmt.Errorf("legacy capability array is invalid")
|
||||
}
|
||||
for index, item := range values {
|
||||
item = strings.TrimSpace(item)
|
||||
if _, ok := supportedLegacyCapabilities[item]; !ok {
|
||||
return "", fmt.Errorf("legacy capability array contains an unsupported value")
|
||||
}
|
||||
values[index] = item
|
||||
}
|
||||
encoded, _ := json.Marshal(values)
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
|
||||
var menu migrationModels.SysMenu
|
||||
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
)
|
||||
|
||||
func TestCanonicalLegacyCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input sql.NullString
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "null", input: sql.NullString{}, want: "[]"},
|
||||
{name: "blank", input: sql.NullString{String: " ", Valid: true}, want: "[]"},
|
||||
{name: "single", input: sql.NullString{String: " video ", Valid: true}, want: `["video"]`},
|
||||
{name: "array", input: sql.NullString{String: `["radar", "contact"]`, Valid: true}, want: `["radar","contact"]`},
|
||||
{name: "empty array", input: sql.NullString{String: `[]`, Valid: true}, want: `[]`},
|
||||
{name: "unknown", input: sql.NullString{String: "unknown", Valid: true}, wantErr: true},
|
||||
{name: "object", input: sql.NullString{String: `{"video":true}`, Valid: true}, wantErr: true},
|
||||
{name: "unknown array item", input: sql.NullString{String: `["video","unknown"]`, Valid: true}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := canonicalLegacyCapabilities(test.input)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("got %q, %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_device_92_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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("converts legacy values and remains idempotent", func(t *testing.T) {
|
||||
resetLegacyDeviceTable(t, db)
|
||||
for _, row := range [][2]string{{"single", "video"}, {"blank", ""}, {"array", `["radar","contact"]`}} {
|
||||
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES (?, ?)`, row[0], row[1]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&deviceModels.Device{}); err != nil {
|
||||
t.Fatalf("AutoMigrate after compatibility conversion: %v", err)
|
||||
}
|
||||
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
|
||||
var values []struct{ ID, Capabilities string }
|
||||
if err = db.Raw(`SELECT id, capabilities::text AS capabilities FROM sense_devices ORDER BY id`).Scan(&values).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, value := range values {
|
||||
got[value.ID] = strings.ReplaceAll(value.Capabilities, " ", "")
|
||||
}
|
||||
if got["single"] != `["video"]` || got["blank"] != `[]` || got["array"] != `["radar","contact"]` {
|
||||
t.Fatalf("unexpected converted values: %#v", got)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
|
||||
t.Fatalf("repeat migration: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown values without partial conversion", func(t *testing.T) {
|
||||
resetLegacyDeviceTable(t, db)
|
||||
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('valid', 'video'), ('invalid', 'unknown')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err == nil {
|
||||
t.Fatal("expected unsupported legacy data error")
|
||||
}
|
||||
assertCapabilitiesColumn(t, db, "text", "''::text")
|
||||
var value string
|
||||
if err = db.Raw(`SELECT capabilities FROM sense_devices WHERE id = 'valid'`).Scan(&value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "video" {
|
||||
t.Fatalf("transaction left partial data: %q", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does nothing when the table is absent", func(t *testing.T) {
|
||||
if err = db.Exec(`DROP TABLE sense_devices`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func resetLegacyDeviceTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_devices`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE sense_devices (
|
||||
id text PRIMARY KEY,
|
||||
name varchar(128) NOT NULL DEFAULT '',
|
||||
location varchar(255) NOT NULL DEFAULT '',
|
||||
modality varchar(32) NOT NULL DEFAULT 'video',
|
||||
capabilities text NOT NULL DEFAULT '',
|
||||
status varchar(32) NOT NULL DEFAULT 'active',
|
||||
adapter_status varchar(32) NOT NULL DEFAULT 'ready',
|
||||
rtsp_credential_same_as_onvif boolean NOT NULL DEFAULT true,
|
||||
credential_updated_at timestamptz,
|
||||
retry_requested_at timestamptz,
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
create_by bigint NOT NULL DEFAULT 0,
|
||||
update_by bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
deleted_at timestamptz
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCapabilitiesColumn(t *testing.T, db *gorm.DB, wantType, wantDefault string) {
|
||||
t.Helper()
|
||||
var column struct{ DataType, ColumnDefault string }
|
||||
if err := db.Raw(`SELECT data_type, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_devices'
|
||||
AND column_name = 'capabilities'`).Scan(&column).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if column.DataType != wantType || column.ColumnDefault != wantDefault {
|
||||
t.Fatalf("type/default=%q/%q; want %q/%q", column.DataType, column.ColumnDefault, wantType, wantDefault)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user