Repository: ariga/atlas
Pull request #693 (2022-04-11 08:26:15)

sql/migrate: golang-migrate and goose support
@a8m @rotemtam I am not sure we want to have the tooling support be part of the Core or move it out to a `cli` package, that can be consumed by package users (and our `cmd` package).


Patch:
diff --git a/internal/tool/doc.go b/internal/tool/doc.go
new file mode 100644
index 00000000..dacd8ffb
--- /dev/null
+++ b/internal/tool/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+// Package tool contains logic to integrate existing tools like Flyway or Liquibase with the Atlas CLI.
+package tool
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
new file mode 100644
index 00000000..5a19829f
--- /dev/null
+++ b/internal/tool/tool.go
@@ -0,0 +1,67 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package tool
+
+import (
+	"text/template"
+	"time"
+
+	"ariga.io/atlas/sql/migrate"
+)
+
+var (
+	// funcs contains the template.FuncMap for the different formatters.
+	funcs = template.FuncMap{
+		// now format the current time in a lexicographically ascending order while maintaining human readability.
+		"now": func() string { return time.Now().Format("20060102150405") },
+		"rev": reverse,
+	}
+)
+
+// NewGolangMigrateFormatter returns a migrate.Formatter computable with golang-migrate/migrate.
+func NewGolangMigrateFormatter() (migrate.Formatter, error) {
+	return templateFormatter(
+		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.up.sql",
+		`{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}`,
+		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.down.sql",
+		`{{ range rev .Changes }}{{ if .Reverse }}{{ with .Comment }}-- reverse: {{ println . }}{{ end }}{{ printf "%s;\n" .Reverse }}{{ end }}{{ end }}`,
+	)
+}
+
+// NewGooseFormatter returns a migrate.Formatter computable with pressly/goose.
+func NewGooseFormatter() (migrate.Formatter, error) {
+	return templateFormatter(
+		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
+		`-- +goose Up
+{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}
+-- +goose Down
+{{ range rev .Changes }}{{ if .Reverse }}{{ with .Comment }}-- reverse: {{ println . }}{{ end }}{{ printf "%s;\n" .Reverse }}{{ end }}{{ end }}`,
+	)
+}
+
+// templateFormatter parses the given templates and passes them on to the migrate.NewTemplateFormatter.
+func templateFormatter(templates ...string) (fmt migrate.Formatter, err error) {
+	tpls := make([]*template.Template, len(templates))
+	for i, t := range templates {
+		tpls[i], err = template.New("").Funcs(funcs).Parse(t)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return migrate.NewTemplateFormatter(tpls...)
+}
+
+// reverse changes for the down migration.
+func reverse(changes []*migrate.Change) []*migrate.Change {
+	n := len(changes)
+	rev := make([]*migrate.Change, n)
+	if n%2 == 1 {
+		rev[n/2] = changes[n/2]
+	}
+	for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
+		rev[i], rev[j] = changes[j], changes[i]
+	}
+	return rev
+}
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 8468b08a..b59dd744 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -389,27 +389,18 @@ var _ Dir = (*LocalDir)(nil)
 var (
 	// templateFuncs contains the template.FuncMap for the DefaultFormatter.
 	templateFuncs = template.FuncMap{
-		"now": time.Now,
-		"sem": ensureSemicolonSuffix,
+		"now": func() string { return time.Now().Format("20060102150405") },
 		"rev": reverse,
 	}
-	// DefaultFormatter is a default implementation for Formatter. Compatible with golang-migrate/migrate.
+	// DefaultFormatter is a default implementation for Formatter.
 	DefaultFormatter = &TemplateFormatter{
 		templates: []struct{ N, C *template.Template }{
 			{
 				N: template.Must(template.New("").Funcs(templateFuncs).Parse(
-					"{{ now.Unix }}{{ with .Name }}_{{ . }}{{ end }}.up.sql",
+					"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
 				)),
 				C: template.Must(template.New("").Funcs(templateFuncs).Parse(
-					"{{ range .Changes }}{{ println (sem .Cmd) }}{{ end }}",
-				)),
-			},
-			{
-				N: template.Must(template.New("").Funcs(templateFuncs).Parse(
-					"{{ now.Unix }}{{ with .Name }}_{{ . }}{{ end }}.down.sql",
-				)),
-				C: template.Must(template.New("").Funcs(templateFuncs).Parse(
-					"{{ range rev .Changes }}{{ with .Reverse }}{{ println (sem .) }}{{ end }}{{ end }}",
+					`{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}`,
 				)),
 			},
 		},
@@ -614,14 +605,6 @@ func reverse(changes []*Change) []*Change {
 	return rev
 }
 
-// ensure an SQL statement has a trailing ";".
-func ensureSemicolonSuffix(s string) string {
-	if !strings.HasSuffix(s, ";") {
-		return s + ";"
-	}
-	return s
-}
-
 func wrap(err1, err2 error) error {
 	if err2 != nil {
 		return fmt.Errorf("sql/migrate: %w: %v", err2, err1)


Test patch:
diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go
new file mode 100644
index 00000000..ffbd58c4
--- /dev/null
+++ b/internal/tool/tool_test.go
@@ -0,0 +1,100 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package tool_test
+
+import (
+	"io/fs"
+	"testing"
+	"time"
+
+	"ariga.io/atlas/internal/tool"
+	"ariga.io/atlas/sql/migrate"
+	"github.com/stretchr/testify/require"
+)
+
+var plan = &migrate.Plan{
+	Name:       "tooling-plan",
+	Reversible: true,
+	Changes: []*migrate.Change{
+		{Cmd: "CREATE TABLE t1(c int)", Reverse: "DROP TABLE t1 IF EXISTS", Comment: "create table t1"},
+		{Cmd: "CREATE TABLE t2(c int)", Reverse: "DROP TABLE t2", Comment: "create table t2"},
+	},
+}
+
+func TestFormatters(t *testing.T) {
+	v := time.Now().Format("20060102150405")
+	for _, tt := range []struct {
+		name     string
+		fmt      func() (migrate.Formatter, error)
+		expected map[string]string
+	}{
+		{
+			"golang-migrate/migrate",
+			tool.NewGolangMigrateFormatter,
+			map[string]string{
+				v + "_tooling-plan.up.sql": `-- create table t1
+CREATE TABLE t1(c int);
+-- create table t2
+CREATE TABLE t2(c int);
+`,
+				v + "_tooling-plan.down.sql": `-- reverse: create table t2
+DROP TABLE t2;
+-- reverse: create table t1
+DROP TABLE t1 IF EXISTS;
+`,
+			},
+		},
+		{
+			"golang-migrate/migrate",
+			tool.NewGooseFormatter,
+			map[string]string{
+				v + "_tooling-plan.sql": `-- +goose Up
+-- create table t1
+CREATE TABLE t1(c int);
+-- create table t2
+CREATE TABLE t2(c int);
+
+-- +goose Down
+-- reverse: create table t2
+DROP TABLE t2;
+-- reverse: create table t1
+DROP TABLE t1 IF EXISTS;
+`,
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			d := dir(t)
+			fmt, err := tt.fmt()
+			require.NoError(t, err)
+			pl := migrate.NewPlanner(nil, d, migrate.WithFormatter(fmt), migrate.DisableChecksum())
+			require.NotNil(t, pl)
+			require.NoError(t, pl.WritePlan(plan))
+			require.Equal(t, len(tt.expected), countFiles(t, d))
+			for name, content := range tt.expected {
+				requireFileEqual(t, d, name, content)
+			}
+		})
+	}
+}
+
+func dir(t *testing.T) migrate.Dir {
+	p := t.TempDir()
+	d, err := migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	return d
+}
+
+func countFiles(t *testing.T, d migrate.Dir) int {
+	files, err := fs.ReadDir(d, "")
+	require.NoError(t, err)
+	return len(files)
+}
+
+func requireFileEqual(t *testing.T, d migrate.Dir, name, contents string) {
+	c, err := fs.ReadFile(d, name)
+	require.NoError(t, err)
+	require.Equal(t, contents, string(c))
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index ae516fe9..706753f7 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -13,7 +13,6 @@ import (
 	"io/fs"
 	"os"
 	"path/filepath"
-	"strconv"
 	"testing"
 	"text/template"
 	"time"
@@ -31,7 +30,7 @@ func TestPlanner_WritePlan(t *testing.T) {
 	plan := &migrate.Plan{
 		Name: "add_t1_and_t2",
 		Changes: []*migrate.Change{
-			{Cmd: "CREATE TABLE t1(c int);", Reverse: "DROP TABLE t1 IF EXISTS"},
+			{Cmd: "CREATE TABLE t1(c int)", Reverse: "DROP TABLE t1 IF EXISTS"},
 			{Cmd: "CREATE TABLE t2(c int)", Reverse: "DROP TABLE t2"},
 		},
 	}
@@ -40,22 +39,24 @@ func TestPlanner_WritePlan(t *testing.T) {
 	pl := migrate.NewPlanner(nil, d, migrate.DisableChecksum())
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
-	v := strconv.FormatInt(time.Now().Unix(), 10)
-	require.Equal(t, countFiles(t, d), 2)
-	requireFileEqual(t, filepath.Join(p, v+"_add_t1_and_t2.up.sql"), "CREATE TABLE t1(c int);\nCREATE TABLE t2(c int);\n")
-	requireFileEqual(t, filepath.Join(p, v+"_add_t1_and_t2.down.sql"), "DROP TABLE t2;\nDROP TABLE t1 IF EXISTS;\n")
+	v := time.Now().Format("20060102150405")
+	require.Equal(t, countFiles(t, d), 1)
+	requireFileEqual(t, d, v+"_add_t1_and_t2.sql", "CREATE TABLE t1(c int);\nCREATE TABLE t2(c int);\n")
 
-	// Custom formatter (creates only "up" migration files).
+	// Custom formatter (creates "up" and "down" migration files).
 	fmt, err := migrate.NewTemplateFormatter(
-		template.Must(template.New("").Parse("{{.Name}}.sql")),
-		template.Must(template.New("").Parse("{{range .Changes}}{{println .Cmd}}{{end}}")),
+		template.Must(template.New("").Parse("{{ .Name }}.up.sql")),
+		template.Must(template.New("").Parse("{{ range .Changes }}{{ println .Cmd }}{{ end }}")),
+		template.Must(template.New("").Parse("{{ .Name }}.down.sql")),
+		template.Must(template.New("").Parse("{{ range .Changes }}{{ println .Reverse }}{{ end }}")),
 	)
 	require.NoError(t, err)
 	pl = migrate.NewPlanner(nil, d, migrate.WithFormatter(fmt), migrate.DisableChecksum())
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
 	require.Equal(t, countFiles(t, d), 3)
-	requireFileEqual(t, filepath.Join(p, "add_t1_and_t2.sql"), "CREATE TABLE t1(c int);\nCREATE TABLE t2(c int)\n")
+	requireFileEqual(t, d, "add_t1_and_t2.up.sql", "CREATE TABLE t1(c int)\nCREATE TABLE t2(c int)\n")
+	requireFileEqual(t, d, "add_t1_and_t2.down.sql", "DROP TABLE t1 IF EXISTS\nDROP TABLE t2\n")
 }
 
 func TestPlanner_Plan(t *testing.T) {
@@ -92,14 +93,13 @@ func TestHashSum(t *testing.T) {
 	p := t.TempDir()
 	d, err := migrate.NewLocalDir(p)
 	require.NoError(t, err)
-	plan := &migrate.Plan{Name: "plan", Changes: []*migrate.Change{{Cmd: "cmd", Reverse: "rev"}}}
+	plan := &migrate.Plan{Name: "plan", Changes: []*migrate.Change{{Cmd: "cmd"}}}
 	pl := migrate.NewPlanner(nil, d)
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
-	v := strconv.FormatInt(time.Now().Unix(), 10)
-	require.Equal(t, countFiles(t, d), 3)
-	requireFileEqual(t, filepath.Join(p, v+"_plan.up.sql"), "cmd;\n")
-	requireFileEqual(t, filepath.Join(p, v+"_plan.down.sql"), "rev;\n")
+	v := time.Now().Format("20060102150405")
+	require.Equal(t, 2, countFiles(t, d))
+	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
 
 	p = t.TempDir()
@@ -108,9 +108,8 @@ func TestHashSum(t *testing.T) {
 	pl = migrate.NewPlanner(nil, d, migrate.DisableChecksum())
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
-	require.Equal(t, countFiles(t, d), 2)
-	requireFileEqual(t, filepath.Join(p, v+"_plan.up.sql"), "cmd;\n")
-	requireFileEqual(t, filepath.Join(p, v+"_plan.down.sql"), "rev;\n")
+	require.Equal(t, 1, countFiles(t, d))
+	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
 }
 
 func TestValidate(t *testing.T) {
@@ -309,8 +308,8 @@ func countFiles(t *testing.T, d migrate.Dir) int {
 	return len(files)
 }
 
-func requireFileEqual(t *testing.T, name, contents string) {
-	c, err := os.ReadFile(name)
+func requireFileEqual(t *testing.T, d migrate.Dir, name, contents string) {
+	c, err := fs.ReadFile(d, name)
 	require.NoError(t, err)
 	require.Equal(t, contents, string(c))
 }