squash all

pull/741/head
Mike Fridman 2024-09-18 05:57:05 -04:00
parent cf53a224e1
commit fe8bc39842
No known key found for this signature in database
18 changed files with 957 additions and 22 deletions

View File

@ -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 \

View File

@ -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
}

14
go.mod
View File

@ -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

25
go.sum
View File

@ -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=

47
internal/cli/cli.go Normal file
View File

@ -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...)
}

56
internal/cli/cli_test.go Normal file
View File

@ -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
}

41
internal/cli/cmd_root.go Normal file
View File

@ -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 <command> [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
}

View File

@ -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
}

View File

@ -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",
}
}

View File

@ -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, ","))
}

144
internal/cli/help.go Normal file
View File

@ -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 <command> [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 <command> --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
}

View File

@ -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
}

View File

@ -0,0 +1,8 @@
//go:build no_mysql
// +build no_mysql
package normalizedsn
func DBString(dsn string) (string, error) {
return dsn, nil
}

111
internal/cli/options.go Normal file
View File

@ -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
})
}

118
internal/cli/run.go Normal file
View File

@ -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
// }

67
internal/cli/state.go Normal file
View File

@ -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...)
}

53
internal/cli/types.go Normal file
View File

@ -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,
}
}

View File

@ -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 != "" {