This commit is contained in:
parent
8390e27e6c
commit
57f1dab894
@ -12,6 +12,8 @@
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
padding-right: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
@ -95,10 +97,15 @@
|
||||
}
|
||||
|
||||
.database-form {
|
||||
flex: 1 1 620px;
|
||||
width: min(100%, 620px);
|
||||
min-width: 520px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.database-form .el-form {
|
||||
.database-form .el-form,
|
||||
.database-form :deep(.el-select),
|
||||
.database-form :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -129,7 +136,7 @@
|
||||
|
||||
.database-inline-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(170px, 0.55fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@ -137,6 +144,14 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.database-host-field :deep(.el-form-item__label) {
|
||||
width: 88px !important;
|
||||
}
|
||||
|
||||
.database-port-field :deep(.el-form-item__label) {
|
||||
width: 56px !important;
|
||||
}
|
||||
|
||||
.database-host-field,
|
||||
.database-port-field {
|
||||
margin-bottom: 18px;
|
||||
@ -628,6 +643,12 @@
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.database-form {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.database-inline-fields {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
|
||||
@ -10,12 +10,13 @@ import (
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int64
|
||||
Name string
|
||||
Statements []string
|
||||
DialectStatements map[Dialect][]string
|
||||
ChecksumSeed string
|
||||
Apply func(context.Context, executor, Dialect) error
|
||||
Version int64
|
||||
Name string
|
||||
Statements []string
|
||||
DialectStatements map[Dialect][]string
|
||||
ChecksumSeed string
|
||||
Apply func(context.Context, executor, Dialect) error
|
||||
CompatibleChecksums []string
|
||||
}
|
||||
|
||||
func Define(version int64, name string, statements ...string) Migration {
|
||||
@ -47,6 +48,26 @@ func (m Migration) StatementsFor(dialect Dialect) []string {
|
||||
return append([]string(nil), m.Statements...)
|
||||
}
|
||||
|
||||
func WithCompatibleChecksums(migration Migration, checksums ...string) Migration {
|
||||
migration.CompatibleChecksums = make([]string, len(checksums))
|
||||
for index, checksum := range checksums {
|
||||
migration.CompatibleChecksums[index] = strings.TrimSpace(checksum)
|
||||
}
|
||||
return migration
|
||||
}
|
||||
|
||||
func (m Migration) AcceptsChecksum(checksum string) bool {
|
||||
if checksum == m.Checksum() {
|
||||
return true
|
||||
}
|
||||
for _, compatible := range m.CompatibleChecksums {
|
||||
if checksum == compatible {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Migration) Checksum() string {
|
||||
hash := sha256.New()
|
||||
_, _ = fmt.Fprintf(hash, "%d\x00%s\x00", m.Version, m.Name)
|
||||
@ -87,6 +108,18 @@ func normalize(migrations []Migration) ([]Migration, error) {
|
||||
if item.Version <= 0 || item.Name == "" || !hasStatements(item) {
|
||||
return nil, fmt.Errorf("invalid migration definition at index %d", i)
|
||||
}
|
||||
knownChecksums := map[string]struct{}{item.Checksum(): {}}
|
||||
for _, checksum := range item.CompatibleChecksums {
|
||||
checksum = strings.TrimSpace(checksum)
|
||||
decoded, decodeErr := hex.DecodeString(checksum)
|
||||
if decodeErr != nil || len(decoded) != sha256.Size {
|
||||
return nil, fmt.Errorf("migration %d contains an invalid compatible checksum", item.Version)
|
||||
}
|
||||
if _, exists := knownChecksums[checksum]; exists {
|
||||
return nil, fmt.Errorf("migration %d contains a duplicate compatible checksum", item.Version)
|
||||
}
|
||||
knownChecksums[checksum] = struct{}{}
|
||||
}
|
||||
for _, statement := range item.Statements {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
return nil, fmt.Errorf("migration %d contains an empty statement", item.Version)
|
||||
|
||||
@ -2,7 +2,10 @@ package migration
|
||||
|
||||
func DefaultMigrations() []Migration {
|
||||
return []Migration{
|
||||
DefineForDialects(1, "create_core_schema", coreSchemaStatements()),
|
||||
WithCompatibleChecksums(
|
||||
DefineForDialects(1, "create_core_schema", coreSchemaStatements()),
|
||||
"57b378b273f243bae44e92cad161f297248e5226e96fbd7905815aa8604146bc",
|
||||
),
|
||||
DefineCustom(2, "expand_user_security", "expand_user_security_v1", applyUserSecuritySchema),
|
||||
DefineForDialects(3, "create_security_email_tables", securityEmailStatements()),
|
||||
DefineForDialects(4, "create_account_recovery_tables", credentialStatements()),
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) {
|
||||
@ -198,3 +199,80 @@ func TestDefaultMigrationsAreContiguousAndDialectComplete(t *testing.T) {
|
||||
t.Fatalf("default migrations failed normalization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
||||
expected := map[int64]string{
|
||||
1: "bb9bbf1ef17f1430900b8c5bed3539d01f72d49bdfedc2528dd3cf089b2a00d8",
|
||||
2: "b91524dc66ba86962372368ed7254e0c33eda6f76d487d6d3d291574cbf41345",
|
||||
3: "675251af1f5cd5d03f645c4b4ac15db2b488f8a3c9a972e836a136450da67c52",
|
||||
4: "30d34ff976ada3fec17173556f1b3a2798e03db6bce9fc93df6bd370f9044e7c",
|
||||
5: "678da8a46273d17e5f85a12c6c3ca8d254af25ccd3c48245b11d8f80e1dba2c3",
|
||||
6: "95b0ca429b6d237b1c905e8b5756363a242ead7fc0cf53783199d3e9ac317dc8",
|
||||
7: "ae1e63c75bf6bde508d8711689db2d5a36f6619885fb3b0a28c1f26b9710143d",
|
||||
8: "6c0697b86c9a57e5dc5b1a38bf18dc1893d850a62229c95134d931b6f4a9d59a",
|
||||
9: "b600b7bd4d7f0aa72ca12f261d2019bc419500b6fde099bba7c3ccf0448f5860",
|
||||
10: "701167503d518151ee73ff2ba32f561194538cc46f630551b50b36a483c77c12",
|
||||
11: "b7203bbb965b5e5760704ff495c58a8938e97c52ac068e2b0e7c522a2874b3ed",
|
||||
12: "b746b89a15262ecedacf8725148d77be22caa57e4dfb60a209854ecdec98382b",
|
||||
13: "c51604f27347d0a1b93acad1e33af53463fceb7b6573960f881f95f29c5628ed",
|
||||
14: "531398c2b8fc83a65753fae08666946bb253ac3da6596829e71cc8ccc4ddf62f",
|
||||
}
|
||||
migrations := DefaultMigrations()
|
||||
if len(migrations) != len(expected) {
|
||||
t.Fatalf("migration count = %d, want %d", len(migrations), len(expected))
|
||||
}
|
||||
if got := migrations[0].CompatibleChecksums; len(got) != 1 || got[0] != "57b378b273f243bae44e92cad161f297248e5226e96fbd7905815aa8604146bc" {
|
||||
t.Fatalf("migration 1 compatible checksums = %v", got)
|
||||
}
|
||||
for _, item := range migrations {
|
||||
want, ok := expected[item.Version]
|
||||
if !ok {
|
||||
t.Fatalf("migration version %d is not registered", item.Version)
|
||||
}
|
||||
if got := item.Checksum(); got != want {
|
||||
t.Fatalf("migration %d (%s) checksum = %s, want %s; create a new migration instead of changing an applied migration", item.Version, item.Name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMigrationsResumePublishedVersionOne(t *testing.T) {
|
||||
migrations := DefaultMigrations()
|
||||
published := migrations[0]
|
||||
checksums := append([]string{published.Checksum()}, published.CompatibleChecksums...)
|
||||
for _, checksum := range checksums {
|
||||
checksum := checksum
|
||||
t.Run(checksum[:8], func(t *testing.T) {
|
||||
database := openSQLite(t)
|
||||
for _, statement := range published.StatementsFor(DialectSQLite) {
|
||||
if _, err := database.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ddl, err := schemaTableDDL(DialectSQLite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = database.Exec(ddl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = database.Exec(
|
||||
`INSERT INTO schema_migration(version, name, checksum, applied_at, execution_ms) VALUES(?, ?, ?, ?, ?)`,
|
||||
published.Version,
|
||||
published.Name,
|
||||
checksum,
|
||||
time.Now().UTC(),
|
||||
0,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := (Runner{DB: database, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.CurrentVersion != int64(len(migrations)) || len(report.Applied) != len(migrations)-1 {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,7 +257,7 @@ func verifyApplied(applied map[int64]Applied, migrations []Migration) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: version %d", ErrUnknownAppliedMigration, version)
|
||||
}
|
||||
if stored.Name != item.Name || stored.Checksum != item.Checksum() {
|
||||
if stored.Name != item.Name || !item.AcceptsChecksum(stored.Checksum) {
|
||||
return fmt.Errorf("%w: version %d", ErrChecksumMismatch, version)
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,6 +54,44 @@ func TestSQLiteChecksumConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteCompatibleChecksumAccepted(t *testing.T) {
|
||||
db := openSQLite(t)
|
||||
migrations := testMigrations()
|
||||
if _, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const compatibleChecksum = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
if _, err := db.Exec("UPDATE schema_migration SET checksum = ? WHERE version = 1", compatibleChecksum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migrations[0] = WithCompatibleChecksums(migrations[0], compatibleChecksum)
|
||||
report, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.CurrentVersion != 2 || len(report.Applied) != 0 {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteCompatibleChecksumStillRequiresMatchingName(t *testing.T) {
|
||||
db := openSQLite(t)
|
||||
migrations := testMigrations()
|
||||
if _, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const compatibleChecksum = "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
if _, err := db.Exec("UPDATE schema_migration SET name = 'changed', checksum = ? WHERE version = 1", compatibleChecksum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migrations[0] = WithCompatibleChecksums(migrations[0], compatibleChecksum)
|
||||
_, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background())
|
||||
if !errors.Is(err, ErrChecksumMismatch) {
|
||||
t.Fatalf("error = %v, want checksum mismatch", err)
|
||||
}
|
||||
}
|
||||
func TestSQLiteFailedMigrationRollsBack(t *testing.T) {
|
||||
db := openSQLite(t)
|
||||
runner := Runner{
|
||||
@ -142,6 +180,18 @@ func TestNormalizeRejectsInvalidDefinitions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsInvalidCompatibleChecksums(t *testing.T) {
|
||||
base := Define(1, "create_sample", "CREATE TABLE sample (id INTEGER PRIMARY KEY)")
|
||||
migrations := [][]Migration{
|
||||
{WithCompatibleChecksums(base, "invalid")},
|
||||
{WithCompatibleChecksums(base, base.Checksum())},
|
||||
}
|
||||
for index, items := range migrations {
|
||||
if _, err := normalize(items); err == nil {
|
||||
t.Fatalf("case %d accepted", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
func testMigrations() []Migration {
|
||||
return []Migration{
|
||||
Define(1, "create_sample", "CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT NOT NULL)"),
|
||||
|
||||
@ -9,39 +9,35 @@ import (
|
||||
func coreSchemaStatements() map[Dialect][]string {
|
||||
return map[Dialect][]string{
|
||||
DialectSQLite: {
|
||||
`CREATE TABLE IF NOT EXISTS "user" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "account" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL, "password" TEXT NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" TEXT NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1, "security_version" INTEGER NOT NULL DEFAULT 1, "must_change_password" INTEGER NOT NULL DEFAULT 0, "password_changed_at" DATETIME NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "account" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL, "password" TEXT NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" TEXT NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1)`,
|
||||
`CREATE TABLE IF NOT EXISTS "email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "type" INTEGER NOT NULL DEFAULT 0, "subject" TEXT NOT NULL DEFAULT '', "reply_to" TEXT NULL, "from_name" TEXT NOT NULL DEFAULT '', "from_address" TEXT NOT NULL DEFAULT '', "to" TEXT NULL, "bcc" TEXT NULL, "cc" TEXT NULL, "text" TEXT NULL, "html" TEXT NULL, "sender" TEXT NULL, "attachments" TEXT NULL, "spf_check" INTEGER NOT NULL DEFAULT 0, "dkim_check" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "cron_send_time" DATETIME NULL, "update_time" DATETIME NULL, "send_user_id" INTEGER NOT NULL DEFAULT 0, "size" INTEGER NOT NULL DEFAULT 1000, "error" TEXT NULL, "send_date" DATETIME NULL, "create_time" DATETIME NULL, "msg_id" TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE TABLE IF NOT EXISTS "group" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL DEFAULT '', "parent_id" INTEGER NOT NULL DEFAULT 0, "user_id" INTEGER NOT NULL DEFAULT 0, "full_path" TEXT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS "rule" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL DEFAULT 0, "name" TEXT NOT NULL DEFAULT '', "value" TEXT NULL, "action" INTEGER NOT NULL DEFAULT 0, "params" TEXT NOT NULL DEFAULT '', "sort" INTEGER NOT NULL DEFAULT 0)`,
|
||||
`CREATE TABLE IF NOT EXISTS "sessions" ("token" TEXT PRIMARY KEY, "data" BLOB NOT NULL, "expiry" REAL NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_sessions_expiry" ON "sessions"("expiry")`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "address" TEXT NULL, "address_canonical" TEXT NULL, "is_primary" INTEGER NOT NULL DEFAULT 0, "verified_at" DATETIME NULL, "created_at" DATETIME NULL, "updated_at" DATETIME NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_eid" ON "user_email"("user_id", "email_id")`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_user_email_address_canonical" ON "user_email"("address_canonical")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_user_email_user_primary" ON "user_email"("user_id", "is_primary")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_create_time" ON "user_email"("create")`,
|
||||
`CREATE TABLE IF NOT EXISTS "version" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "info" TEXT NOT NULL)`,
|
||||
},
|
||||
DialectMySQL: {
|
||||
"CREATE TABLE IF NOT EXISTS `user` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `account` VARCHAR(20) NOT NULL, `name` VARCHAR(10) NOT NULL, `password` VARCHAR(255) NOT NULL, `disabled` INT UNSIGNED NOT NULL DEFAULT 0, `role` VARCHAR(20) NOT NULL DEFAULT 'user', `is_admin` INT UNSIGNED NOT NULL DEFAULT 0, `session_version` INT UNSIGNED NOT NULL DEFAULT 1, `security_version` INT UNSIGNED NOT NULL DEFAULT 1, `must_change_password` INT UNSIGNED NOT NULL DEFAULT 0, `password_changed_at` DATETIME(6) NULL, UNIQUE KEY `uq_user_account` (`account`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `user` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `account` VARCHAR(20) NOT NULL, `name` VARCHAR(10) NOT NULL, `password` VARCHAR(255) NOT NULL, `disabled` INT UNSIGNED NOT NULL DEFAULT 0, `role` VARCHAR(20) NOT NULL DEFAULT 'user', `is_admin` INT UNSIGNED NOT NULL DEFAULT 0, `session_version` INT UNSIGNED NOT NULL DEFAULT 1, UNIQUE KEY `uq_user_account` (`account`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `type` TINYINT NOT NULL DEFAULT 0, `subject` VARCHAR(1000) NOT NULL DEFAULT '', `reply_to` TEXT NULL, `from_name` VARCHAR(50) NOT NULL DEFAULT '', `from_address` VARCHAR(100) NOT NULL DEFAULT '', `to` TEXT NULL, `bcc` TEXT NULL, `cc` TEXT NULL, `text` TEXT NULL, `html` MEDIUMTEXT NULL, `sender` TEXT NULL, `attachments` LONGTEXT NULL, `spf_check` TINYINT NOT NULL DEFAULT 0, `dkim_check` TINYINT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `cron_send_time` DATETIME(6) NULL, `update_time` DATETIME(6) NULL, `send_user_id` INT UNSIGNED NOT NULL DEFAULT 0, `size` INT UNSIGNED NOT NULL DEFAULT 1000, `error` TEXT NULL, `send_date` DATETIME(6) NULL, `create_time` DATETIME(6) NULL, `msg_id` VARCHAR(255) NOT NULL DEFAULT '') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `group` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(10) NOT NULL DEFAULT '', `parent_id` INT UNSIGNED NOT NULL DEFAULT 0, `user_id` INT UNSIGNED NOT NULL DEFAULT 0, `full_path` VARCHAR(600) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `rule` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL DEFAULT 0, `name` VARCHAR(255) NOT NULL DEFAULT '', `value` TEXT NULL, `action` INT NOT NULL DEFAULT 0, `params` VARCHAR(255) NOT NULL DEFAULT '', `sort` INT NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `sessions` (`token` CHAR(43) NOT NULL PRIMARY KEY, `data` BLOB NOT NULL, `expiry` TIMESTAMP(6) NOT NULL, KEY `idx_sessions_expiry` (`expiry`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `user_email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `email_id` INT NOT NULL, `address` VARCHAR(254) NULL, `address_canonical` VARCHAR(254) NULL, `is_primary` TINYINT NOT NULL DEFAULT 0, `verified_at` DATETIME(6) NULL, `created_at` DATETIME(6) NULL, `updated_at` DATETIME(6) NULL, `is_read` TINYINT NOT NULL DEFAULT 0, `group_id` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create` DATETIME(6) NULL, KEY `idx_eid` (`user_id`, `email_id`), UNIQUE KEY `uq_user_email_address_canonical` (`address_canonical`), KEY `idx_user_email_user_primary` (`user_id`, `is_primary`), KEY `idx_create_time` (`create`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `user_email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `email_id` INT NOT NULL, `is_read` TINYINT NOT NULL DEFAULT 0, `group_id` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create` DATETIME(6) NULL, KEY `idx_eid` (`user_id`, `email_id`), KEY `idx_create_time` (`create`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
"CREATE TABLE IF NOT EXISTS `version` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `info` VARCHAR(255) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
},
|
||||
DialectPostgres: {
|
||||
`CREATE TABLE IF NOT EXISTS "user" ("id" BIGSERIAL PRIMARY KEY, "account" VARCHAR(20) NOT NULL UNIQUE, "name" VARCHAR(10) NOT NULL, "password" VARCHAR(255) NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" VARCHAR(20) NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1, "security_version" INTEGER NOT NULL DEFAULT 1, "must_change_password" INTEGER NOT NULL DEFAULT 0, "password_changed_at" TIMESTAMP NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user" ("id" BIGSERIAL PRIMARY KEY, "account" VARCHAR(20) NOT NULL UNIQUE, "name" VARCHAR(10) NOT NULL, "password" VARCHAR(255) NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" VARCHAR(20) NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1)`,
|
||||
`CREATE TABLE IF NOT EXISTS "email" ("id" BIGSERIAL PRIMARY KEY, "type" SMALLINT NOT NULL DEFAULT 0, "subject" VARCHAR(1000) NOT NULL DEFAULT '', "reply_to" TEXT NULL, "from_name" VARCHAR(50) NOT NULL DEFAULT '', "from_address" VARCHAR(100) NOT NULL DEFAULT '', "to" TEXT NULL, "bcc" TEXT NULL, "cc" TEXT NULL, "text" TEXT NULL, "html" TEXT NULL, "sender" TEXT NULL, "attachments" TEXT NULL, "spf_check" SMALLINT NOT NULL DEFAULT 0, "dkim_check" SMALLINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "cron_send_time" TIMESTAMPTZ NULL, "update_time" TIMESTAMPTZ NULL, "send_user_id" BIGINT NOT NULL DEFAULT 0, "size" BIGINT NOT NULL DEFAULT 1000, "error" TEXT NULL, "send_date" TIMESTAMPTZ NULL, "create_time" TIMESTAMPTZ NULL, "msg_id" VARCHAR(255) NOT NULL DEFAULT '')`,
|
||||
`CREATE TABLE IF NOT EXISTS "group" ("id" BIGSERIAL PRIMARY KEY, "name" VARCHAR(10) NOT NULL DEFAULT '', "parent_id" BIGINT NOT NULL DEFAULT 0, "user_id" BIGINT NOT NULL DEFAULT 0, "full_path" VARCHAR(600) NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS "rule" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL DEFAULT 0, "name" VARCHAR(255) NOT NULL DEFAULT '', "value" TEXT NULL, "action" INTEGER NOT NULL DEFAULT 0, "params" VARCHAR(255) NOT NULL DEFAULT '', "sort" INTEGER NOT NULL DEFAULT 0)`,
|
||||
`CREATE TABLE IF NOT EXISTS "sessions" ("token" VARCHAR(43) PRIMARY KEY, "data" BYTEA NOT NULL, "expiry" TIMESTAMPTZ NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_sessions_expiry" ON "sessions"("expiry")`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_email" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email_id" BIGINT NOT NULL, "address" VARCHAR(254) NULL, "address_canonical" VARCHAR(254) NULL, "is_primary" SMALLINT NOT NULL DEFAULT 0, "verified_at" TIMESTAMPTZ NULL, "created_at" TIMESTAMPTZ NULL, "updated_at" TIMESTAMPTZ NULL, "is_read" SMALLINT NOT NULL DEFAULT 0, "group_id" BIGINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "create" TIMESTAMPTZ NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_email" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email_id" BIGINT NOT NULL, "is_read" SMALLINT NOT NULL DEFAULT 0, "group_id" BIGINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "create" TIMESTAMPTZ NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_eid" ON "user_email"("user_id", "email_id")`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_user_email_address_canonical" ON "user_email"("address_canonical")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_user_email_user_primary" ON "user_email"("user_id", "is_primary")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_create_time" ON "user_email"("create")`,
|
||||
`CREATE TABLE IF NOT EXISTS "version" ("id" BIGSERIAL PRIMARY KEY, "info" VARCHAR(255) NOT NULL)`,
|
||||
},
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
"git.pcux.cn/xuans/Smail/db/migration"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
"git.pcux.cn/xuans/Smail/utils/array"
|
||||
@ -58,6 +59,13 @@ func databaseError(message string, cause error) error {
|
||||
return &databaseSetupError{Message: message, Cause: cause}
|
||||
}
|
||||
|
||||
func databaseInitializationError(err error) error {
|
||||
if stderrors.Is(err, migration.ErrChecksumMismatch) || stderrors.Is(err, migration.ErrUnknownAppliedMigration) {
|
||||
return databaseError("数据库连接成功,但检测到历史迁移记录与当前版本不兼容,请先备份数据库后再执行兼容升级", err)
|
||||
}
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
|
||||
func DatabaseSetupErrorMessage(err error) string {
|
||||
var setupErr *databaseSetupError
|
||||
if errors.As(err, &setupErr) {
|
||||
@ -265,7 +273,7 @@ func SetDatabaseSettingsWithOptions(ctx *context.Context, settings DatabaseSetti
|
||||
closeDatabaseInstance()
|
||||
if err = db.Init(""); err != nil {
|
||||
restoreDatabaseConfig(&previousConfig)
|
||||
return errors.Wrap(err)
|
||||
return databaseInitializationError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/db/migration"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
@ -369,3 +370,27 @@ func TestPrepareSQLiteDatabaseCreatesParentDirectory(t *testing.T) {
|
||||
t.Fatalf("SQLite parent directory was not prepared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseInitializationErrorReturnsMigrationGuidance(t *testing.T) {
|
||||
for _, cause := range []error{migration.ErrChecksumMismatch, migration.ErrUnknownAppliedMigration} {
|
||||
err := databaseInitializationError(cause)
|
||||
message := DatabaseSetupErrorMessage(err)
|
||||
if message != "数据库连接成功,但检测到历史迁移记录与当前版本不兼容,请先备份数据库后再执行兼容升级" {
|
||||
t.Fatalf("message = %q", message)
|
||||
}
|
||||
if !stderrors.Is(err, cause) {
|
||||
t.Fatalf("error does not preserve cause %v", cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseInitializationErrorKeepsGenericErrorsInternal(t *testing.T) {
|
||||
cause := stderrors.New("internal database detail")
|
||||
err := databaseInitializationError(cause)
|
||||
if message := DatabaseSetupErrorMessage(err); message != "" {
|
||||
t.Fatalf("unexpected user-facing message = %q", message)
|
||||
}
|
||||
if !stderrors.Is(err, cause) {
|
||||
t.Fatal("generic initialization error does not preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user