Skip to content

Commit e545e7d

Browse files
keroxpclaude
andcommitted
feat(migrate): add minimal golang-migrate compatible migration tool
Adds a migrate package that manages database migrations with the same schema_migrations table layout as golang-migrate (latest version row + dirty flag), so existing databases can be taken over as-is. - migrate.Load reads <version>_<name>.up/down.sql files from any fs.FS - migrate.New returns a Migrator with Up/Down/Drop/Version, serialized across processes via GET_LOCK - migrate.Create generates a timestamped pair of empty migration files - migrate.Cli is a thin CLI frontend for embedding in a user's main - cmd/migrate is a ready-made command installable as a Go tool: go get -tool github.com/loilo-inc/exql/v3/cmd/migrate Blank migration files are applied as no-ops, same as golang-migrate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 055e54f commit e545e7d

11 files changed

Lines changed: 1144 additions & 0 deletions

File tree

cmd/migrate/main.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Command migrate is a minimal database migration CLI compatible with
2+
// golang-migrate, built on the migrate package. Install it as a Go tool
3+
// and run it with go tool:
4+
//
5+
// go get -tool github.com/loilo-inc/exql/v3/cmd/migrate
6+
// go tool migrate -dsn "root:@tcp(127.0.0.1:3306)/app" up
7+
// go tool migrate create add_users
8+
package main
9+
10+
import (
11+
"context"
12+
"database/sql"
13+
"flag"
14+
"fmt"
15+
"io"
16+
"os"
17+
18+
_ "github.com/go-sql-driver/mysql"
19+
"github.com/loilo-inc/exql/v3/migrate"
20+
)
21+
22+
func main() {
23+
if err := run(context.Background(), os.Args[1:], os.Stdout); err != nil {
24+
fmt.Fprintln(os.Stderr, err)
25+
os.Exit(1)
26+
}
27+
}
28+
29+
func run(ctx context.Context, args []string, out io.Writer) error {
30+
flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
31+
flags.SetOutput(out)
32+
dsn := flags.String("dsn", "", "MySQL DSN. Append multiStatements=true to run migration files containing multiple statements.")
33+
dir := flags.String("dir", "migrations", "directory containing migration files")
34+
if err := flags.Parse(args); err != nil {
35+
return err
36+
}
37+
38+
cli := &migrate.Cli{Dir: *dir, Out: out}
39+
cmd := flags.Args()
40+
// create only generates files and does not need a database
41+
if len(cmd) > 0 && cmd[0] != "create" {
42+
if *dsn == "" {
43+
return fmt.Errorf("-dsn is required")
44+
}
45+
db, err := sql.Open("mysql", *dsn)
46+
if err != nil {
47+
return err
48+
}
49+
defer db.Close()
50+
cli.DB = db
51+
cli.FS = os.DirFS(*dir)
52+
}
53+
54+
return cli.Run(ctx, cmd...)
55+
}

cmd/migrate/main_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"database/sql"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestRun(t *testing.T) {
17+
ctx := context.Background()
18+
t.Run("migrates a database from files in -dir", func(t *testing.T) {
19+
root, err := sql.Open("mysql", "root:@tcp(127.0.0.1:13326)/")
20+
require.NoError(t, err)
21+
defer root.Close()
22+
_, err = root.Exec("CREATE DATABASE IF NOT EXISTS exql_migrate_main")
23+
require.NoError(t, err)
24+
defer func() {
25+
_, err := root.Exec("DROP DATABASE IF EXISTS exql_migrate_main")
26+
require.NoError(t, err)
27+
}()
28+
29+
dir := t.TempDir()
30+
require.NoError(t, os.WriteFile(
31+
filepath.Join(dir, "1_create_users.up.sql"),
32+
[]byte("create table users (id int not null primary key)"), 0644))
33+
dsn := "root:@tcp(127.0.0.1:13326)/exql_migrate_main"
34+
35+
var out bytes.Buffer
36+
assert.NoError(t, run(ctx, []string{"-dsn", dsn, "-dir", dir, "up"}, &out))
37+
assert.Equal(t, "applying migration 1_create_users\n", out.String())
38+
39+
out.Reset()
40+
assert.NoError(t, run(ctx, []string{"-dsn", dsn, "-dir", dir, "version"}, &out))
41+
assert.Equal(t, "1\n", out.String())
42+
})
43+
t.Run("create works without -dsn", func(t *testing.T) {
44+
dir := t.TempDir()
45+
var out bytes.Buffer
46+
assert.NoError(t, run(ctx, []string{"-dir", dir, "create", "add_users"}, &out))
47+
entries, err := os.ReadDir(dir)
48+
require.NoError(t, err)
49+
require.Len(t, entries, 2)
50+
assert.Regexp(t, regexp.MustCompile(`^\d{14}_add_users\.down\.sql$`), entries[0].Name())
51+
assert.Regexp(t, regexp.MustCompile(`^\d{14}_add_users\.up\.sql$`), entries[1].Name())
52+
})
53+
t.Run("requires -dsn except for create", func(t *testing.T) {
54+
var out bytes.Buffer
55+
assert.EqualError(t, run(ctx, []string{"up"}, &out), "-dsn is required")
56+
})
57+
t.Run("rejects an unknown flag", func(t *testing.T) {
58+
var out bytes.Buffer
59+
err := run(ctx, []string{"-unknown"}, &out)
60+
assert.Error(t, err)
61+
assert.Contains(t, out.String(), "Usage of migrate:")
62+
})
63+
t.Run("prints usage without a subcommand", func(t *testing.T) {
64+
var out bytes.Buffer
65+
err := run(ctx, nil, &out)
66+
assert.ErrorContains(t, err, "expects a subcommand")
67+
})
68+
}

migrate/cli.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package migrate
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
"io"
8+
"io/fs"
9+
"os"
10+
)
11+
12+
// Cli is a thin command-line frontend for the migration logic.
13+
// Embed your migration files and call Run from your main function:
14+
//
15+
// //go:embed migrations/*.sql
16+
// var migrationFS embed.FS
17+
//
18+
// func main() {
19+
// db, err := sql.Open("mysql", dsn)
20+
// ...
21+
// sub, _ := fs.Sub(migrationFS, "migrations")
22+
// cli := &migrate.Cli{DB: db, FS: sub, Dir: "migrations"}
23+
// if err := cli.Run(context.Background(), os.Args[1:]...); err != nil {
24+
// log.Fatal(err)
25+
// }
26+
// }
27+
type Cli struct {
28+
// Database to migrate. Not required by the create command.
29+
DB *sql.DB
30+
// File system containing migration files at its root.
31+
// Not required by the create command.
32+
FS fs.FS
33+
// @default "migrations"
34+
// Directory where the create command puts new migration files.
35+
Dir string
36+
// @default os.Stdout
37+
// Destination for command output.
38+
Out io.Writer
39+
// Options passed to New. Can be nil.
40+
Options *Options
41+
}
42+
43+
const usage = `usage: <command>
44+
45+
commands:
46+
up apply all pending migrations
47+
down revert all applied migrations (destructive)
48+
drop drop all tables in the database (destructive)
49+
version print the current migration version
50+
create <name> create a new pair of migration files
51+
`
52+
53+
// Run executes a subcommand: up, down, drop, version, or create.
54+
// The down and drop commands are destructive; guard them by environment
55+
// in your main function if necessary.
56+
func (c *Cli) Run(ctx context.Context, args ...string) error {
57+
out := c.Out
58+
if out == nil {
59+
out = os.Stdout
60+
}
61+
if len(args) == 0 {
62+
return fmt.Errorf("expects a subcommand\n%s", usage)
63+
}
64+
65+
if args[0] == "create" {
66+
if len(args) != 2 {
67+
return fmt.Errorf("usage: create <name>")
68+
}
69+
dir := c.Dir
70+
if dir == "" {
71+
dir = "migrations"
72+
}
73+
paths, err := Create(dir, args[1])
74+
if err != nil {
75+
return err
76+
}
77+
for _, p := range paths {
78+
fmt.Fprintln(out, p)
79+
}
80+
return nil
81+
}
82+
83+
migrations, err := Load(c.FS)
84+
if err != nil {
85+
return err
86+
}
87+
opts := c.Options
88+
if opts == nil {
89+
opts = &Options{
90+
Log: func(msg string) { fmt.Fprintln(out, msg) },
91+
}
92+
}
93+
m := New(c.DB, migrations, opts)
94+
95+
switch args[0] {
96+
case "up":
97+
return m.Up(ctx)
98+
case "down":
99+
return m.Down(ctx)
100+
case "drop":
101+
return m.Drop(ctx)
102+
case "version":
103+
version, dirty, err := m.Version(ctx)
104+
if err != nil {
105+
return err
106+
}
107+
if dirty {
108+
fmt.Fprintf(out, "%d (dirty)\n", version)
109+
} else {
110+
fmt.Fprintf(out, "%d\n", version)
111+
}
112+
return nil
113+
default:
114+
return fmt.Errorf("unknown command: %s\n%s", args[0], usage)
115+
}
116+
}

migrate/cli_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package migrate
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"path/filepath"
7+
"testing"
8+
"testing/fstest"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
var testCliFS = fstest.MapFS{
16+
"1_create_users.up.sql": {Data: []byte("create table users (id int not null primary key)")},
17+
"1_create_users.down.sql": {Data: []byte("drop table users")},
18+
}
19+
20+
func TestCli_Run(t *testing.T) {
21+
ctx := context.Background()
22+
t.Run("up, version, down and drop", func(t *testing.T) {
23+
db := testDB(t, "exql_migrate_cli")
24+
var out bytes.Buffer
25+
cli := &Cli{DB: db, FS: testCliFS, Out: &out}
26+
27+
assert.NoError(t, cli.Run(ctx, "up"))
28+
assert.Equal(t, "applying migration 1_create_users\n", out.String())
29+
assert.Equal(t, []string{"schema_migrations", "users"}, tableNames(t, db))
30+
31+
out.Reset()
32+
assert.NoError(t, cli.Run(ctx, "version"))
33+
assert.Equal(t, "1\n", out.String())
34+
35+
assert.NoError(t, cli.Run(ctx, "down"))
36+
assert.Equal(t, []string{"schema_migrations"}, tableNames(t, db))
37+
38+
assert.NoError(t, cli.Run(ctx, "drop"))
39+
assert.Empty(t, tableNames(t, db))
40+
})
41+
t.Run("version reports dirty state", func(t *testing.T) {
42+
db := testDB(t, "exql_migrate_cli_dirty")
43+
broken := fstest.MapFS{
44+
"1_broken.up.sql": {Data: []byte("alter table nonexistent add x int")},
45+
}
46+
var out bytes.Buffer
47+
cli := &Cli{DB: db, FS: broken, Out: &out}
48+
assert.ErrorContains(t, cli.Run(ctx, "up"), "migration 1_broken failed")
49+
50+
out.Reset()
51+
assert.NoError(t, cli.Run(ctx, "version"))
52+
assert.Equal(t, "1 (dirty)\n", out.String())
53+
})
54+
t.Run("create", func(t *testing.T) {
55+
stubTimeNow(t, time.Date(2026, 7, 6, 12, 34, 56, 0, time.UTC))
56+
dir := t.TempDir()
57+
var out bytes.Buffer
58+
// create does not require DB nor FS
59+
cli := &Cli{Dir: dir, Out: &out}
60+
assert.NoError(t, cli.Run(ctx, "create", "add_age"))
61+
assert.Equal(t,
62+
filepath.Join(dir, "20260706123456_add_age.up.sql")+"\n"+
63+
filepath.Join(dir, "20260706123456_add_age.down.sql")+"\n",
64+
out.String())
65+
66+
assert.EqualError(t, cli.Run(ctx, "create"), "usage: create <name>")
67+
assert.EqualError(t, cli.Run(ctx, "create", "a", "b"), "usage: create <name>")
68+
})
69+
t.Run("create defaults to the migrations directory", func(t *testing.T) {
70+
stubTimeNow(t, time.Date(2026, 7, 6, 12, 34, 56, 0, time.UTC))
71+
dir := t.TempDir()
72+
t.Chdir(dir)
73+
require.NoError(t, (&Cli{Out: &bytes.Buffer{}}).Run(ctx, "create", "add_age"))
74+
assert.FileExists(t, filepath.Join(dir, "migrations", "20260706123456_add_age.up.sql"))
75+
})
76+
t.Run("errors", func(t *testing.T) {
77+
cli := &Cli{FS: testCliFS, Out: &bytes.Buffer{}}
78+
assert.ErrorContains(t, cli.Run(ctx), "expects a subcommand")
79+
assert.ErrorContains(t, cli.Run(ctx, "unknown"), "unknown command: unknown")
80+
81+
broken := &Cli{FS: fstest.MapFS{"1_a.down.sql": {Data: []byte("x")}}, Out: &bytes.Buffer{}}
82+
assert.EqualError(t, broken.Run(ctx, "up"), "missing up migration for 1_a")
83+
})
84+
}

migrate/create.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package migrate
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
)
9+
10+
// for stubbing in tests
11+
var timeNow = time.Now
12+
13+
// Create makes an empty pair of up/down migration files in dir, named
14+
// with the current timestamp as the version, and returns their paths.
15+
// dir is created if it does not exist. It fails if a file with the
16+
// same name already exists.
17+
func Create(dir string, name string) ([]string, error) {
18+
if name == "" || filepath.Base(name) != name {
19+
return nil, fmt.Errorf("invalid migration name: %q", name)
20+
}
21+
if err := os.MkdirAll(dir, 0755); err != nil {
22+
return nil, err
23+
}
24+
25+
version := timeNow().UTC().Format("20060102150405")
26+
var paths []string
27+
for _, direction := range []string{"up", "down"} {
28+
path := filepath.Join(dir, fmt.Sprintf("%s_%s.%s.sql", version, name, direction))
29+
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
30+
if err != nil {
31+
return nil, err
32+
}
33+
if err := f.Close(); err != nil {
34+
return nil, err
35+
}
36+
paths = append(paths, path)
37+
}
38+
39+
return paths, nil
40+
}

0 commit comments

Comments
 (0)