diff --git a/cmd/migrate/app_flag_test.go b/cmd/migrate/app_flag_test.go new file mode 100644 index 00000000..df162ff7 --- /dev/null +++ b/cmd/migrate/app_flag_test.go @@ -0,0 +1,49 @@ +package migrate + +import ( + "strings" + "testing" + + "go-admin/cmd/migrate/migration" +) + +// A mistyped --app used to be indistinguishable from an up-to-date database on +// all three paths: `migrate` printed that the app was unknown and exited 0, +// while `--dry-run` and `status` printed "nothing to apply" and "none +// recorded" - the same words a database with nothing pending produces. An +// operator scripting `migrate --app crmm && deploy` therefore deployed against +// a database the migrations never touched. +func TestAppRegistrationErrorRejectsAnUnknownCode(t *testing.T) { + restore := appCode + t.Cleanup(func() { appCode = restore }) + + appCode = "doesnotexist" + err := appRegistrationError() + if err == nil { + t.Fatal("an unregistered app code must be an error, not an empty run") + } + if !strings.Contains(err.Error(), `"doesnotexist"`) { + t.Errorf("the message must quote what was typed; got %q", err) + } + // Listing what is registered is what turns the error into a fix: the typo + // is usually one letter away from something in this list. + if !strings.Contains(err.Error(), migration.FrameworkAppCode) { + t.Errorf("the message must list the registered codes; got %q", err) + } +} + +func TestAppRegistrationErrorAcceptsWhatIsRegistered(t *testing.T) { + restore := appCode + t.Cleanup(func() { appCode = restore }) + + for _, code := range []string{ + "", // no --app at all: every migration runs + migration.FrameworkAppCode, // "core", the framework's own + strings.ToUpper(migration.FrameworkAppCode), // codes normalize to lower case + } { + appCode = code + if err := appRegistrationError(); err != nil { + t.Errorf("appCode %q must be accepted; got %v", code, err) + } + } +} diff --git a/cmd/migrate/server.go b/cmd/migrate/server.go index c68abbfa..951fedee 100644 --- a/cmd/migrate/server.go +++ b/cmd/migrate/server.go @@ -3,12 +3,16 @@ package migrate import ( "bytes" "fmt" - "github.com/go-admin-team/go-admin-core/v2/sdk" - "github.com/go-admin-team/go-admin-core/v2/sdk/pkg" + "os" "strconv" + "strings" "text/template" "time" + "github.com/go-admin-team/go-admin-core/v2/sdk" + "github.com/go-admin-team/go-admin-core/v2/sdk/pkg" + "gorm.io/gorm" + "github.com/go-admin-team/go-admin-core/v2/config/source/file" "github.com/spf13/cobra" @@ -25,6 +29,8 @@ var ( generate bool goAdmin bool host string + appCode string + dryRun bool StartCmd = &cobra.Command{ Use: "migrate", Short: "Initialize the database", @@ -33,14 +39,31 @@ var ( run() }, } + statusCmd = &cobra.Command{ + Use: "status", + Short: "List applied and pending migrations, grouped by app", + Example: "go-admin migrate status -c config/settings.yml", + Run: func(cmd *cobra.Command, args []string) { + runStatus() + }, + } ) // fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移 func init() { StartCmd.PersistentFlags().StringVarP(&configYml, "config", "c", "config/settings.yml", "Start server with provided configuration file") StartCmd.PersistentFlags().BoolVarP(&generate, "generate", "g", false, "generate migration file") - StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "generate go-admin migration file") + StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "with -g, write the generated file to version/ instead of version-local/ (does not affect which migrations run)") StartCmd.PersistentFlags().StringVarP(&host, "domain", "d", "*", "select tenant host") + + // --app is deliberately long-only. -a already means "generate into + // version/ rather than version-local/", which is about writing a template + // file, not about which migrations run; giving the two the same letter + // would be a trap. + StartCmd.PersistentFlags().StringVar(&appCode, "app", "", "limit to the migrations of one app (\""+migration.FrameworkAppCode+"\" for the framework's own)") + StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing") + + StartCmd.AddCommand(statusCmd) } func run() { @@ -58,7 +81,12 @@ func run() { } } -func migrateModel() error { +// resolveDB picks the tenant database and hands it to the registry. +// +// It creates and alters nothing, which is what lets status and --dry-run share +// it: those two must be able to run against a production database without +// leaving a trace. +func resolveDB() (*gorm.DB, error) { if host == "" { host = "*" } @@ -73,29 +101,134 @@ func migrateModel() error { } } if db == nil { - return fmt.Errorf("未找到数据库配置") + return nil, fmt.Errorf("未找到数据库配置") } if config.DatabasesConfig[host].Driver == "mysql" { //初始化数据库时候用 db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4") } - err := db.Debug().AutoMigrate(&models.Migration{}) + return db, nil +} + +// exitUnlessAppRegistered ends the command when --app names something no +// migration was registered under. +// +// Every path took a typo as "nothing matched" and reported success: `migrate` +// printed that the app was unknown and still exited 0, while `--dry-run` and +// `status` said "nothing to apply" and "none recorded" - which is what an +// up-to-date database says too, so the output does not even hint at the typo. +// An operator running `go-admin migrate --app crmm && deploy` gets the deploy. +// +// Checked against the registry, which init() has already filled, so this runs +// before any database work and costs nothing. It lives in the command layer +// because the exit code does: the migration package stays callable from a test +// without taking the process down with it. +func exitUnlessAppRegistered() { + if err := appRegistrationError(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// appRegistrationError carries the decision on its own so it can be tested; +// exitUnlessAppRegistered is only the os.Exit around it. Nil means --app was +// either empty or names a registered app. +func appRegistrationError() error { + if appCode == "" { + return nil + } + want := migration.DisplayAppCode(migration.AppFilter(appCode)) + registered := migration.Migrate.AppCodes() + for _, c := range registered { + if c == want { + return nil + } + } + return fmt.Errorf("no migrations are registered for app %q; registered: %s", + want, strings.Join(registered, ", ")) +} + +func migrateModel() error { + db, err := resolveDB() if err != nil { return err } + // sys_migration is the one table that never goes through a versioned + // migration - it is the table that records them. AutoMigrate realigns it + // on every run, which is how the app_code column reaches an existing + // database without anyone writing a migration for it. + if err = db.Debug().AutoMigrate(&models.Migration{}); err != nil { + return err + } migration.Migrate.SetDb(db.Debug()) + if appCode != "" { + migration.Migrate.MigrateApp(appCode) + return nil + } migration.Migrate.Migrate() - return err + return nil } + func initDB() { + // Before the database is touched, so a typo cannot get as far as looking + // like a successful no-op on either path below. + exitUnlessAppRegistered() + //3. 初始化数据库链接 database.Setup() + + if dryRun { + db, err := resolveDB() + if err != nil { + fmt.Println(err) + return + } + migration.Migrate.SetDb(db) + entries, err := migration.Migrate.Status() + if err != nil { + fmt.Println(err) + return + } + if err = printPending(os.Stdout, entries, appCode); err != nil { + fmt.Println(err) + } + return + } + //4. 数据库迁移 fmt.Println("数据库迁移开始") - _ = migrateModel() + if err := migrateModel(); err != nil { + fmt.Println(err) + return + } fmt.Println(`数据库基础数据初始化成功`) } +func runStatus() { + config.Setup( + file.NewSource(file.WithPath(configYml)), + func() { + exitUnlessAppRegistered() + + database.Setup() + db, err := resolveDB() + if err != nil { + fmt.Println(err) + return + } + migration.Migrate.SetDb(db) + entries, err := migration.Migrate.Status() + if err != nil { + fmt.Println(err) + return + } + if err = printStatus(os.Stdout, entries, appCode); err != nil { + fmt.Println(err) + } + }, + ) +} + func genFile() error { t1, err := template.ParseFiles("template/migrate.template") if err != nil { diff --git a/cmd/migrate/status.go b/cmd/migrate/status.go new file mode 100644 index 00000000..1d809ab5 --- /dev/null +++ b/cmd/migrate/status.go @@ -0,0 +1,160 @@ +package migrate + +import ( + "fmt" + "io" + "sort" + "strings" + "time" + + "go-admin/cmd/migrate/migration" +) + +const applyTimeLayout = "2006-01-02 15:04:05" + +// printStatus lists every migration this binary knows about together with every +// row already in sys_migration, grouped by app. +// +// filter is an app code as typed on the command line; empty means every app. +func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) error { + entries = filterByApp(entries, filter) + + groups, order := groupByApp(entries) + if len(order) == 0 { + _, err := fmt.Fprintln(w, "no migrations registered and none recorded") + return err + } + + // One width for the whole listing rather than one per group: the versions + // of two apps line up, so a long list can be read down the column. + width := versionWidth(entries) + + var applied, pending, orphaned int + for i, app := range order { + if i > 0 { + fmt.Fprintln(w) + } + fmt.Fprintf(w, "[%s]\n", app) + for _, e := range groups[app] { + state := "pending" + switch { + case e.Applied && !e.Registered: + state = "orphaned" + orphaned++ + case e.Applied: + state = "applied" + applied++ + default: + pending++ + } + fmt.Fprintln(w, strings.TrimRight( + fmt.Sprintf(" %-*s%-*s%s", stateWidth, state, width, e.Version, formatApplyTime(e.ApplyTime)), " ")) + } + } + + fmt.Fprintf(w, "\n%d applied, %d pending across %d app(s)\n", applied, pending, len(order)) + if orphaned > 0 { + fmt.Fprintf(w, "%d orphaned: recorded in sys_migration, but nothing in this binary registers them.\n"+ + "Expected after a migration file is removed or an app is uninstalled; they will not run again.\n", orphaned) + } + return nil +} + +// printPending is --dry-run: the same data as status, narrowed to what an +// actual run would do and printed in the order it would do it. +// +// It reads and prints. Every write path - AutoMigrate on sys_migration +// included - is on the other branch in initDB, so a dry run leaves the database +// byte for byte as it found it. +func printPending(w io.Writer, entries []migration.StatusEntry, filter string) error { + entries = filterByApp(entries, filter) + + fmt.Fprintln(w, "dry-run: nothing will be written") + + pending := make([]migration.StatusEntry, 0, len(entries)) + for _, e := range entries { + // An orphaned row is recorded and unregistered; a real run cannot + // apply it, so a dry run must not offer to. + if !e.Applied && e.Registered { + pending = append(pending, e) + } + } + if len(pending) == 0 { + _, err := fmt.Fprintln(w, "nothing to apply") + return err + } + + appWidth := 0 + for _, e := range pending { + if n := len(migration.DisplayAppCode(e.AppCode)) + 2; n > appWidth { + appWidth = n + } + } + + fmt.Fprintln(w, "would apply, in this order:") + for _, e := range pending { + fmt.Fprintf(w, " %-*s%s\n", appWidth+2, "["+migration.DisplayAppCode(e.AppCode)+"]", e.Version) + } + fmt.Fprintf(w, "\n%d migration(s) pending\n", len(pending)) + return nil +} + +// stateWidth is the width of the applied/pending/orphaned column, sized to the +// longest of the three plus a gap. +const stateWidth = len("orphaned") + 2 + +func versionWidth(entries []migration.StatusEntry) int { + width := 0 + for _, e := range entries { + if n := len(e.Version) + 2; n > width { + width = n + } + } + return width +} + +// filterByApp keeps the entries of one app. The filter is matched after the +// same normalisation ForApp applies, so --app CRM finds crm. +func filterByApp(entries []migration.StatusEntry, filter string) []migration.StatusEntry { + if filter == "" { + return entries + } + want := migration.AppFilter(filter) + out := make([]migration.StatusEntry, 0, len(entries)) + for _, e := range entries { + if e.AppCode == want { + out = append(out, e) + } + } + return out +} + +// groupByApp buckets entries by display name and returns the buckets plus the +// order to print them in: the framework first, then apps alphabetically. That +// is also the order a full run executes them in, because version strings sort +// as ASCII and the framework's are bare digits. +func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusEntry, []string) { + groups := make(map[string][]migration.StatusEntry) + for _, e := range entries { + app := migration.DisplayAppCode(e.AppCode) + groups[app] = append(groups[app], e) + } + order := make([]string, 0, len(groups)) + for app := range groups { + order = append(order, app) + } + sort.Slice(order, func(i, j int) bool { + if (order[i] == migration.FrameworkAppCode) != (order[j] == migration.FrameworkAppCode) { + return order[i] == migration.FrameworkAppCode + } + return order[i] < order[j] + }) + return groups, order +} + +func formatApplyTime(t *time.Time) string { + if t == nil { + return "" + } + return t.Format(applyTimeLayout) +} diff --git a/cmd/migrate/status_test.go b/cmd/migrate/status_test.go new file mode 100644 index 00000000..d35f1ea1 --- /dev/null +++ b/cmd/migrate/status_test.go @@ -0,0 +1,170 @@ +package migrate + +import ( + "bytes" + "strings" + "testing" + "time" + + "go-admin/cmd/migrate/migration" +) + +func at(s string) *time.Time { + t, err := time.Parse(applyTimeLayout, s) + if err != nil { + panic(err) + } + return &t +} + +// The order Status returns: version strings sorted as ASCII. +func sampleEntries() []migration.StatusEntry { + return []migration.StatusEntry{ + {Version: "1786700001000", AppCode: "", Registered: true, Applied: true, ApplyTime: at("2026-08-20 10:00:00")}, + {Version: "1786700005000", AppCode: "", Registered: true}, + {Version: "crm-1786800001000", AppCode: "crm", Registered: true, Applied: true, ApplyTime: at("2026-08-25 14:03:11")}, + {Version: "crm-1786800002000", AppCode: "crm", Registered: true}, + } +} + +func TestPrintStatusGroupsByApp(t *testing.T) { + var buf bytes.Buffer + if err := printStatus(&buf, sampleEntries(), ""); err != nil { + t.Fatal(err) + } + got := buf.String() + + for _, want := range []string{ + "[core]", + "[crm]", + "applied 1786700001000 2026-08-20 10:00:00", + "pending 1786700005000", + "applied crm-1786800001000 2026-08-25 14:03:11", + "pending crm-1786800002000", + "2 applied, 2 pending across 2 app(s)", + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } + // The framework heads the list, because that is the order a full run + // executes in. + if strings.Index(got, "[core]") > strings.Index(got, "[crm]") { + t.Errorf("core is not listed first:\n%s", got) + } +} + +// A row nobody registers any more is neither applied-and-current nor pending. +// Calling it applied would say the migration is in this binary, which is what +// sends someone looking for a file that was deleted. +func TestPrintStatusMarksOrphanedRows(t *testing.T) { + entries := append(sampleEntries(), migration.StatusEntry{ + Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"), + }) + var buf bytes.Buffer + if err := printStatus(&buf, entries, ""); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.Contains(got, "orphaned gone-1786800000000") { + t.Errorf("orphaned row not marked:\n%s", got) + } + if !strings.Contains(got, "nothing in this binary registers them") { + t.Errorf("orphaned rows need an explanation:\n%s", got) + } + if !strings.Contains(got, "2 applied, 2 pending") { + t.Errorf("orphaned rows must not be counted as applied:\n%s", got) + } +} + +func TestPrintStatusFiltersByApp(t *testing.T) { + var buf bytes.Buffer + if err := printStatus(&buf, sampleEntries(), "crm"); err != nil { + t.Fatal(err) + } + got := buf.String() + if strings.Contains(got, "[core]") { + t.Errorf("--app crm listed the framework:\n%s", got) + } + if !strings.Contains(got, "across 1 app(s)") { + t.Errorf("output = %s", got) + } +} + +// status prints [core]; --app core has to mean the same thing. +func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) { + var buf bytes.Buffer + if err := printStatus(&buf, sampleEntries(), migration.FrameworkAppCode); err != nil { + t.Fatal(err) + } + got := buf.String() + if strings.Contains(got, "[crm]") { + t.Errorf("--app core listed crm:\n%s", got) + } + if !strings.Contains(got, "[core]") { + t.Errorf("--app core listed nothing:\n%s", got) + } +} + +func TestPrintStatusOnAnEmptyRegistry(t *testing.T) { + var buf bytes.Buffer + if err := printStatus(&buf, nil, ""); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "no migrations registered and none recorded") { + t.Errorf("output = %s", buf.String()) + } +} + +func TestPrintPendingListsOnlyPendingInOrder(t *testing.T) { + var buf bytes.Buffer + if err := printPending(&buf, sampleEntries(), ""); err != nil { + t.Fatal(err) + } + got := buf.String() + + if !strings.Contains(got, "dry-run: nothing will be written") { + t.Errorf("dry-run must say it writes nothing:\n%s", got) + } + if strings.Contains(got, "1786700001000\n") || strings.Contains(got, "crm-1786800001000") { + t.Errorf("dry-run listed already applied migrations:\n%s", got) + } + if !strings.Contains(got, "[core] 1786700005000") || !strings.Contains(got, "[crm] crm-1786800002000") { + t.Errorf("dry-run is missing pending migrations:\n%s", got) + } + if !strings.Contains(got, "2 migration(s) pending") { + t.Errorf("output = %s", got) + } + if strings.Index(got, "1786700005000") > strings.Index(got, "crm-1786800002000") { + t.Errorf("dry-run order does not match run order:\n%s", got) + } +} + +// An orphaned row is applied and unregistered; a dry run must not offer to +// apply it, because a real run cannot. +func TestPrintPendingSkipsOrphanedRows(t *testing.T) { + entries := []migration.StatusEntry{ + {Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00")}, + } + var buf bytes.Buffer + if err := printPending(&buf, entries, ""); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "nothing to apply") { + t.Errorf("output = %s", buf.String()) + } +} + +func TestPrintPendingFiltersByApp(t *testing.T) { + var buf bytes.Buffer + if err := printPending(&buf, sampleEntries(), "CRM"); err != nil { + t.Fatal(err) + } + got := buf.String() + if strings.Contains(got, "[core]") { + t.Errorf("--app CRM listed the framework:\n%s", got) + } + if !strings.Contains(got, "1 migration(s) pending") { + t.Errorf("output = %s", got) + } +}