diff --git a/extra/buncli/auto.go b/extra/buncli/auto.go index 4fd4eb17..089d4343 100644 --- a/extra/buncli/auto.go +++ b/extra/buncli/auto.go @@ -1,11 +1,12 @@ package buncli import ( + "github.com/uptrace/bun/migrate" "github.com/urfave/cli/v2" ) // CmdAuto creates the auto command hierarchy. -func CmdAuto(c *Config) *cli.Command { +func CmdAuto(c AutoConfig) *cli.Command { return &cli.Command{ Name: "auto", Usage: "Manage database schema with AutoMigrator", @@ -31,17 +32,23 @@ func CmdAuto(c *Config) *cli.Command { } } -func runAutoMigrate(ctx *cli.Context, c *Config) error { - _, err := c.AutoMigrator.Migrate(ctx.Context, c.MigrateOptions...) +// AutoConfig provides configuration for commands related to auto migration. +type AutoConfig interface { + OptionsConfig + Auto() *migrate.AutoMigrator +} + +func runAutoMigrate(ctx *cli.Context, c AutoConfig) error { + _, err := c.Auto().Migrate(ctx.Context, c.GetMigrateOptions()...) return err } -func runAutoCreate(ctx *cli.Context, c *Config) error { +func runAutoCreate(ctx *cli.Context, c AutoConfig) error { var err error if flagTx.Get(ctx) { - _, err = c.AutoMigrator.CreateTxSQLMigrations(ctx.Context) + _, err = c.Auto().CreateTxSQLMigrations(ctx.Context) } else { - _, err = c.AutoMigrator.CreateSQLMigrations(ctx.Context) + _, err = c.Auto().CreateSQLMigrations(ctx.Context) } return err } diff --git a/extra/buncli/buncli.go b/extra/buncli/buncli.go index 06578cde..c1333e3e 100644 --- a/extra/buncli/buncli.go +++ b/extra/buncli/buncli.go @@ -18,8 +18,9 @@ import ( // bunApp is the root-level bundb app that all other commands attach to. var bunApp = &cli.App{ - Name: "bundb", - Usage: "Database migration tool for uptrace/bun", + Name: "bundb", + Usage: "Database migration tool for uptrace/bun", + Suggest: true, } // New creates a new CLI application for managing bun migrations. @@ -40,7 +41,7 @@ func New(c *Config) *App { } // NewStandalone create a new CLI application to be distributed as a standalone binary. -// It's intended to be used in the cmb/bund and does not require any prior setup from the user: +// It's intended to be used in the cmb/bundb and does not require any prior setup from the user: // the app only includes the Init command and reads all its configuration from command line. // // Prefer using New(*Config) in your custom entrypoint. @@ -49,6 +50,9 @@ func NewStandalone(name string) *App { bunApp.Commands = cli.Commands{ CmdInit(), } + + // NOTE: use `-tags experimental` to enable/disable this feature? + addCommandGroup(bunApp, "EXPERIMENTAL", pluginCommands()...) return &App{ App: bunApp, } @@ -78,3 +82,24 @@ func RunContext(ctx context.Context, args []string, c *Config) error { type App struct { *cli.App } + +var _ OptionsConfig = (*Config)(nil) +var _ MigratorConfig = (*Config)(nil) +var _ AutoConfig = (*Config)(nil) + +func (c *Config) NewMigrator() *migrate.Migrator { + return migrate.NewMigrator(c.DB, c.Migrations, c.MigratorOptions...) +} + +func (c *Config) Auto() *migrate.AutoMigrator { return c.AutoMigrator } +func (c *Config) GetMigratorOptions() []migrate.MigratorOption { return c.MigratorOptions } +func (c *Config) GetMigrateOptions() []migrate.MigrationOption { return c.MigrateOptions } +func (c *Config) GetGoMigrationOptions() []migrate.GoMigrationOption { return c.GoMigrationOptions } + +// addCommandGroup groups commands into one category. +func addCommandGroup(app *cli.App, group string, commands ...*cli.Command) { + for _, cmd := range commands { + cmd.Category = group + } + app.Commands = append(app.Commands, commands...) +} \ No newline at end of file diff --git a/extra/buncli/init.go b/extra/buncli/init.go index 2f92415e..0d1df173 100644 --- a/extra/buncli/init.go +++ b/extra/buncli/init.go @@ -46,6 +46,12 @@ func CmdInit() *cli.Command { } } +type OptionsConfig interface { + GetMigratorOptions() []migrate.MigratorOption + GetMigrateOptions() []migrate.MigrationOption + GetGoMigrationOptions() []migrate.GoMigrationOption +} + const ( maingo = "main.go" defaultBin = "bun" @@ -102,8 +108,8 @@ var ( } ) -func runInit(ctx *cli.Context, c *Config) error { - m := migrate.NewMigrator(c.DB, c.Migrations, c.MigratorOptions...) +func runInit(ctx *cli.Context, c MigratorConfig) error { + m := c.NewMigrator() if err := m.Init(ctx.Context); err != nil { return err } @@ -209,17 +215,19 @@ func (n *normalMode) Bootstrap() error { if err != nil { return err } - + migratorOpts := strings.Join(n.MigratorOptions, ", ") if err := writef(binDir, maingo, entrypointTemplate, modPath, n.Binary, migratorOpts); err != nil { return err } - + // Create migrations/main.go template migrationsDir := path.Join(binDir, n.Migrations) if err := writef(migrationsDir, maingo, migrationsTemplate); err != nil { return err } + + return nil } @@ -274,11 +282,15 @@ func init() { ` func (p *pluginMode) Bootstrap() error { - binDir := path.Join(p.Loc, p.Migrations) + migrationsDir := path.Join(p.Loc, p.Migrations) migratorOpts := strings.Join(p.MigratorOptions, ", ") - if err := writef(binDir, maingo, pluginTemplate, migratorOpts); err != nil { + if err := writef(migrationsDir, maingo, pluginTemplate, migratorOpts); err != nil { return err } + + // TODO: run go mod init in migrations/ directory so that it'd be a standalone package + // TODO: run go mod init in migrations/ directory + return nil } diff --git a/extra/buncli/migrator.go b/extra/buncli/migrator.go index 8ef0ea05..fc4355a4 100644 --- a/extra/buncli/migrator.go +++ b/extra/buncli/migrator.go @@ -6,7 +6,7 @@ import ( ) // CmdMigrate creates migrate command. -func CmdMigrate(c *Config) *cli.Command { +func CmdMigrate(c MigratorConfig) *cli.Command { return &cli.Command{ Name: "migrate", Usage: "Apply database migrations", @@ -16,14 +16,20 @@ func CmdMigrate(c *Config) *cli.Command { } } -func runMigrate(ctx *cli.Context, c *Config) error { - m := migrate.NewMigrator(c.DB, c.Migrations, c.MigratorOptions...) - _, err := m.Migrate(ctx.Context, c.MigrateOptions...) +// MigratorConfig provides configuration related to conventional migrations. +type MigratorConfig interface { + OptionsConfig + NewMigrator() *migrate.Migrator +} + +func runMigrate(ctx *cli.Context, c MigratorConfig) error { + m := c.NewMigrator() + _, err := m.Migrate(ctx.Context, c.GetMigrateOptions()...) return err } // CmdRollback creates rollback command. -func CmdRollback(c *Config) *cli.Command { +func CmdRollback(c MigratorConfig) *cli.Command { return &cli.Command{ Name: "rollback", Usage: "Rollback the last migration group", @@ -33,14 +39,14 @@ func CmdRollback(c *Config) *cli.Command { } } -func runRollback(ctx *cli.Context, c *Config) error { - m := migrate.NewMigrator(c.DB, c.Migrations, c.MigratorOptions...) - _, err := m.Rollback(ctx.Context, c.MigrateOptions...) +func runRollback(ctx *cli.Context, c MigratorConfig) error { + m := c.NewMigrator() + _, err := m.Rollback(ctx.Context, c.GetMigrateOptions()...) return err } // CmdCreate creates create command. -func CmdCreate(c *Config) *cli.Command { +func CmdCreate(c MigratorConfig) *cli.Command { return &cli.Command{ Name: "create", Usage: "Create a new migration file template", @@ -88,13 +94,13 @@ var ( } ) -func runCreate(ctx *cli.Context, c *Config) error { +func runCreate(ctx *cli.Context, c MigratorConfig) error { var err error - m := migrate.NewMigrator(c.DB, c.Migrations, c.MigratorOptions...) + m := c.NewMigrator() name := ctx.Args().First() if createGo { - _, err = m.CreateGoMigration(ctx.Context, name, c.GoMigrationOptions...) + _, err = m.CreateGoMigration(ctx.Context, name, c.GetGoMigrationOptions()...) return err } @@ -107,7 +113,7 @@ func runCreate(ctx *cli.Context, c *Config) error { } // CmdUnlock creates an unlock command. -func CmdUnlock(c *Config) *cli.Command { +func CmdUnlock(c MigratorConfig) *cli.Command { return &cli.Command{ Name: "unlock", Usage: "Unlock migration locks table", @@ -117,7 +123,7 @@ func CmdUnlock(c *Config) *cli.Command { } } -func runUnlock(ctx *cli.Context, c *Config) error { - m := migrate.NewMigrator(c.DB, c.Migrations, c.MigratorOptions...) +func runUnlock(ctx *cli.Context, c MigratorConfig) error { + m := c.NewMigrator() return m.Unlock(ctx.Context) } diff --git a/extra/buncli/plugin.go b/extra/buncli/plugin.go new file mode 100644 index 00000000..2ff370dc --- /dev/null +++ b/extra/buncli/plugin.go @@ -0,0 +1,176 @@ +package buncli + +import ( + "bytes" + "fmt" + "os/exec" + "path" + "plugin" + "sync" + + "github.com/uptrace/bun/migrate" + "github.com/urfave/cli/v2" +) + +const ( + pluginName = "plugin.so" + configLookupName = "Config" +) + +var ( + buildOnce = sync.OnceValue(buildPlugin) + importOnce = sync.OnceValues(importConfig) + + pluginPath string + + flagPluginPath = &cli.StringFlag{ + Name: "m", + Usage: "relative `PATH` to migrations directory", + Value: "./" + defaultMigrations, + Destination: &pluginPath, + } +) + +func pluginCommands() cli.Commands { + c := fromPlugin() + + auto := CmdAuto(c) + skipAuto := func(c *cli.Command) bool { + return c == auto + } + return extendCommands(cli.Commands{ + auto, + CmdMigrate(c), + CmdRollback(c), + CmdCreate(c), + CmdUnlock(c), + }, skipAuto, withBefore(checkCanImportPlugin), withFlag(flagPluginPath)) +} + +func fromPlugin() *pluginConfig { + return &pluginConfig{ + config: func() *Config { + c, _ := importOnce() + return c + }, + } +} + +// checkCanImportPlugin returns an error if migrations/ plugin build failed or *buncli.Config could not be imported from it. +func checkCanImportPlugin(ctx *cli.Context) error { + if isHelp(ctx) { + // Do not build plugin if on -help. + return nil + } + _, err := importOnce() + return err +} + +func isHelp(ctx *cli.Context) bool { + help := cli.HelpFlag.(*cli.BoolFlag) + return ctx.Command.Name == bunApp.HelpName || help.Get(ctx) +} + +// importConfig builds migrations/ plugin and imports Config from it. +// Config must be exported and of type *buncli.Config. +func importConfig() (*Config, error) { + if err := buildOnce(); err != nil { + return nil, err + } + + p, err := plugin.Open(path.Join(pluginPath, pluginName)) + if err != nil { + return nil, err + } + sym, err := p.Lookup(configLookupName) + if err != nil { + return nil, err + } + cfg, ok := sym.(**Config) + if !ok { + return nil, fmt.Errorf("migrations plugin must export Config as *buncli.Config, got %T", sym) + } + return *cfg, nil +} + +type pluginConfig struct { + config func() *Config +} + +var _ OptionsConfig = (*pluginConfig)(nil) +var _ MigratorConfig = (*pluginConfig)(nil) +var _ AutoConfig = (*pluginConfig)(nil) + +func (p *pluginConfig) NewMigrator() *migrate.Migrator { + return p.config().NewMigrator() +} + +func (p *pluginConfig) Auto() *migrate.AutoMigrator { + return p.config().Auto() +} + +func (p *pluginConfig) GetMigratorOptions() []migrate.MigratorOption { + return p.config().GetMigratorOptions() +} + +func (p *pluginConfig) GetMigrateOptions() []migrate.MigrationOption { + return p.config().GetMigrateOptions() +} + +func (p *pluginConfig) GetGoMigrationOptions() []migrate.GoMigrationOption { + return p.config().GetGoMigrationOptions() +} + +// buildPlugin compiles migrations/ plugin with -buildmode=plugin. +func buildPlugin() error { + cmd := exec.Command("go", "build", "-C", pluginPath, "-buildmode", "plugin", "-o", pluginName) + + // Cmd.Run returns *exec.ExitError which will only contain the exit code message in case of an error. + // Rather than logging "exit code 1" we want to output a more informative error, so we redirect the Stderr. + var errBuf bytes.Buffer + cmd.Stderr = &errBuf + + err := cmd.Run() + if err != nil { + return fmt.Errorf("build %s: %s", path.Join(pluginPath, pluginName), &errBuf) + } + return nil +} + +type commandOption func(*cli.Command) + +func extendCommands(commands cli.Commands, skip func(c *cli.Command) bool, options ...commandOption) cli.Commands { + var all cli.Commands + var flatten func(cmds cli.Commands) + + flatten = func(cmds cli.Commands) { + for _, cmd := range cmds { + if skip != nil && !skip(cmd) { + all = append(all, cmd) + } + flatten(cmd.Subcommands) + } + } + flatten(commands) + + for _, cmd := range all { + for _, opt := range options { + opt(cmd) + } + } + return commands +} + +// withFlag adds a flag to command. +func withFlag(f cli.Flag) commandOption { + return func(c *cli.Command) { + c.Flags = append(c.Flags, f) + } +} + +// withBefore adds BeforeFunc to command. +func withBefore(bf cli.BeforeFunc) commandOption { + return func(c *cli.Command) { + c.Before = bf + } +}