mirror of https://github.com/pressly/goose.git
squash all
parent
cf53a224e1
commit
fe8bc39842
2
Makefile
2
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 \
|
||||
|
|
|
@ -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
14
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
|
||||
|
|
25
go.sum
25
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=
|
||||
|
|
|
@ -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...)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
}
|
|
@ -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, ","))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
//go:build no_mysql
|
||||
// +build no_mysql
|
||||
|
||||
package normalizedsn
|
||||
|
||||
func DBString(dsn string) (string, error) {
|
||||
return dsn, nil
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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
|
||||
// }
|
|
@ -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...)
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
26
provider.go
26
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 != "" {
|
||||
|
|
Loading…
Reference in New Issue