MySQL 8: Improve migrate command, ignore errors when dropping indexes

This commit is contained in:
Michael Mayer 2021-11-28 13:52:27 +01:00
parent 86c43159eb
commit 7e8974fd20
19 changed files with 369 additions and 145 deletions

View file

@ -38,6 +38,7 @@ services:
PHOTOPRISM_DATABASE_PASSWORD: "photoprism" PHOTOPRISM_DATABASE_PASSWORD: "photoprism"
PHOTOPRISM_TEST_DRIVER: "sqlite" PHOTOPRISM_TEST_DRIVER: "sqlite"
PHOTOPRISM_TEST_DSN: ".test.db" PHOTOPRISM_TEST_DSN: ".test.db"
PHOTOPRISM_TEST_DSN_MYSQL8: "root:photoprism@tcp(mysql:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true"
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters) PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters)
PHOTOPRISM_ASSETS_PATH: "/go/src/github.com/photoprism/photoprism/assets" PHOTOPRISM_ASSETS_PATH: "/go/src/github.com/photoprism/photoprism/assets"
PHOTOPRISM_STORAGE_PATH: "/go/src/github.com/photoprism/photoprism/storage" PHOTOPRISM_STORAGE_PATH: "/go/src/github.com/photoprism/photoprism/storage"
@ -157,6 +158,21 @@ services:
MYSQL_PASSWORD: photoprism MYSQL_PASSWORD: photoprism
MYSQL_DATABASE: photoprism MYSQL_DATABASE: photoprism
## MySQL Database Server
## Docs: https://dev.mysql.com/doc/refman/8.0/en/
mysql:
image: mysql:8
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=120
expose:
- "4001" # Database port (internal)
volumes:
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
environment:
MYSQL_ROOT_PASSWORD: photoprism
MYSQL_USER: photoprism
MYSQL_PASSWORD: photoprism
MYSQL_DATABASE: photoprism
## Dummy WebDAV Server ## Dummy WebDAV Server
dummy-webdav: dummy-webdav:
image: photoprism/dummy-webdav:20211109 image: photoprism/dummy-webdav:20211109

View file

@ -57,19 +57,6 @@ services:
MYSQL_PASSWORD: photoprism MYSQL_PASSWORD: photoprism
MYSQL_DATABASE: photoprism MYSQL_DATABASE: photoprism
mysql-8:
image: mysql:8
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=120
expose:
- "4001" # Database port (internal)
volumes:
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
environment:
MYSQL_ROOT_PASSWORD: photoprism
MYSQL_USER: photoprism
MYSQL_PASSWORD: photoprism
MYSQL_DATABASE: photoprism
networks: networks:
default: default:
external: external:

View file

@ -22,6 +22,7 @@ services:
- "2343:2343" # Acceptance Test HTTP port (host:container) - "2343:2343" # Acceptance Test HTTP port (host:container)
shm_size: "2gb" shm_size: "2gb"
environment: environment:
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters)
PHOTOPRISM_UID: ${UID:-1000} PHOTOPRISM_UID: ${UID:-1000}
PHOTOPRISM_GID: ${GID:-1000} PHOTOPRISM_GID: ${GID:-1000}
PHOTOPRISM_SITE_URL: "http://localhost:2342/" PHOTOPRISM_SITE_URL: "http://localhost:2342/"
@ -44,7 +45,7 @@ services:
PHOTOPRISM_DATABASE_PASSWORD: "photoprism" PHOTOPRISM_DATABASE_PASSWORD: "photoprism"
PHOTOPRISM_TEST_DRIVER: "sqlite" PHOTOPRISM_TEST_DRIVER: "sqlite"
PHOTOPRISM_TEST_DSN: ".test.db" PHOTOPRISM_TEST_DSN: ".test.db"
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters) PHOTOPRISM_TEST_DSN_MYSQL8: "root:photoprism@tcp(mysql:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true"
PHOTOPRISM_ASSETS_PATH: "/go/src/github.com/photoprism/photoprism/assets" PHOTOPRISM_ASSETS_PATH: "/go/src/github.com/photoprism/photoprism/assets"
PHOTOPRISM_STORAGE_PATH: "/go/src/github.com/photoprism/photoprism/storage" PHOTOPRISM_STORAGE_PATH: "/go/src/github.com/photoprism/photoprism/storage"
PHOTOPRISM_ORIGINALS_PATH: "/go/src/github.com/photoprism/photoprism/storage/originals" PHOTOPRISM_ORIGINALS_PATH: "/go/src/github.com/photoprism/photoprism/storage/originals"
@ -103,6 +104,21 @@ services:
MYSQL_PASSWORD: photoprism MYSQL_PASSWORD: photoprism
MYSQL_DATABASE: photoprism MYSQL_DATABASE: photoprism
## MySQL Database Server
## Docs: https://dev.mysql.com/doc/refman/8.0/en/
mysql:
image: mysql:8
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=120
expose:
- "4001" # Database port (internal)
volumes:
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
environment:
MYSQL_ROOT_PASSWORD: photoprism
MYSQL_USER: photoprism
MYSQL_PASSWORD: photoprism
MYSQL_DATABASE: photoprism
## Dummy WebDAV Server ## Dummy WebDAV Server
dummy-webdav: dummy-webdav:
image: photoprism/dummy-webdav:20211109 image: photoprism/dummy-webdav:20211109

View file

@ -4,15 +4,26 @@ import (
"context" "context"
"time" "time"
"github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
) )
// MigrateCommand registers the migrate cli command. // MigrateCommand registers the "migrate" CLI command.
var MigrateCommand = cli.Command{ var MigrateCommand = cli.Command{
Name: "migrate", Name: "migrate",
Usage: "Updates the index database schema", Usage: "Updates the index database schema",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "failed, f",
Usage: "run previously failed migrations",
},
cli.BoolFlag{
Name: "trace, t",
Usage: "show trace logs for debugging",
},
},
Action: migrateAction, Action: migrateAction,
} }
@ -29,15 +40,26 @@ func migrateAction(ctx *cli.Context) error {
return err return err
} }
defer conf.Shutdown()
if ctx.Bool("trace") {
log.SetLevel(logrus.TraceLevel)
log.Infoln("migrate: enabled trace mode")
}
runFailed := ctx.Bool("failed")
if runFailed {
log.Infoln("migrate: running previously failed migrations")
}
log.Infoln("migrating database schema...") log.Infoln("migrating database schema...")
conf.InitDb() conf.MigrateDb(runFailed)
elapsed := time.Since(start) elapsed := time.Since(start)
log.Infof("migration completed in %s", elapsed) log.Infof("migration completed in %s", elapsed)
conf.Shutdown()
return nil return nil
} }

View file

@ -16,11 +16,18 @@ import (
// PlacesCommand registers the places subcommands. // PlacesCommand registers the places subcommands.
var PlacesCommand = cli.Command{ var PlacesCommand = cli.Command{
Name: "places", Name: "places",
Usage: "Geographic data subcommands", Usage: "Maps and location details subcommands",
Subcommands: []cli.Command{ Subcommands: []cli.Command{
{ {
Name: "update", Name: "update",
Usage: "Retrieves updated location details", Usage: "Retrieves updated location details",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "yes, y",
Hidden: true,
Usage: "assume \"yes\" as answer to all prompts and run non-interactively",
},
},
Action: placesUpdateAction, Action: placesUpdateAction,
}, },
}, },
@ -46,6 +53,7 @@ func placesUpdateAction(ctx *cli.Context) error {
return nil return nil
} }
if !ctx.Bool("yes") {
confirmPrompt := promptui.Prompt{ confirmPrompt := promptui.Prompt{
Label: "Interrupting the update may result in inconsistent location details. Proceed?", Label: "Interrupting the update may result in inconsistent location details. Proceed?",
IsConfirm: true, IsConfirm: true,
@ -55,6 +63,7 @@ func placesUpdateAction(ctx *cli.Context) error {
if _, err := confirmPrompt.Run(); err != nil { if _, err := confirmPrompt.Run(); err != nil {
return nil return nil
} }
}
start := time.Now() start := time.Now()

View file

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/manifoldco/promptui" "github.com/manifoldco/promptui"
"github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
@ -19,18 +20,25 @@ import (
var ResetCommand = cli.Command{ var ResetCommand = cli.Command{
Name: "reset", Name: "reset",
Usage: "Resets the index and removes generated sidecar files", Usage: "Resets the index and removes generated sidecar files",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "index, i",
Usage: "reset index database only",
},
cli.BoolFlag{
Name: "trace, t",
Usage: "show trace logs for debugging",
},
cli.BoolFlag{
Name: "yes, y",
Usage: "assume \"yes\" as answer to all prompts and run non-interactively",
},
},
Action: resetAction, Action: resetAction,
} }
// resetAction resets the index and removes sidecar files after confirmation. // resetAction resets the index and removes sidecar files after confirmation.
func resetAction(ctx *cli.Context) error { func resetAction(ctx *cli.Context) error {
log.Warnf("YOU ARE ABOUT TO RESET THE INDEX AND REMOVE ALL JSON / YAML SIDECAR FILES")
removeIndexPrompt := promptui.Prompt{
Label: "Reset index database including albums and metadata?",
IsConfirm: true,
}
conf := config.NewConfig(ctx) conf := config.NewConfig(ctx)
_, cancel := context.WithCancel(context.Background()) _, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -39,101 +47,73 @@ func resetAction(ctx *cli.Context) error {
return err return err
} }
defer conf.Shutdown()
entity.SetDbProvider(conf) entity.SetDbProvider(conf)
if _, err := removeIndexPrompt.Run(); err == nil { if !ctx.Bool("yes") {
start := time.Now() log.Warnf("This will delete and recreate your index database after confirmation")
tables := entity.Entities if !ctx.Bool("index") {
log.Warnf("You will be asked next if you also want to remove all JSON and YAML backup files")
log.Infoln("dropping existing tables") }
tables.Drop()
log.Infoln("restoring default schema")
entity.MigrateDb(true)
if conf.AdminPassword() != "" {
log.Infoln("restoring initial admin password")
entity.Admin.InitPassword(conf.AdminPassword())
} }
log.Infof("database reset completed in %s", time.Since(start)) if ctx.Bool("trace") {
log.SetLevel(logrus.TraceLevel)
log.Infoln("reset: enabled trace mode")
}
resetIndex := ctx.Bool("yes")
// Show prompt?
if !resetIndex {
removeIndexPrompt := promptui.Prompt{
Label: "Delete and recreate index database?",
IsConfirm: true,
}
if _, err := removeIndexPrompt.Run(); err == nil {
resetIndex = true
} else { } else {
log.Infof("keeping index database") log.Infof("keeping index database")
} }
}
// Reset index?
if resetIndex {
resetIndexDb(conf)
}
// Reset index only?
if ctx.Bool("index") {
return nil
}
removeSidecarJsonPrompt := promptui.Prompt{ removeSidecarJsonPrompt := promptui.Prompt{
Label: "Permanently delete existing JSON metadata sidecar files?", Label: "Delete all JSON metadata sidecar files?",
IsConfirm: true, IsConfirm: true,
} }
if _, err := removeSidecarJsonPrompt.Run(); err == nil { if _, err := removeSidecarJsonPrompt.Run(); err == nil {
start := time.Now() resetSidecarJson(conf)
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.json")
if err != nil {
return err
}
if len(matches) > 0 {
log.Infof("removing %d JSON metadata sidecar files", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
fmt.Print("E")
} else {
fmt.Print(".")
}
}
fmt.Println("")
log.Infof("removed JSON metadata sidecar files [%s]", time.Since(start))
} else {
log.Infof("found no JSON metadata sidecar files")
}
} else { } else {
log.Infof("keeping JSON metadata sidecar files") log.Infof("keeping JSON metadata sidecar files")
} }
removeSidecarYamlPrompt := promptui.Prompt{ removeSidecarYamlPrompt := promptui.Prompt{
Label: "Permanently delete existing YAML metadata backups?", Label: "Delete all YAML metadata backup files?",
IsConfirm: true, IsConfirm: true,
} }
if _, err := removeSidecarYamlPrompt.Run(); err == nil { if _, err := removeSidecarYamlPrompt.Run(); err == nil {
start := time.Now() resetSidecarYaml(conf)
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.yml")
if err != nil {
return err
}
if len(matches) > 0 {
log.Infof("%d YAML metadata backups will be removed", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
fmt.Print("E")
} else {
fmt.Print(".")
}
}
fmt.Println("")
log.Infof("removed all YAML metadata backups [%s]", time.Since(start))
} else {
log.Infof("found no YAML metadata backups")
}
} else { } else {
log.Infof("keeping YAML metadata backups") log.Infof("keeping YAML metadata backups")
} }
removeAlbumYamlPrompt := promptui.Prompt{ removeAlbumYamlPrompt := promptui.Prompt{
Label: "Permanently delete existing YAML album backups?", Label: "Delete all YAML album backup files?",
IsConfirm: true, IsConfirm: true,
} }
@ -167,7 +147,85 @@ func resetAction(ctx *cli.Context) error {
log.Infof("keeping YAML album backup files") log.Infof("keeping YAML album backup files")
} }
conf.Shutdown()
return nil return nil
} }
// resetIndexDb resets the index database schema.
func resetIndexDb(conf *config.Config) {
start := time.Now()
tables := entity.Entities
log.Infoln("dropping existing tables")
tables.Drop(conf.Db())
log.Infoln("restoring default schema")
entity.MigrateDb(true, false)
if conf.AdminPassword() != "" {
log.Infoln("restoring initial admin password")
entity.Admin.InitPassword(conf.AdminPassword())
}
log.Infof("database reset completed in %s", time.Since(start))
}
// resetSidecarJson removes generated JSON sidecar files.
func resetSidecarJson(conf *config.Config) {
start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.json")
if err != nil {
log.Errorf("reset: %s (find json sidecar files)", err)
return
}
if len(matches) > 0 {
log.Infof("removing %d JSON metadata sidecar files", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
fmt.Print("E")
} else {
fmt.Print(".")
}
}
fmt.Println("")
log.Infof("removed JSON metadata sidecar files [%s]", time.Since(start))
} else {
log.Infof("found no JSON metadata sidecar files")
}
}
// resetSidecarYaml removes generated YAML sidecar files.
func resetSidecarYaml(conf *config.Config) {
start := time.Now()
matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.yml")
if err != nil {
log.Errorf("reset: %s (find yaml sidecar files)", err)
return
}
if len(matches) > 0 {
log.Infof("%d YAML metadata backups will be removed", len(matches))
for _, name := range matches {
if err := os.Remove(name); err != nil {
fmt.Print("E")
} else {
fmt.Print(".")
}
}
fmt.Println("")
log.Infof("removed all YAML metadata backups [%s]", time.Since(start))
} else {
log.Infof("found no YAML metadata backups")
}
}

View file

@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/dustin/go-humanize/english" "github.com/dustin/go-humanize/english"
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
@ -151,7 +150,7 @@ func restoreAction(ctx *cli.Context) error {
) )
case config.SQLite: case config.SQLite:
log.Infoln("dropping existing tables") log.Infoln("dropping existing tables")
tables.Drop() tables.Drop(conf.Db())
cmd = exec.Command( cmd = exec.Command(
conf.SqliteBin(), conf.SqliteBin(),
conf.DatabaseDsn(), conf.DatabaseDsn(),

View file

@ -232,11 +232,16 @@ func (c *Config) SetDbOptions() {
} }
} }
// InitDb will initialize the database connection and schema. // InitDb initializes the database without running previously failed migrations.
func (c *Config) InitDb() { func (c *Config) InitDb() {
c.MigrateDb(false)
}
// MigrateDb initializes the database and migrates the schema if needed.
func (c *Config) MigrateDb(runFailed bool) {
c.SetDbOptions() c.SetDbOptions()
entity.SetDbProvider(c) entity.SetDbProvider(c)
entity.MigrateDb(false) entity.MigrateDb(true, runFailed)
entity.Admin.InitPassword(c.AdminPassword()) entity.Admin.InitPassword(c.AdminPassword())

View file

@ -13,9 +13,9 @@ func CreateDefaultFixtures() {
// ResetTestFixtures re-creates registered database tables and inserts test fixtures. // ResetTestFixtures re-creates registered database tables and inserts test fixtures.
func ResetTestFixtures() { func ResetTestFixtures() {
Entities.Migrate() Entities.Migrate(Db(), false)
Entities.WaitForMigration() Entities.WaitForMigration(Db())
Entities.Truncate() Entities.Truncate(Db())
CreateDefaultFixtures() CreateDefaultFixtures()

View file

@ -1,13 +1,13 @@
package entity package entity
// MigrateDb creates database tables and inserts default fixtures as needed. // MigrateDb creates database tables and inserts default fixtures as needed.
func MigrateDb(dropDeprecated bool) { func MigrateDb(dropDeprecated, runFailed bool) {
if dropDeprecated { if dropDeprecated {
DeprecatedTables.Drop() DeprecatedTables.Drop(Db())
} }
Entities.Migrate() Entities.Migrate(Db(), runFailed)
Entities.WaitForMigration() Entities.WaitForMigration(Db())
CreateDefaultFixtures() CreateDefaultFixtures()
} }

View file

@ -0,0 +1,51 @@
package entity
import (
"os"
"testing"
"time"
"github.com/jinzhu/gorm"
)
func TestMySQL8(t *testing.T) {
dbDriver := MySQL
dbDsn := os.Getenv("PHOTOPRISM_TEST_DSN_MYSQL8")
db, err := gorm.Open(dbDriver, dbDsn)
if err != nil || db == nil {
for i := 1; i <= 5; i++ {
db, err = gorm.Open(dbDriver, dbDsn)
if db != nil && err == nil {
break
}
time.Sleep(5 * time.Second)
}
if err != nil || db == nil {
t.Fatal(err)
}
}
defer db.Close()
db.LogMode(false)
DeprecatedTables.Drop(db)
Entities.Drop(db)
// First migration.
Entities.Migrate(db, false)
Entities.WaitForMigration(db)
// Second migration.
Entities.Migrate(db, false)
Entities.WaitForMigration(db)
// Third migration with force flag.
Entities.Migrate(db, true)
Entities.WaitForMigration(db)
}

View file

@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/migrate" "github.com/photoprism/photoprism/internal/migrate"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
@ -44,7 +46,7 @@ var Entities = Tables{
} }
// WaitForMigration waits for the database migration to be successful. // WaitForMigration waits for the database migration to be successful.
func (list Tables) WaitForMigration() { func (list Tables) WaitForMigration(db *gorm.DB) {
type RowCount struct { type RowCount struct {
Count int Count int
} }
@ -53,7 +55,7 @@ func (list Tables) WaitForMigration() {
for name := range list { for name := range list {
for i := 0; i <= attempts; i++ { for i := 0; i <= attempts; i++ {
count := RowCount{} count := RowCount{}
if err := Db().Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil { if err := db.Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil {
log.Tracef("entity: %s migrated", txt.Quote(name)) log.Tracef("entity: %s migrated", txt.Quote(name))
break break
} else { } else {
@ -70,9 +72,9 @@ func (list Tables) WaitForMigration() {
} }
// Truncate removes all data from tables without dropping them. // Truncate removes all data from tables without dropping them.
func (list Tables) Truncate() { func (list Tables) Truncate(db *gorm.DB) {
for name := range list { for name := range list {
if err := Db().Exec(fmt.Sprintf("DELETE FROM %s WHERE 1", name)).Error; err == nil { if err := db.Exec(fmt.Sprintf("DELETE FROM %s WHERE 1", name)).Error; err == nil {
// log.Debugf("entity: removed all data from %s", name) // log.Debugf("entity: removed all data from %s", name)
break break
} else if err.Error() != "record not found" { } else if err.Error() != "record not found" {
@ -82,29 +84,29 @@ func (list Tables) Truncate() {
} }
// Migrate migrates all database tables of registered entities. // Migrate migrates all database tables of registered entities.
func (list Tables) Migrate() { func (list Tables) Migrate(db *gorm.DB, runFailed bool) {
for name, entity := range list { for name, entity := range list {
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil { if err := db.AutoMigrate(entity).Error; err != nil {
log.Debugf("entity: %s (waiting 1s)", err.Error()) log.Debugf("entity: %s (waiting 1s)", err.Error())
time.Sleep(time.Second) time.Sleep(time.Second)
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil { if err := db.AutoMigrate(entity).Error; err != nil {
log.Errorf("entity: failed migrating %s", txt.Quote(name)) log.Errorf("entity: failed migrating %s", txt.Quote(name))
panic(err) panic(err)
} }
} }
} }
if err := migrate.Auto(Db()); err != nil { if err := migrate.Auto(db, runFailed); err != nil {
log.Error(err) log.Error(err)
} }
} }
// Drop drops all database tables of registered entities. // Drop drops all database tables of registered entities.
func (list Tables) Drop() { func (list Tables) Drop(db *gorm.DB) {
for _, entity := range list { for _, entity := range list {
if err := UnscopedDb().DropTableIfExists(entity).Error; err != nil { if err := db.DropTableIfExists(entity).Error; err != nil {
panic(err) panic(err)
} }
} }

View file

@ -1,12 +1,14 @@
package entity package entity
import "github.com/jinzhu/gorm"
// Deprecated represents a list of deprecated database tables. // Deprecated represents a list of deprecated database tables.
type Deprecated []string type Deprecated []string
// Drop drops all deprecated tables. // Drop drops all deprecated tables.
func (list Deprecated) Drop() { func (list Deprecated) Drop(db *gorm.DB) {
for _, tableName := range list { for _, tableName := range list {
if err := UnscopedDb().DropTableIfExists(tableName).Error; err != nil { if err := db.DropTableIfExists(tableName).Error; err != nil {
log.Debugf("drop %s: %s", tableName, err) log.Debugf("drop %s: %s", tableName, err)
} }
} }

View file

@ -7,7 +7,7 @@ import (
) )
// Auto automatically migrates the database provided. // Auto automatically migrates the database provided.
func Auto(db *gorm.DB) error { func Auto(db *gorm.DB, runFailed bool) error {
if db == nil { if db == nil {
return fmt.Errorf("migrate: database connection required") return fmt.Errorf("migrate: database connection required")
} }
@ -23,7 +23,7 @@ func Auto(db *gorm.DB) error {
} }
if migrations, ok := Dialects[name]; ok && len(migrations) > 0 { if migrations, ok := Dialects[name]; ok && len(migrations) > 0 {
migrations.Start(db) migrations.Start(db, runFailed)
return nil return nil
} else { } else {
return fmt.Errorf("migrate: no migrations found for %s", name) return fmt.Errorf("migrate: no migrations found for %s", name)

View file

@ -5,11 +5,11 @@ var DialectMySQL = Migrations{
{ {
ID: "20211121-094727", ID: "20211121-094727",
Dialect: "mysql", Dialect: "mysql",
Statements: []string{"DROP INDEX IF EXISTS uix_places_place_label ON `places`;"}, Statements: []string{"DROP INDEX uix_places_place_label ON `places`;"},
}, },
{ {
ID: "20211124-120008", ID: "20211124-120008",
Dialect: "mysql", Dialect: "mysql",
Statements: []string{"DROP INDEX IF EXISTS idx_places_place_label ON `places`;", "DROP INDEX IF EXISTS uix_places_label ON `places`;"}, Statements: []string{"DROP INDEX idx_places_place_label ON `places`;", "DROP INDEX uix_places_label ON `places`;"},
}, },
} }

View file

@ -1,6 +1,7 @@
package migrate package migrate
import ( import (
"strings"
"time" "time"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
@ -29,6 +30,7 @@ func (m *Migration) Fail(err error, db *gorm.DB) {
} }
m.Error = err.Error() m.Error = err.Error()
db.Model(m).Updates(Values{"Error": m.Error}) db.Model(m).Updates(Values{"Error": m.Error})
} }
@ -41,9 +43,13 @@ func (m *Migration) Finish(db *gorm.DB) error {
func (m *Migration) Execute(db *gorm.DB) error { func (m *Migration) Execute(db *gorm.DB) error {
for _, s := range m.Statements { for _, s := range m.Statements {
if err := db.Exec(s).Error; err != nil { if err := db.Exec(s).Error; err != nil {
if strings.HasPrefix(s, "DROP ") && strings.Contains(err.Error(), "DROP") {
log.Tracef("migrate: %s (drop statement)", err)
} else {
return err return err
} }
} }
}
return nil return nil
} }

View file

@ -1,33 +1,84 @@
package migrate package migrate
import ( import (
"database/sql"
"time" "time"
"github.com/dustin/go-humanize/english"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
) )
// Migrations represents a sorted list of migrations. // Migrations represents a sorted list of migrations.
type Migrations []Migration type Migrations []Migration
// MigrationMap represents a map of migrations.
type MigrationMap map[string]Migration
// Existing finds and returns previously executed database schema migrations.
func Existing(db *gorm.DB) MigrationMap {
result := make(MigrationMap)
dialect := db.Dialect().GetName()
stmt := db.Model(Migration{}).Where("dialect = ?", dialect)
stmt = stmt.Select("id, dialect, error, source, started_at, finished_at")
rows, err := stmt.Rows()
if err != nil {
log.Warnf("migrate: %s (find existing)", err)
return result
}
defer func(rows *sql.Rows) {
err = rows.Close()
}(rows)
for rows.Next() {
m := Migration{}
if err = rows.Scan(&m.ID, &m.Dialect, &m.Error, &m.Source, &m.StartedAt, &m.FinishedAt); err != nil {
log.Warnf("migrate: %s (scan existing)", err)
return result
}
result[m.ID] = m
}
return result
}
// Start runs all migrations that haven't been executed yet. // Start runs all migrations that haven't been executed yet.
func (m *Migrations) Start(db *gorm.DB) { func (m *Migrations) Start(db *gorm.DB, runFailed bool) {
// Find previously executed migrations.
executed := Existing(db)
log.Debugf("migrate: found %s", english.Plural(len(executed), "previous migration", "previous migrations"))
for _, migration := range *m { for _, migration := range *m {
start := time.Now() start := time.Now()
migration.StartedAt = start.UTC().Round(time.Second) migration.StartedAt = start.UTC().Round(time.Second)
// Continue if already executed. // Already executed?
if err := db.Create(migration).Error; err != nil { if done, ok := executed[migration.ID]; ok {
// Try to run failed migrations again?
if !runFailed || done.Error == "" {
log.Debugf("migrate: %s skipped", migration.ID)
continue
}
} else if err := db.Create(migration).Error; err != nil {
// Should not happen.
log.Warnf("migrate: creating %s failed with %s [%s]", migration.ID, err, time.Since(start))
continue continue
} }
// Run migration.
if err := migration.Execute(db); err != nil { if err := migration.Execute(db); err != nil {
migration.Fail(err, db) migration.Fail(err, db)
log.Errorf("migration %s failed: %s [%s]", migration.ID, err, time.Since(start)) log.Errorf("migrate: executing %s failed with %s [%s]", migration.ID, err, time.Since(start))
} else if err = migration.Finish(db); err != nil { } else if err = migration.Finish(db); err != nil {
log.Warnf("migration %s failed: %s [%s]", migration.ID, err, time.Since(start)) log.Warnf("migrate: updating %s failed with %s [%s]", migration.ID, err, time.Since(start))
} else { } else {
log.Infof("migration %s successful [%s]", migration.ID, time.Since(start)) log.Infof("migrate: %s successful [%s]", migration.ID, time.Since(start))
} }
} }
} }

View file

@ -1 +1 @@
DROP INDEX IF EXISTS uix_places_place_label ON `places`; DROP INDEX uix_places_place_label ON `places`;

View file

@ -1,2 +1,2 @@
DROP INDEX IF EXISTS idx_places_place_label ON `places`; DROP INDEX idx_places_place_label ON `places`;
DROP INDEX IF EXISTS uix_places_label ON `places`; DROP INDEX uix_places_label ON `places`;