57a849d made lagoon.Status read migrationSets, which changed its result
for consumers: the framework sets came first and a plugin with no
migrations lost its row. Status is back to its pre-57a849d behaviour.
migrate:status (table and --json) now reads the unexported setStatus,
which lists the framework sets and the plugins from migrationSets, so it
still agrees with migrate --pretend. Both share historyRows.
- TestStatusPluginRows pins Status: plugin rows only, an empty row for a
plugin with no migrations or no history table, none without HasMigrations
- TestMigrateStatusJSON also checks the table lists the framework sets
- TestTwoPluginMigrationSetsIsolated and TestDocsMigrate are back to the
plugin-only rows
279 lines
7.9 KiB
Go
279 lines
7.9 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"github.com/go-gormigrate/gormigrate/v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const historyTablePrefix = "summer_migrations_"
|
|
|
|
var (
|
|
// ErrUnknownPlugin is returned when migrate:rollback names a plugin that is not activated.
|
|
ErrUnknownPlugin = errors.New("lagoon: plugin is not activated")
|
|
// ErrNoMigrations is returned when there is no plugin migration set to roll back.
|
|
ErrNoMigrations = errors.New("lagoon: no plugin migrations to roll back")
|
|
)
|
|
|
|
// HistoryTableName returns the isolated gormigrate table for pluginID.
|
|
func HistoryTableName(pluginID string) (string, error) {
|
|
if err := validatePluginID(pluginID); err != nil {
|
|
return "", err
|
|
}
|
|
return historyTablePrefix + strings.ReplaceAll(pluginID, ".", "_"), nil
|
|
}
|
|
|
|
func validatePluginID(id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("lagoon: plugin id is empty")
|
|
}
|
|
for _, r := range id {
|
|
if r == '.' || unicode.IsLower(r) || unicode.IsDigit(r) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("lagoon: plugin id %q is not a valid history table name", id)
|
|
}
|
|
if strings.Contains(id, "..") || strings.HasPrefix(id, ".") || strings.HasSuffix(id, ".") {
|
|
return fmt.Errorf("lagoon: plugin id %q is not a valid history table name", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration) (*gormigrate.Gormigrate, error) {
|
|
table, err := HistoryTableName(pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gormigrate.New(gdb, &gormigrate.Options{
|
|
TableName: table,
|
|
UseTransaction: true,
|
|
}, migrations), nil
|
|
}
|
|
|
|
// Migrate runs the framework-owned system_files, backend-admin,
|
|
// deferred-binding (DeferredBindingMigrations, history DeferredHistoryID) and
|
|
// job-queue (QueueMigrations) sets first, then each plugin's HasMigrations set
|
|
// in party.Activate order. It applies the sets migrationSets lists, the list
|
|
// PendingMigrations reads, and stops at the first set that fails.
|
|
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
|
|
if gdb == nil {
|
|
return fmt.Errorf("lagoon: gorm db is nil")
|
|
}
|
|
sets, err := migrationSets(gdb, plugins)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, s := range sets {
|
|
m, err := migrator(gdb, s.id, s.migrations)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.Migrate(); err != nil {
|
|
return fmt.Errorf("lagoon: migrate %s: %w", s.label, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RollbackLast rolls back the last migration of pluginID.
|
|
func RollbackLast(gdb *gorm.DB, plugins []party.Plugin, pluginID string) error {
|
|
if gdb == nil {
|
|
return fmt.Errorf("lagoon: gorm db is nil")
|
|
}
|
|
pluginID = strings.TrimSpace(pluginID)
|
|
if pluginID == "" {
|
|
pluginID = lastMigrationPlugin(plugins)
|
|
}
|
|
if pluginID == "" {
|
|
return ErrNoMigrations
|
|
}
|
|
var target party.Plugin
|
|
for _, p := range plugins {
|
|
if p.ID() == pluginID {
|
|
target = p
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
return fmt.Errorf("%w: %q", ErrUnknownPlugin, pluginID)
|
|
}
|
|
hm, ok := target.(pact.HasMigrations)
|
|
if !ok || len(hm.Migrations()) == 0 {
|
|
return fmt.Errorf("%w: %q", ErrNoMigrations, pluginID)
|
|
}
|
|
m, err := migrator(gdb, target.ID(), hm.Migrations())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.RollbackLast(); err != nil {
|
|
return fmt.Errorf("lagoon: rollback %s: %w", pluginID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lastMigrationPlugin(plugins []party.Plugin) string {
|
|
for i := len(plugins) - 1; i >= 0; i-- {
|
|
p := plugins[i]
|
|
hm, ok := p.(pact.HasMigrations)
|
|
if !ok || len(hm.Migrations()) == 0 {
|
|
continue
|
|
}
|
|
return p.ID()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// StatusRow is one plugin's recorded migration ids.
|
|
type StatusRow struct {
|
|
Plugin string
|
|
Table string
|
|
IDs []string
|
|
}
|
|
|
|
// Status reads each plugin history table: one row per plugin that
|
|
// implements pact.HasMigrations, in plugin order, a plugin with no
|
|
// migrations included. The framework's own sets are not listed.
|
|
func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
|
|
if gdb == nil {
|
|
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
|
}
|
|
var ids []string
|
|
for _, p := range plugins {
|
|
if _, ok := p.(pact.HasMigrations); ok {
|
|
ids = append(ids, p.ID())
|
|
}
|
|
}
|
|
return historyRows(gdb, ids)
|
|
}
|
|
|
|
// setStatus reads the history table of every set Migrate applies, in its
|
|
// order (migrationSets): the framework's own sets first, then each plugin
|
|
// with at least one migration. migrate:status prints it, so that it agrees
|
|
// with migrate --pretend.
|
|
func setStatus(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
|
|
if gdb == nil {
|
|
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
|
}
|
|
sets, err := migrationSets(gdb, plugins)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]string, 0, len(sets))
|
|
for _, s := range sets {
|
|
ids = append(ids, s.id)
|
|
}
|
|
return historyRows(gdb, ids)
|
|
}
|
|
|
|
// historyRows reads the applied IDs of each history ID's table, sorted. A
|
|
// missing history table gives a row without IDs (nil), an empty one a row
|
|
// with an empty slice.
|
|
func historyRows(gdb *gorm.DB, ids []string) ([]StatusRow, error) {
|
|
var rows []StatusRow
|
|
for _, id := range ids {
|
|
table, err := HistoryTableName(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row := StatusRow{Plugin: id, Table: table}
|
|
if !gdb.Migrator().HasTable(table) {
|
|
rows = append(rows, row)
|
|
continue
|
|
}
|
|
if err := gdb.Table(table).Order("id").Pluck("id", &row.IDs).Error; err != nil {
|
|
return nil, fmt.Errorf("lagoon: status %s: %w", id, err)
|
|
}
|
|
if row.IDs == nil {
|
|
row.IDs = []string{}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// MigrationRef names one migration: the plugin (or framework set) that
|
|
// declares it and its ID.
|
|
type MigrationRef struct {
|
|
Plugin string
|
|
ID string
|
|
}
|
|
|
|
// migrationSet is one history table's declared migrations.
|
|
type migrationSet struct {
|
|
id string
|
|
// label names the set in Migrate's errors: "system_files", "backend
|
|
// admin", "deferred bindings", "queue" or the plugin ID.
|
|
label string
|
|
migrations []*gormigrate.Migration
|
|
}
|
|
|
|
// migrationSets lists the sets Migrate applies, in its order: the framework
|
|
// sets (system files, backend admin, deferred bindings, job queue), then each
|
|
// plugin's HasMigrations set in plugin order.
|
|
func migrationSets(gdb *gorm.DB, plugins []party.Plugin) ([]migrationSet, error) {
|
|
sqlDB, err := gdb.DB()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lagoon: migration sets: %w", err)
|
|
}
|
|
sets := []migrationSet{
|
|
{"summercms.attach", "system_files", attach.Migrations},
|
|
{"summercms.cabana", "backend admin", BackendAdminMigrations},
|
|
{DeferredHistoryID, "deferred bindings", DeferredBindingMigrations},
|
|
{QueueHistoryID, "queue", QueueMigrations(sqlDB)},
|
|
}
|
|
for _, p := range plugins {
|
|
hm, ok := p.(pact.HasMigrations)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ms := hm.Migrations(); len(ms) > 0 {
|
|
sets = append(sets, migrationSet{p.ID(), p.ID(), ms})
|
|
}
|
|
}
|
|
return sets, nil
|
|
}
|
|
|
|
// PendingMigrations returns the migrations Migrate would apply: each set's
|
|
// declared IDs that its history table does not hold, framework sets first,
|
|
// then plugins in order, each in declaration order. A missing history table
|
|
// means every migration of that set is pending. It only reads.
|
|
func PendingMigrations(gdb *gorm.DB, plugins []party.Plugin) ([]MigrationRef, error) {
|
|
if gdb == nil {
|
|
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
|
}
|
|
sets, err := migrationSets(gdb, plugins)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pending []MigrationRef
|
|
for _, s := range sets {
|
|
table, err := HistoryTableName(s.id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied := map[string]bool{}
|
|
if gdb.Migrator().HasTable(table) {
|
|
var ids []string
|
|
if err := gdb.Table(table).Pluck("id", &ids).Error; err != nil {
|
|
return nil, fmt.Errorf("lagoon: pending %s: %w", s.id, err)
|
|
}
|
|
for _, id := range ids {
|
|
applied[id] = true
|
|
}
|
|
}
|
|
for _, m := range s.migrations {
|
|
if m != nil && !applied[m.ID] {
|
|
pending = append(pending, MigrationRef{Plugin: s.id, ID: m.ID})
|
|
}
|
|
}
|
|
}
|
|
return pending, nil
|
|
}
|