diff --git a/Makefile b/Makefile index 47709cfd8..f7cf056f5 100644 --- a/Makefile +++ b/Makefile @@ -108,7 +108,7 @@ docker-postgres: -p $(DB_POSTGRES_PORT):5432 \ -l goose_test \ postgres:14-alpine -c log_statement=all - echo "postgres://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_POSTGRES_PORT)/$(DB_NAME)?sslmode=disable" + @echo "Connection string: postgres://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_POSTGRES_PORT)/$(DB_NAME)?sslmode=disable" docker-mysql: docker run --rm -d \ diff --git a/cmd/goose/main.go b/cmd/goose/main.go index 8a8f86f92..4748cf822 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -10,16 +10,17 @@ import ( "log" "os" "path/filepath" - "runtime/debug" "sort" "strconv" "strings" "text/tabwriter" "text/template" + "github.com/mfridman/buildversion" "github.com/mfridman/xflag" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/internal/cfg" + "github.com/pressly/goose/v3/internal/cli" "github.com/pressly/goose/v3/internal/migrationstats" ) @@ -43,6 +44,10 @@ var ( var version string func main() { + if ok, err := strconv.ParseBool(os.Getenv("GOOSE_CLI")); err == nil && ok { + cli.Main(cli.WithVersion(buildversion.New(version))) + return + } ctx := context.Background() flags.Usage = usage @@ -53,11 +58,7 @@ func main() { } if *versionFlag { - buildInfo, ok := debug.ReadBuildInfo() - if version == "" && ok && buildInfo != nil && buildInfo.Main.Version != "" { - version = buildInfo.Main.Version - } - fmt.Printf("goose version: %s\n", strings.TrimSpace(version)) + fmt.Printf("goose version: %s\n", buildversion.New(version)) return } if *verbose { @@ -80,8 +81,8 @@ func main() { os.Exit(1) } - // The -dir option has not been set, check whether the env variable is set - // before defaulting to ".". + // The -dir option has not been set, check whether the env variable is set before defaulting to + // ".". if *dir == cfg.DefaultMigrationDir && cfg.GOOSEMIGRATIONDIR != "" { *dir = cfg.GOOSEMIGRATIONDIR } @@ -380,8 +381,8 @@ func printValidate(filename string, verbose bool) error { if err != nil { return err } - // TODO(mf): we should introduce a --debug flag, which allows printing - // more internal debug information and leave verbose for additional information. + // TODO(mf): we should introduce a --debug flag, which allows printing more internal debug + // information and leave verbose for additional information. if !verbose { return nil } diff --git a/go.mod b/go.mod index 3a7dbc8b9..3ce81ae45 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,24 @@ module github.com/pressly/goose/v3 -go 1.21.0 +go 1.22 + +toolchain go1.23.1 require ( github.com/ClickHouse/clickhouse-go/v2 v2.28.3 + github.com/charmbracelet/lipgloss v0.13.0 github.com/go-sql-driver/mysql v1.8.1 github.com/jackc/pgx/v5 v5.7.1 + github.com/mfridman/buildversion v0.3.0 github.com/mfridman/interpolate v0.0.2 github.com/mfridman/xflag v0.0.0-20240825232106-efb77353e578 github.com/microsoft/go-mssqldb v1.7.2 + github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 github.com/sethvargo/go-retry v0.3.0 github.com/stretchr/testify v1.9.0 github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d github.com/vertica/vertica-sql-go v1.3.3 + github.com/xo/dburl v0.23.2 github.com/ydb-platform/ydb-go-sdk/v3 v3.80.2 github.com/ziutek/mymysql v1.5.4 go.uber.org/multierr v1.11.0 @@ -25,6 +31,8 @@ require ( github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -44,7 +52,10 @@ require ( github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/klauspost/compress v1.17.7 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/paulmach/orb v0.11.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect @@ -52,6 +63,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 // indirect diff --git a/go.sum b/go.sum index 4d4bcbfa0..fff202649 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,14 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -147,8 +153,14 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mfridman/buildversion v0.3.0 h1:hehEX3IbBZJBqquXctUEOWJfIM46P0ku9naK9h1BGuY= +github.com/mfridman/buildversion v0.3.0/go.mod h1:sfXvYxwfmLvkklTJLv9xJ0Wffw57z9ZFOK4KOGJYafU= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/xflag v0.0.0-20240825232106-efb77353e578 h1:CRrqlUmLebb/QjzRDWE0E66+YyN/v95+w6WyH9ju8/Y= @@ -158,6 +170,8 @@ github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpth github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -167,6 +181,10 @@ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2sz github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 h1:aiqS8aBlF9PsAKeMddMSfbwp3smONCn3UO8QfUg0Z7Y= +github.com/peterbourgon/ff/v4 v4.0.0-alpha.4/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -184,6 +202,9 @@ github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= @@ -208,6 +229,8 @@ github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9Y github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xo/dburl v0.23.2 h1:Fl88cvayrgE56JA/sqhNMLljCW/b7RmG1mMkKMZUFgA= +github.com/xo/dburl v0.23.2/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4= github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 h1:nL8XwD6fSst7xFUirkaWJmE7kM0CdWRYgu6+YQer1d4= github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-sdk/v3 v3.80.2 h1:qmZGJQCNx09/r0HDIT2cDDogiOvWikELy13ubM2CFS8= @@ -346,6 +369,8 @@ gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 000000000..6b3a786c2 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,47 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "runtime" + "syscall" +) + +// Main is the entry point for the CLI. +// +// If an error is returned, it is printed to stderr and the process exits with a non-zero exit code. +// The process is also canceled when an interrupt signal is received. This function and does not +// return. +func Main(opts ...Options) { + ctx, stop := newContext() + go func() { + defer stop() + if err := Run(ctx, os.Args[1:], opts...); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + }() + // TODO(mf): this looks wonky because we're not waiting for the context to be done. But + // eventually, I'd like to add a timeout here so we don't hang indefinitely. + <-ctx.Done() + os.Exit(0) +} + +// Run runs the CLI with the provided arguments. The arguments should not include the command name +// itself, only the arguments to the command, use os.Args[1:]. +// +// Options can be used to customize the behavior of the CLI, such as setting the environment, +// redirecting stdout and stderr, and providing a custom filesystem such as embed.FS. +func Run(ctx context.Context, args []string, opts ...Options) error { + return run(ctx, args, opts...) +} + +func newContext() (context.Context, context.CancelFunc) { + signals := []os.Signal{os.Interrupt} + if runtime.GOOS != "windows" { + signals = append(signals, syscall.SIGTERM) + } + return signal.NotifyContext(context.Background(), signals...) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 000000000..d595ba1de --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +const ( + version = "devel" +) + +func TestRun(t *testing.T) { + t.Run("version", func(t *testing.T) { + stdout, stderr, err := runCommand("--version") + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, stdout, "goose version: "+version+"\n") + }) + t.Run("with_filesystem", func(t *testing.T) { + fsys := fstest.MapFS{ + "migrations/001_foo.sql": {Data: []byte(`-- +goose up`)}, + } + command := "status --dir=migrations --dbstring=sqlite3://:memory: --json" + buf := bytes.NewBuffer(nil) + err := Run(context.Background(), strings.Split(command, " "), WithFilesystem(fsys.Sub), WithStdout(buf)) + require.NoError(t, err) + var status migrationsStatus + err = json.Unmarshal(buf.Bytes(), &status) + require.NoError(t, err) + require.Len(t, status.Migrations, 1) + assert.True(t, status.HasPending) + assert.Equal(t, "001_foo.sql", status.Migrations[0].Source.Path) + assert.Equal(t, "pending", status.Migrations[0].State) + assert.Equal(t, "", status.Migrations[0].AppliedAt) + }) +} + +func runCommand(args ...string) (string, string, error) { + stdout, stderr := bytes.NewBuffer(nil), bytes.NewBuffer(nil) + err := Run( + context.Background(), + args, + WithStdout(stdout), + WithStderr(stderr), + WithVersion(version), + ) + return stdout.String(), stderr.String(), err +} diff --git a/internal/cli/cmd_root.go b/internal/cli/cmd_root.go new file mode 100644 index 000000000..334c8b93b --- /dev/null +++ b/internal/cli/cmd_root.go @@ -0,0 +1,41 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/peterbourgon/ff/v4" +) + +type cmdRoot struct { + state *state + fs *ff.FlagSet + + // flags + version bool +} + +func newRootCommand(state *state) *ff.Command { + c := &cmdRoot{ + state: state, + fs: ff.NewFlagSet("goose"), + } + c.fs.BoolVarDefault(&c.version, 0, "version", false, "print version and exit") + + cmd := &ff.Command{ + Name: "goose", + Usage: "goose [flags] [args...]", + ShortHelp: "A database migration tool. Supports SQL migrations and Go functions.", + Flags: c.fs, + Exec: c.exec, + } + return cmd +} + +func (c *cmdRoot) exec(ctx context.Context, args []string) error { + if c.version { + fmt.Fprintf(c.state.stdout, "goose version: %s\n", c.state.version) + return nil + } + return nil +} diff --git a/internal/cli/cmd_status.go b/internal/cli/cmd_status.go new file mode 100644 index 000000000..4347e6ba7 --- /dev/null +++ b/internal/cli/cmd_status.go @@ -0,0 +1,91 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/peterbourgon/ff/v4" + "github.com/pressly/goose/v3" +) + +type cmdStatus struct { + state *state + fs *ff.FlagSet + + // flags + dir string + dbstring string + tablename string + useJSON bool +} + +// TODO(mf): there is something not very ergonomic about how all this works. Will need to think +// about how to improve this and file an issue upstream. I wish the default could be set here, +// instead of in the flag definition. +func mustFlag(fs *ff.FlagSet, cfg ff.FlagConfig) { + if _, err := fs.AddFlag(cfg); err != nil { + panic(err) + } +} + +func newStatusCommand(state *state) (*ff.Command, error) { + c := cmdStatus{ + state: state, + fs: ff.NewFlagSet("status"), + } + // Mandatory flags + mustFlag(c.fs, newDirFlag(&c.dir)) + mustFlag(c.fs, newDBStringFlag(&c.dbstring)) + // Optional flags + mustFlag(c.fs, newTablenameFlag(&c.tablename)) + mustFlag(c.fs, newJSONFlag(&c.useJSON)) + + return &ff.Command{ + Name: "status", + Usage: "status [flags]", + ShortHelp: "List the status of all migrations", + LongHelp: strings.TrimSpace(statusLongHelp), + Flags: c.fs, + Exec: c.exec, + }, nil +} + +const ( + statusLongHelp = ` +List the status of all migrations, comparing the current state of the database with the migrations +available in the filesystem. If a migration is applied to the database, it will be listed with the +timestamp it was applied, otherwise it will be listed as "Pending". +` +) + +func (c *cmdStatus) exec(ctx context.Context, args []string) error { + p, err := c.state.initProvider(c.dir, c.dbstring, c.tablename) + if err != nil { + return err + } + results, err := p.Status(ctx) + if err != nil { + return err + } + if c.useJSON { + return c.state.writeJSON(convertMigrationStatus(results)) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) + defer tw.Flush() + fmtPattern := "%v\t%v\n" + fmt.Fprintf(tw, fmtPattern, "Migration name", "Applied At") + fmt.Fprintf(tw, fmtPattern, "──────────────", "──────────") + for _, result := range results { + t := "Pending" + if result.State == goose.StateApplied { + t = result.AppliedAt.Format(time.DateTime) + } + fmt.Fprintf(tw, fmtPattern, filepath.Base(result.Source.Path), t) + } + return nil +} diff --git a/internal/cli/common_flags.go b/internal/cli/common_flags.go new file mode 100644 index 000000000..41a54174a --- /dev/null +++ b/internal/cli/common_flags.go @@ -0,0 +1,51 @@ +package cli + +import ( + "fmt" + + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffval" + "github.com/pressly/goose/v3" +) + +var requiredFlags = map[string]bool{ + "dir": true, + "dbstring": true, +} + +func newDirFlag(s *string) ff.FlagConfig { + return ff.FlagConfig{ + LongName: "dir", + Usage: "directory with migration files", + NoDefault: true, + Value: ffval.NewValue(s), + Placeholder: "string", + } +} + +func newDBStringFlag(s *string) ff.FlagConfig { + return ff.FlagConfig{ + LongName: "dbstring", + Usage: "connection string for the database", + NoDefault: true, + Value: ffval.NewValue(s), + Placeholder: "string", + } +} + +func newJSONFlag(b *bool) ff.FlagConfig { + return ff.FlagConfig{ + LongName: "json", + Usage: "output as JSON", + Value: ffval.NewValue(b), + } +} + +func newTablenameFlag(b *string) ff.FlagConfig { + return ff.FlagConfig{ + LongName: "table", + Usage: fmt.Sprintf("migration table name (default: %s)", goose.DefaultTablename), + Value: ffval.NewValue(b), + Placeholder: "string", + } +} diff --git a/internal/cli/connection.go b/internal/cli/connection.go new file mode 100644 index 000000000..cee6b41af --- /dev/null +++ b/internal/cli/connection.go @@ -0,0 +1,86 @@ +package cli + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/internal/cli/normalizedsn" + "github.com/xo/dburl" +) + +// dialectToDriverMapping maps dialects to the actual driver names used by the goose CLI. +// +// See the ./cmd/goose directory for driver imports, which are conditionally compiled based on build +// tags. For example, for postgres we use github.com/jackc/pgx/v5/stdlib, and the driver name is +// "pgx". For sqlite3 we use modernc.org/sqlite and the driver name is "sqlite". +var dialectToDriverMapping = map[goose.Dialect]string{ + goose.DialectPostgres: "pgx", + goose.DialectRedshift: "pgx", + goose.DialectMySQL: "mysql", + goose.DialectTiDB: "mysql", + goose.DialectSQLite3: "sqlite", + goose.DialectMSSQL: "sqlserver", + goose.DialectClickHouse: "clickhouse", + goose.DialectVertica: "vertica", +} + +func openConnection(dbstring string) (*sql.DB, goose.Dialect, error) { + dbURL, err := dburl.Parse(dbstring) + if err != nil { + return nil, "", fmt.Errorf("failed to parse DSN: %w", err) + } + dialect, err := resolveDialect(dbURL.UnaliasedDriver, dbURL.Scheme) + if err != nil { + return nil, "", fmt.Errorf("failed to resolve dialect: %w", err) + } + var dataSourceName string + switch dialect { + case goose.DialectMySQL: + dataSourceName, err = normalizedsn.DBString(dataSourceName) + if err != nil { + return nil, "", fmt.Errorf("failed to normalize mysql DSN: %w", err) + } + default: + dataSourceName = dbURL.DSN + } + driverName, ok := dialectToDriverMapping[dialect] + if !ok { + return nil, "", fmt.Errorf("unknown database dialect: %s", dialect) + } + db, err := sql.Open(driverName, dataSourceName) + if err != nil { + return nil, "", fmt.Errorf("failed to open connection: %w", err) + } + return db, dialect, nil +} + +// resolveDialect returns the dialect for the first string that matches a known dialect alias or +// schema name. If no match is found, an error is returned. +// +// The string can be a schema name or an alias. The aliases are defined by the dburl package for +// common databases. See: https://github.com/xo/dburl#database-schemes-aliases-and-drivers +func resolveDialect(ss ...string) (goose.Dialect, error) { + for _, s := range ss { + switch s { + case "postgres", "pg", "pgx", "postgresql", "pgsql": + return goose.DialectPostgres, nil + case "mysql", "my", "mariadb", "maria", "percona", "aurora": + return goose.DialectMySQL, nil + case "sqlite", "sqlite3": + return goose.DialectSQLite3, nil + case "sqlserver", "ms", "mssql", "azuresql": + return goose.DialectMSSQL, nil + case "redshift", "rs": + return goose.DialectRedshift, nil + case "tidb", "ti": + return goose.DialectTiDB, nil + case "clickhouse", "ch": + return goose.DialectClickHouse, nil + case "vertica", "ve": + return goose.DialectVertica, nil + } + } + return "", fmt.Errorf("failed to resolve scheme names or aliases to a dialect: %q", strings.Join(ss, ",")) +} diff --git a/internal/cli/help.go b/internal/cli/help.go new file mode 100644 index 000000000..3c857c0cf --- /dev/null +++ b/internal/cli/help.go @@ -0,0 +1,144 @@ +package cli + +import ( + "bytes" + "os" + "strconv" + "text/tabwriter" + + "github.com/charmbracelet/lipgloss" + "github.com/peterbourgon/ff/v4" + "github.com/peterbourgon/ff/v4/ffhelp" +) + +const ( + redColor = "#cc0000" +) + +// additionalSections contains additional help sections for specific commands. +var additionalSections = map[string][]ffhelp.Section{ + "status": { + { + Title: "EXAMPLES", + Lines: []string{ + `goose status --dir=migrations --dbstring=sqlite:./test.db`, + `GOOSE_DIR=migrations GOOSE_DBSTRING=sqlite:./test.db goose status`, + }, + LinePrefix: ffhelp.DefaultLinePrefix, + }, + }, +} + +func createHelp(cmd *ff.Command) ffhelp.Help { + style := lipgloss.NewStyle().Foreground(lipgloss.Color(redColor)) + render := func(s string) string { + // TODO(mf): should we also support a global flag to disable color? + if val := os.Getenv("NO_COLOR"); val != "" { + if ok, err := strconv.ParseBool(val); err == nil && ok { + return s + } + } + return style.Render(s) + } + if selected := cmd.GetSelected(); selected != nil { + cmd = selected + } + // For the root command, we're going to print a custom help message. + if cmd.Name == "goose" { + return rootHelp(cmd, render) + } + // For all other commands, we're going to print the default help message. + var help ffhelp.Help + + if cmd.LongHelp != "" { + section := ffhelp.NewUntitledSection(cmd.LongHelp) + help = append(help, section) + } + + title := cmd.Name + if cmd.ShortHelp != "" { + title = title + " -- " + cmd.ShortHelp + } + help = append(help, ffhelp.NewSection(render("COMMAND"), title)) + + if cmd.Usage != "" { + help = append(help, ffhelp.NewSection(render("USAGE"), cmd.Usage)) + } + + if len(cmd.Subcommands) > 0 { + section := ffhelp.NewSubcommandsSection(cmd.Subcommands) + section.Title = render(section.Title) + help = append(help, section) + } + + for _, section := range ffhelp.NewFlagsSections(cmd.Flags) { + section.Title = render(section.Title) + help = append(help, section) + } + if sections, ok := additionalSections[cmd.Name]; ok { + for _, section := range sections { + section.Title = render(section.Title) + help = append(help, section) + } + } + + return help +} + +func rootHelp(cmd *ff.Command, render func(s string) string) ffhelp.Help { + var help ffhelp.Help + + section := ffhelp.NewUntitledSection("A database migration tool. Supports SQL migrations and Go functions.") + help = append(help, section) + + section = ffhelp.NewSection(render("USAGE"), "goose [flags] [args...]") + help = append(help, section) + + section = ffhelp.NewSubcommandsSection(cmd.Subcommands) + section.Title = render("COMMANDS") + help = append(help, section) + + section = ffhelp.NewUntitledSection(render("SUPPORTED DATABASES")) + for _, s := range []string{ + "postgres mysql sqlite3 clickhouse", + "redshift tidb mssql vertica", + } { + section.Lines = append(section.Lines, ffhelp.DefaultLinePrefix+s) + } + help = append(help, section) + + section = ffhelp.NewUntitledSection(render("ENVIRONMENT VARIABLES")) + keys := []struct { + name string + description string + }{ + {"GOOSE_DBSTRING", "Database connection string, lower priority than --dbstring"}, + {"GOOSE_DIR", "Directory with migration files, lower priority than --dir"}, + {"GOOSE_TABLE", "Database table name, lower priority than --table (default goose_db_version)"}, + {"NO_COLOR", "Disable color output"}, + } + buf := bytes.NewBuffer(nil) + tw := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + for _, v := range keys { + _, _ = tw.Write([]byte(ffhelp.DefaultLinePrefix + v.name + "\t" + v.description + "\n")) + } + tw.Flush() + section.Lines = append(section.Lines, buf.String()) + help = append(help, section) + + // section = ffhelp.NewUntitledSection("EXAMPLES") + // for _, s := range []string{ + // "goose status --dbstring=\"postgres://dbuser:password1@localhost:5433/testdb?sslmode=disable\" --dir=./examples/sql-migrations", + // "GOOSE_DIR=./examples/sql-migrations GOOSE_DBSTRING=\"sqlite:./test.db\" goose status", + // } { + // section.Lines = append(section.Lines, s) + // } + // help = append(help, section) + + section = ffhelp.NewUntitledSection(render("LEARN MORE")) + section.Lines = append(section.Lines, ffhelp.DefaultLinePrefix+"Use 'goose --help' for more information about a command") + section.Lines = append(section.Lines, ffhelp.DefaultLinePrefix+"Read the docs at https://pressly.github.io/goose/") + help = append(help, section) + + return help +} diff --git a/internal/cli/normalizedsn/normalizedsn_mysql.go b/internal/cli/normalizedsn/normalizedsn_mysql.go new file mode 100644 index 000000000..4e6e435c2 --- /dev/null +++ b/internal/cli/normalizedsn/normalizedsn_mysql.go @@ -0,0 +1,18 @@ +//go:build !no_mysql +// +build !no_mysql + +package normalizedsn + +import "github.com/go-sql-driver/mysql" + +// DBString parses the dsn used with the mysql driver to always have the parameter `parseTime` set +// to true. This allows internal goose logic to assume that DATETIME/DATE/TIMESTAMP can be scanned +// into the time.Time type. +func DBString(dsn string) (string, error) { + config, err := mysql.ParseDSN(dsn) + if err != nil { + return "", err + } + config.ParseTime = true + return config.FormatDSN(), nil +} diff --git a/internal/cli/normalizedsn/normalizedsn_no_mysql.go b/internal/cli/normalizedsn/normalizedsn_no_mysql.go new file mode 100644 index 000000000..d6654acf8 --- /dev/null +++ b/internal/cli/normalizedsn/normalizedsn_no_mysql.go @@ -0,0 +1,8 @@ +//go:build no_mysql +// +build no_mysql + +package normalizedsn + +func DBString(dsn string) (string, error) { + return dsn, nil +} diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 000000000..7be4eddb9 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,111 @@ +package cli + +import ( + "database/sql" + "fmt" + "io" + "io/fs" + + "github.com/pressly/goose/v3" +) + +// Options are used to configure the command execution and are passed to the Run or Main function. +type Options interface { + apply(*state) error +} + +type optionFunc func(*state) error + +func (f optionFunc) apply(s *state) error { return f(s) } + +// WithEnviron sets the environment variables for the command. This will overwrite the current +// environment, primarily useful for testing. +func WithEnviron(env []string) Options { + return optionFunc(func(s *state) error { + s.environ = env + return nil + }) +} + +// WithStdout sets the writer for stdout. +func WithStdout(w io.Writer) Options { + return optionFunc(func(s *state) error { + if w == nil { + return fmt.Errorf("stdout cannot be nil") + } + if s.stdout != nil { + return fmt.Errorf("stdout already set") + } + s.stdout = w + return nil + }) +} + +// WithStderr sets the writer for stderr. +func WithStderr(w io.Writer) Options { + return optionFunc(func(s *state) error { + if w == nil { + return fmt.Errorf("stderr cannot be nil") + } + if s.stderr != nil { + return fmt.Errorf("stderr already set") + } + s.stderr = w + return nil + }) +} + +// WithFilesystem takes a function that returns a filesystem for the given directory. The directory +// will be the value of the --dir flag passed to the command. A typical use case is to use +// [embed.FS] or [fstest.MapFS]. For example: +// +// fsys := fstest.MapFS{ +// "migrations/001_foo.sql": {Data: []byte(`-- +goose Up`)}, +// } +// err := cli.Run(context.Background(), os.Args[1:], cli.WithFilesystem(fsys.Sub)) +// +// The above example will run the command with the filesystem provided by [fsys.Sub]. +func WithFilesystem(fsys func(dir string) (fs.FS, error)) Options { + return optionFunc(func(s *state) error { + if fsys == nil { + return fmt.Errorf("filesystem cannot be nil") + } + if s.fsys != nil { + return fmt.Errorf("filesystem already set") + } + s.fsys = fsys + return nil + }) +} + +// WithOpenConnection sets the function that opens a connection to the database from a DSN string. +// The function should return the dialect and the database connection. The dbstring will typically +// be a DSN, such as "postgres://user:password@localhost/dbname" or "sqlite3://file.db" and it is up +// to the function to parse it. +func WithOpenConnection(open func(dbstring string) (*sql.DB, goose.Dialect, error)) Options { + return optionFunc(func(s *state) error { + if open == nil { + return fmt.Errorf("open connection function cannot be nil") + } + if s.openConnection != nil { + return fmt.Errorf("open connection function already set") + } + s.openConnection = open + return nil + }) +} + +// WithVersion sets the version string for the command. This is typically set by the build system +// when the binary is built. It is used to print the version when the --version flag is passed. +func WithVersion(version string) Options { + return optionFunc(func(s *state) error { + if version == "" { + return fmt.Errorf("version cannot be empty") + } + if s.version != "" { + return fmt.Errorf("version already set") + } + s.version = version + return nil + }) +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 000000000..b9c292b53 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,118 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/peterbourgon/ff/v4" +) + +const ( + ENV_NO_COLOR = "NO_COLOR" +) + +func run(ctx context.Context, args []string, opts ...Options) (retErr error) { + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("panic: %v", r) + } + }() + st, err := newStateWithDefaults(opts...) + if err != nil { + return err + } + + root := newRootCommand(st) + // Add subcommands + commands := []func(*state) (*ff.Command, error){ + newStatusCommand, + } + for _, cmd := range commands { + c, err := cmd(st) + if err != nil { + return err + } + root.Subcommands = append(root.Subcommands, c) + } + + // Parse the flags and return help if requested. + if err := root.Parse( + args, + ff.WithEnvVarPrefix("GOOSE"), // Support environment variables for all flags + ); err != nil { + if errors.Is(err, ff.ErrHelp) { + fmt.Fprintf(st.stderr, "\n%s\n", createHelp(root)) + return nil + } + return err + } + // TODO(mf): ideally this would be done in the ff package. See open issue: + // https://github.com/peterbourgon/ff/issues/128 + if err := checkRequiredFlags(root); err != nil { + return err + } + return root.Run(ctx) +} + +func newStateWithDefaults(opts ...Options) (*state, error) { + state := &state{ + environ: os.Environ(), + } + for _, opt := range opts { + if err := opt.apply(state); err != nil { + return nil, err + } + } + // Set defaults if not set by the caller + if state.stdout == nil { + state.stdout = os.Stdout + } + if state.stderr == nil { + state.stderr = os.Stderr + } + if state.fsys == nil { + // Use the default filesystem if not set, reading from the local filesystem. + state.fsys = func(dir string) (fs.FS, error) { return os.DirFS(dir), nil } + } + if state.openConnection == nil { + // Use the default openConnection function if not set. + state.openConnection = openConnection + } + return state, nil +} + +func checkRequiredFlags(cmd *ff.Command) error { + if cmd != nil { + cmd = cmd.GetSelected() + } + var required []string + if err := cmd.Flags.WalkFlags(func(f ff.Flag) error { + name, ok := f.GetLongName() + if !ok { + return fmt.Errorf("flag %v doesn't have a long name", f) + } + if requiredFlags[name] && !f.IsSet() { + required = append(required, "--"+name) + } + return nil + }); err != nil { + return err + } + if len(required) > 0 { + return fmt.Errorf("required flags not set: %v", strings.Join(required, ", ")) + } + return nil +} + +// func coalesce[T comparable](values ...T) (zero T) { +// for _, v := range values { +// if v != zero { +// return v +// } +// } +// return zero +// } diff --git a/internal/cli/state.go b/internal/cli/state.go new file mode 100644 index 000000000..282d22030 --- /dev/null +++ b/internal/cli/state.go @@ -0,0 +1,67 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/database" +) + +// state holds the state of the CLI and is passed to each command. It is used to configure the +// environment, filesystem, and output streams. +type state struct { + version string + environ []string + stdout io.Writer + stderr io.Writer + // This is effectively [fs.SubFS](https://pkg.go.dev/io/fs#SubFS). + fsys func(dir string) (fs.FS, error) + openConnection func(dbstring string) (*sql.DB, goose.Dialect, error) +} + +func (s *state) writeJSON(v interface{}) error { + by, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + _, err = s.stdout.Write(by) + return err +} + +func (s *state) initProvider( + dir string, + dbstring string, + tablename string, + options ...goose.ProviderOption, +) (*goose.Provider, error) { + if dir == "" { + return nil, fmt.Errorf("migrations directory is required, set with --dir or GOOSE_DIR") + } + if dbstring == "" { + return nil, errors.New("database connection string is required, set with --dbstring or GOOSE_DBSTRING") + } + db, dialect, err := openConnection(dbstring) + if err != nil { + return nil, fmt.Errorf("failed to open connection: %w", err) + } + if tablename != "" { + store, err := database.NewStore(dialect, tablename) + if err != nil { + return nil, fmt.Errorf("failed to create store: %w", err) + } + options = append(options, goose.WithStore(store)) + // TODO(mf): I don't like how this works. It's not obvious that if a store is provided, the + // dialect must be set to an empty string. This is because the dialect is set in the store. + dialect = "" + } + fsys, err := s.fsys(dir) + if err != nil { + return nil, fmt.Errorf("failed to get subtree rooted at dir: %q: %w", dir, err) + } + return goose.NewProvider(dialect, db, fsys, options...) +} diff --git a/internal/cli/types.go b/internal/cli/types.go new file mode 100644 index 000000000..d3c124c72 --- /dev/null +++ b/internal/cli/types.go @@ -0,0 +1,53 @@ +package cli + +import ( + "time" + + "github.com/pressly/goose/v3" +) + +type migrationsStatus struct { + Migrations []migrationStatus `json:"migrations"` + HasPending bool `json:"has_pending"` +} + +type migrationStatus struct { + AppliedAt string `json:"applied_at,omitempty"` + State string `json:"state"` + Source source `json:"source"` +} + +func convertMigrationStatus(all []*goose.MigrationStatus) migrationsStatus { + out := migrationsStatus{ + Migrations: make([]migrationStatus, 0, len(all)), + } + for _, s := range all { + var appliedAt string + switch s.State { + case goose.StateApplied: + appliedAt = s.AppliedAt.Format(time.DateTime) + case goose.StatePending: + out.HasPending = true + } + out.Migrations = append(out.Migrations, migrationStatus{ + AppliedAt: appliedAt, + State: string(s.State), + Source: convertSource(s.Source), + }) + } + return out +} + +type source struct { + Type string `json:"type"` + Path string `json:"path"` + Version int64 `json:"version"` +} + +func convertSource(s *goose.Source) source { + return source{ + Type: string(s.Type), + Path: s.Path, + Version: s.Version, + } +} diff --git a/provider.go b/provider.go index ec3b8749a..5ab38f856 100644 --- a/provider.go +++ b/provider.go @@ -38,19 +38,25 @@ type Provider struct { // NewProvider returns a new goose provider. // // The caller is responsible for matching the database dialect with the database/sql driver. For -// example, if the database dialect is "postgres", the database/sql driver could be +// example, if the database dialect is "postgres", the driver in your application could be // github.com/lib/pq or github.com/jackc/pgx. Each dialect has a corresponding [database.Dialect] -// constant backed by a default [database.Store] implementation. For more advanced use cases, such -// as using a custom table name or supplying a custom store implementation, see [WithStore]. +// backed by a default [database.Store] implementation. Most users won't concern themselves with the +// store and it's enough to just supply the correct dialect to match the database technology. For +// more advanced use cases, such as using a custom table name or supplying a custom store +// implementation, see [WithStore] option. // // fsys is the filesystem used to read migration files, but may be nil. Most users will want to use -// [os.DirFS], os.DirFS("path/to/migrations"), to read migrations from the local filesystem. -// However, it is possible to use a different "filesystem", such as [embed.FS] or filter out -// migrations using [fs.Sub]. +// [os.DirFS], example: os.DirFS("path/to/migrations"), to read migrations from the local +// filesystem. However, it is possible to use a different filesystem, such as [embed.FS] or filter +// out migrations using [fs.Sub]. // -// See [ProviderOption] for more information on configuring the provider. +// See [ProviderOption] for more info on configuring the provider. // -// Unless otherwise specified, all methods on Provider are safe for concurrent use. +// Unless otherwise specified, all methods on Provider are safe for concurrent use within the same +// application. However, it is not safe to use goose in parallel across multiple applications unless +// a lock implementation is provided to the provider. See [WithSessionLocker] option for more info. +// +// Experimental: This API is experimental and may change in the future. func NewProvider(dialect Dialect, db *sql.DB, fsys fs.FS, opts ...ProviderOption) (*Provider, error) { if db == nil { return nil, errors.New("db must not be nil") @@ -72,10 +78,10 @@ func NewProvider(dialect Dialect, db *sql.DB, fsys fs.FS, opts ...ProviderOption // Allow users to specify a custom store implementation, but only if they don't specify a // dialect. If they specify a dialect, we'll use the default store implementation. if dialect == "" && cfg.store == nil { - return nil, errors.New("dialect must not be empty") + return nil, errors.New("failed to create goose provider: dialect or store must be supplied") } if dialect != "" && cfg.store != nil { - return nil, errors.New("dialect must be empty when using a custom store implementation") + return nil, errors.New("failed to create goose provider: dialect and store cannot be supplied together") } var store database.Store if dialect != "" {