Repository: ariga/atlas
Pull request #1060 (2022-08-16 14:55:18)

cmd/atlas: make schema diff commands work with realms
currently the command: `atlas schema diff --from mysql://root:pass@localhost:3306 --to mysql://root:pass@localhost:33`
will return: `Error: mysql: schema "" was not found`


Patch:
diff --git a/cmd/atlas/internal/cmdapi/diff.go b/cmd/atlas/internal/cmdapi/diff.go
index ed92dfaa..2ee84eb7 100644
--- a/cmd/atlas/internal/cmdapi/diff.go
+++ b/cmd/atlas/internal/cmdapi/diff.go
@@ -5,8 +5,10 @@
 package cmdapi
 
 import (
+	"fmt"
 	"strings"
 
+	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/spf13/cobra"
@@ -52,21 +54,38 @@ func cmdDiffRun(cmd *cobra.Command, flags *diffCmdOpts) {
 	toC, err := sqlclient.Open(cmd.Context(), flags.toURL)
 	cobra.CheckErr(err)
 	defer toC.Close()
-	fromName := fromC.URL.Schema
-	toName := toC.URL.Schema
-	fromSchema, err := fromC.InspectSchema(ctx, fromName, nil)
-	cobra.CheckErr(err)
-	toSchema, err := toC.InspectSchema(ctx, toName, nil)
-	cobra.CheckErr(err)
-	// SchemaDiff checks for name equality which is irrelevant in the case
-	// the user wants to compare their contents, if the names are different
-	// we reset them to allow the comparison.
-	if fromName != toName {
-		toSchema.Name = ""
-		fromSchema.Name = ""
+	fromS := fromC.URL.Schema
+	toS := toC.URL.Schema
+	var diff []schema.Change
+	switch {
+	case fromS == "" && toS == "":
+		// compare realm.
+		fromRealm, err := fromC.InspectRealm(ctx, nil)
+		cobra.CheckErr(err)
+		toRealm, err := toC.InspectRealm(ctx, nil)
+		cobra.CheckErr(err)
+		diff, err = toC.RealmDiff(fromRealm, toRealm)
+		cobra.CheckErr(err)
+	case fromS == "":
+		cobra.CheckErr(fmt.Errorf("cannot diff schema %q with a database connection", fromS))
+	case toS == "":
+		cobra.CheckErr(fmt.Errorf("cannot diff database connection with a schema %q", toS))
+	default:
+		// compare schemas.
+		fromSchema, err := fromC.InspectSchema(ctx, fromS, nil)
+		cobra.CheckErr(err)
+		toSchema, err := toC.InspectSchema(ctx, toS, nil)
+		cobra.CheckErr(err)
+		// SchemaDiff checks for name equality which is irrelevant in the case
+		// the user wants to compare their contents, if the names are different
+		// we reset them to allow the comparison.
+		if fromS != toS {
+			toSchema.Name = ""
+			fromSchema.Name = ""
+		}
+		diff, err = toC.SchemaDiff(fromSchema, toSchema)
+		cobra.CheckErr(err)
 	}
-	diff, err := toC.SchemaDiff(fromSchema, toSchema)
-	cobra.CheckErr(err)
 	p, err := toC.PlanChanges(ctx, "plan", diff)
 	cobra.CheckErr(err)
 	if len(p.Changes) == 0 {
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index 7bcf8daf..d927e346 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -738,7 +738,9 @@ func dir(create bool) (migrate.Dir, error) {
 	case formatGoose:
 		f = func() (migrate.Dir, error) { return sqltool.NewGooseDir(parts[1]) }
 	case formatFlyway:
+		f = func() (migrate.Dir, error) { return sqltool.NewFlywayDir(parts[1]) }
 	case formatLiquibase:
+		f = func() (migrate.Dir, error) { return sqltool.NewLiquibaseDir(parts[1]) }
 	case formatDbmate:
 		f = func() (migrate.Dir, error) { return sqltool.NewDBMateDir(parts[1]) }
 	default:
@@ -945,7 +947,7 @@ func formatter() (migrate.Formatter, error) {
 	case formatLiquibase:
 		return sqltool.LiquibaseFormatter, nil
 	case formatDbmate:
-		return sqltool.DbmateFormatter, nil
+		return sqltool.DBMateFormatter, nil
 	default:
 		return nil, fmt.Errorf("unknown format %q", MigrateFlags.DirFormat)
 	}
diff --git a/doc/website/src/pages/index.js b/doc/website/src/pages/index.js
index 9afb166b..21cb959d 100644
--- a/doc/website/src/pages/index.js
+++ b/doc/website/src/pages/index.js
@@ -11,7 +11,7 @@ import BrowserOnly from "@docusaurus/core/lib/client/exports/BrowserOnly";
 function Nav() {
     return  <ul className={index.nav}>
         <li className={index.linkItem}>
-            <Link to={"/cli/getting-started/setting-up"}>
+            <Link to={"/getting-started"}>
                Docs
             </Link>
         </li>
@@ -77,7 +77,7 @@ export default function () {
                             on your desired state.
                         </p>
                     </div>
-                    <AtlasButton style={{"marginTop": "5%"}} text="Get Started"  link="/cli/getting-started/setting-up" type="primaryButton"/>
+                    <AtlasButton style={{"marginTop": "5%"}} text="Get Started" link="/getting-started" type="primaryButton"/>
                 </div>
                 <div className={index.imageContainer}>
                     <img src="https://atlasgo.io/uploads/hero2.png" alt="hero"/>
diff --git a/go.sum b/go.sum
index e721ca2f..669e9ec5 100644
--- a/go.sum
+++ b/go.sum
@@ -3,7 +3,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q
 github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
 github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
-github.com/apparentlymart/go-textseg v1.0.0 h1:rRmlIsPEEhUTIKQb7T++Nz/A5Q6C9IuX2wFoYVvnCs0=
 github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
 github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
 github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
@@ -51,16 +50,13 @@ github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUA
 github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
 golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -70,9 +66,6 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/sql/migrate/dir.go b/sql/migrate/dir.go
index da6921c5..9eba56e5 100644
--- a/sql/migrate/dir.go
+++ b/sql/migrate/dir.go
@@ -105,7 +105,7 @@ func (d *LocalDir) Files() ([]File, error) {
 		if err != nil {
 			return nil, fmt.Errorf("sql/migrate: read file %q: %w", n, err)
 		}
-		ret[i] = &LocalFile{n: n, b: b}
+		ret[i] = NewLocalFile(n, b)
 	}
 	return ret, nil
 }
diff --git a/sql/sqltool/tool.go b/sql/sqltool/tool.go
index 6e79722d..4a11658c 100644
--- a/sql/sqltool/tool.go
+++ b/sql/sqltool/tool.go
@@ -9,6 +9,7 @@ import (
 	"bytes"
 	"fmt"
 	"io/fs"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strings"
@@ -54,18 +55,26 @@ var (
 {{ with $change.Reverse }}--rollback: {{ . }};{{ end }}
 {{ end }}`,
 	)
-	// DbmateFormatter returns migrate.Formatter compatible with amacneil/dbmate.
-	DbmateFormatter = templateFormatter(
+	// DBMateFormatter returns migrate.Formatter compatible with amacneil/dbmate.
+	DBMateFormatter = templateFormatter(
 		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
 		`-- migrate:up
 {{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}
 -- migrate:down
 {{ range rev .Changes }}{{ if .Reverse }}{{ with .Comment }}-- reverse: {{ println . }}{{ end }}{{ printf "%s;\n" .Reverse }}{{ end }}{{ end }}`,
 	)
+	// DbmateFormatter is the same as DBMateFormatter.
+	// Deprecated: Use DBMateFormatter instead.
+	DbmateFormatter = DBMateFormatter
 )
 
-// GolangMigrateDir wraps migrate.LocalDir and provides implementation compatible with golang-migrate/migrate.
-type GolangMigrateDir struct{ *migrate.LocalDir }
+type (
+	// GolangMigrateDir wraps migrate.LocalDir and provides a migrate.Scanner implementation able to understand files
+	// generated by the GolangMigrateFormatter for migration directory replaying.
+	GolangMigrateDir struct{ *migrate.LocalDir }
+	// GolangMigrateFile wraps migrate.LocalFile with custom description function.
+	GolangMigrateFile struct{ *migrate.LocalFile }
+)
 
 // NewGolangMigrateDir returns a new GolangMigrateDir.
 func NewGolangMigrateDir(path string) (*GolangMigrateDir, error) {
@@ -97,18 +106,18 @@ func (d *GolangMigrateDir) Files() ([]migrate.File, error) {
 	return ret, nil
 }
 
-// GolangMigrateFile wraps migrate.LocalFile with custom description function.
-type GolangMigrateFile struct {
-	*migrate.LocalFile
-}
-
 // Desc implements File.Desc.
 func (f *GolangMigrateFile) Desc() string {
 	return strings.TrimSuffix(f.LocalFile.Desc(), ".up")
 }
 
-// GooseDir wraps migrate.LocalDir and provides implementation compatible with pressly/goose.
-type GooseDir struct{ *migrate.LocalDir }
+type (
+	// GooseDir wraps migrate.LocalDir and provides a migrate.Scanner implementation able to understand files
+	// generated by the GooseFormatter for migration directory replaying.
+	GooseDir struct{ *migrate.LocalDir }
+	// GooseFile wraps migrate.LocalFile with custom statements function.
+	GooseFile struct{ *migrate.LocalFile }
+)
 
 // NewGooseDir returns a new GooseDir.
 func NewGooseDir(path string) (*GooseDir, error) {
@@ -119,11 +128,6 @@ func NewGooseDir(path string) (*GooseDir, error) {
 	return &GooseDir{dir}, nil
 }
 
-// GooseFile wraps migrate.LocalFile with custom statements function.
-type GooseFile struct {
-	*migrate.LocalFile
-}
-
 // Files implements Scanner.Files. It looks for all files with up.sql suffix and orders them by filename.
 func (d *GooseDir) Files() ([]migrate.File, error) {
 	files, err := d.LocalDir.Files()
@@ -210,39 +214,14 @@ func (f *GooseFile) Stmts() ([]string, error) {
 	return stmts, nil
 }
 
-const (
-	none int = iota // state when parsing goose/dbmate sql file
-	up
-	begin
-	end
-	goosePragma  = "-- +goose"
-	dbmatePragma = "-- migrate:"
+type (
+	// DBMateDir wraps migrate.LocalDir and provides a migrate.Scanner implementation able to understand files
+	// generated by the DBMateFormatter for migration directory replaying.
+	DBMateDir struct{ *migrate.LocalDir }
+	// DBMateFile wraps migrate.LocalFile with custom statements function.
+	DBMateFile struct{ *migrate.LocalFile }
 )
 
-var (
-	reGoosePragma  = regexp.MustCompile(regexp.QuoteMeta(goosePragma) + " Up|Down|StatementBegin|StatementEnd")
-	reDBMatePragma = regexp.MustCompile(dbmatePragma + "up|down")
-)
-
-func unexpectedPragmaErr(f migrate.File, line int, pragma string) error {
-	var tool string
-	switch f := f.(type) {
-	case *GooseFile:
-		tool = "goose"
-	case *DBMateFile:
-		tool = "dbmate"
-	default:
-		return fmt.Errorf("sql/migrate: unexpected migration file type '%T'", f)
-	}
-	return fmt.Errorf(
-		"sql/migrate: %s: %s:%d unexpected goosePragma '%s'",
-		tool, f.Name(), line, pragma,
-	)
-}
-
-// DBMateDir wraps migrate.LocalDir and provides implementation compatible with amacneil/dbmate.
-type DBMateDir struct{ *migrate.LocalDir }
-
 // NewDBMateDir returns a new DBMateDir.
 func NewDBMateDir(path string) (*DBMateDir, error) {
 	dir, err := migrate.NewLocalDir(path)
@@ -252,11 +231,6 @@ func NewDBMateDir(path string) (*DBMateDir, error) {
 	return &DBMateDir{dir}, nil
 }
 
-// DBMateFile wraps migrate.LocalFile with custom statements function.
-type DBMateFile struct {
-	*migrate.LocalFile
-}
-
 // Files implements Scanner.Files. It looks for all files with up.sql suffix and orders them by filename.
 func (d *DBMateDir) Files() ([]migrate.File, error) {
 	files, err := d.LocalDir.Files()
@@ -322,6 +296,100 @@ func (f *DBMateFile) Stmts() ([]string, error) {
 	return stmts, nil
 }
 
+type (
+	// FlywayDir wraps migrate.LocalDir and provides a migrate.Scanner implementation able to understand files
+	// generated by the FlywayFormatter for migration directory replaying.
+	FlywayDir struct{ *migrate.LocalDir }
+	// FlywayFile wraps migrate.LocalFile with custom statements function.
+	FlywayFile struct{ *migrate.LocalFile }
+)
+
+// NewFlywayDir returns a new FlywayDir.
+func NewFlywayDir(path string) (*FlywayDir, error) {
+	dir, err := migrate.NewLocalDir(path)
+	if err != nil {
+		return nil, err
+	}
+	return &FlywayDir{dir}, nil
+}
+
+// Files implements Scanner.Files. It looks for all files with .sql suffix.
+func (d *FlywayDir) Files() ([]migrate.File, error) {
+	var names []string
+	if err := fs.WalkDir(d, "", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if path != "" && d.IsDir() {
+			return fs.SkipDir
+		}
+		if filepath.Ext(d.Name()) == ".sql" && d.Name()[0] == 'V' {
+			names = append(names, path)
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	ret := make([]migrate.File, len(names))
+	for i, n := range names {
+		b, err := fs.ReadFile(d, n)
+		if err != nil {
+			return nil, fmt.Errorf("sql/migrate: read file %q: %w", n, err)
+		}
+		ret[i] = &FlywayFile{migrate.NewLocalFile(n, b)}
+	}
+	return ret, nil
+}
+
+// Desc implements File.Desc.
+func (f FlywayFile) Desc() string {
+	parts := strings.SplitN(f.Name(), "__", 2)
+	if len(parts) == 1 {
+		return ""
+	}
+	return strings.TrimSuffix(parts[1], ".sql")
+}
+
+// Version implements File.Version.
+func (f FlywayFile) Version() string {
+	return strings.TrimPrefix(strings.SplitN(strings.TrimSuffix(f.Name(), ".sql"), "__", 2)[0], "V")
+}
+
+// NewLiquibaseDir returns a new LiquibaseDir.
+func NewLiquibaseDir(path string) (*migrate.LocalDir, error) {
+	return migrate.NewLocalDir(path)
+}
+
+const (
+	none int = iota // state when parsing goose/dbmate sql file
+	up
+	begin
+	end
+	goosePragma  = "-- +goose"
+	dbmatePragma = "-- migrate:"
+)
+
+var (
+	reGoosePragma  = regexp.MustCompile(regexp.QuoteMeta(goosePragma) + " Up|Down|StatementBegin|StatementEnd")
+	reDBMatePragma = regexp.MustCompile(dbmatePragma + "up|down")
+)
+
+func unexpectedPragmaErr(f migrate.File, line int, pragma string) error {
+	var tool string
+	switch f := f.(type) {
+	case *GooseFile:
+		tool = "goose"
+	case *DBMateFile:
+		tool = "dbmate"
+	default:
+		return fmt.Errorf("sql/migrate: unexpected migration file type '%T'", f)
+	}
+	return fmt.Errorf(
+		"sql/migrate: %s: %s:%d unexpected goosePragma '%s'",
+		tool, f.Name(), line, pragma,
+	)
+}
+
 // funcs contains the template.FuncMap for the different formatters.
 var funcs = template.FuncMap{
 	"inc": func(x int) int { return x + 1 },


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index e54e2df8..09d3e382 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -352,43 +352,42 @@ func TestMigrate_New(t *testing.T) {
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
 	require.Equal(t, 3, countFiles(t, p))
 
-	_, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+p, "--force", "--dir-format", formatGolangMigrate)
-	require.NoError(t, err)
-	MigrateFlags.Force = false
+	p = t.TempDir()
 	s, err = runCmd(Root, "migrate", "new", "golang-migrate", "--dir", "file://"+p, "--dir-format", formatGolangMigrate)
 	require.Zero(t, s)
 	require.NoError(t, err)
 	require.FileExists(t, filepath.Join(p, v+"_golang-migrate.up.sql"))
 	require.FileExists(t, filepath.Join(p, v+"_golang-migrate.down.sql"))
-	require.Equal(t, 5, countFiles(t, p))
+	require.Equal(t, 3, countFiles(t, p))
 
-	s, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+p, "--force", "--dir-format", formatGoose)
-	require.NoError(t, err)
-	MigrateFlags.Force = false
+	p = t.TempDir()
 	s, err = runCmd(Root, "migrate", "new", "goose", "--dir", "file://"+p, "--dir-format", formatGoose)
 	require.Zero(t, s)
 	require.NoError(t, err)
 	require.FileExists(t, filepath.Join(p, v+"_goose.sql"))
-	require.Equal(t, 6, countFiles(t, p))
+	require.Equal(t, 2, countFiles(t, p))
 
+	p = t.TempDir()
 	s, err = runCmd(Root, "migrate", "new", "flyway", "--dir", "file://"+p, "--dir-format", formatFlyway)
 	require.Zero(t, s)
 	require.NoError(t, err)
 	require.FileExists(t, filepath.Join(p, fmt.Sprintf("V%s__%s.sql", v, formatFlyway)))
 	require.FileExists(t, filepath.Join(p, fmt.Sprintf("U%s__%s.sql", v, formatFlyway)))
-	require.Equal(t, 8, countFiles(t, p))
+	require.Equal(t, 3, countFiles(t, p))
 
+	p = t.TempDir()
 	s, err = runCmd(Root, "migrate", "new", "liquibase", "--dir", "file://"+p, "--dir-format", formatLiquibase)
 	require.Zero(t, s)
 	require.NoError(t, err)
 	require.FileExists(t, filepath.Join(p, v+"_liquibase.sql"))
-	require.Equal(t, 9, countFiles(t, p))
+	require.Equal(t, 2, countFiles(t, p))
 
+	p = t.TempDir()
 	s, err = runCmd(Root, "migrate", "new", "dbmate", "--dir", "file://"+p, "--dir-format", formatDbmate)
 	require.Zero(t, s)
 	require.NoError(t, err)
 	require.FileExists(t, filepath.Join(p, v+"_dbmate.sql"))
-	require.Equal(t, 10, countFiles(t, p))
+	require.Equal(t, 2, countFiles(t, p))
 
 	f := filepath.Join("testdata", "mysql", "new.sql")
 	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index 43f6374f..d9b97395 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -426,6 +426,8 @@ func (t *liteTest) cmdCmpMig(ts *testscript.TestScript, neg bool, args []string)
 	cmdCmpMig(ts, neg, args)
 }
 
+var reLiquibaseChangeset = regexp.MustCompile("--changeset atlas:[0-9]+-[0-9]+")
+
 // cmdCmpMig compares a migration file under migrations with a provided file.
 // If the first argument is a filename that does exist, that file is used for comparison.
 // If there is no file with that name, the argument is parsed to an integer n and the
@@ -443,7 +445,7 @@ func cmdCmpMig(ts *testscript.TestScript, _ bool, args []string) {
 	if _, err := os.Stat(ts.MkAbs(filepath.Join(ver, fname))); err == nil {
 		fname = filepath.Join(ver, fname)
 	}
-	expected := ts.ReadFile(fname)
+	expected := strings.TrimSpace(ts.ReadFile(fname))
 	dir, err := os.ReadDir(ts.MkAbs("migrations"))
 	ts.Check(err)
 	idx, err := strconv.Atoi(args[0])
@@ -454,13 +456,25 @@ func cmdCmpMig(ts *testscript.TestScript, _ bool, args []string) {
 			continue
 		}
 		if current == idx {
-			actual := ts.ReadFile(filepath.Join("migrations", f.Name()))
-			var sb strings.Builder
-			if strings.TrimSpace(actual) == strings.TrimSpace(expected) {
-				return
+			actual := strings.TrimSpace(ts.ReadFile(filepath.Join("migrations", f.Name())))
+			exLines, acLines := strings.Split(actual, "\n"), strings.Split(expected, "\n")
+			if len(exLines) != len(acLines) {
+				var sb strings.Builder
+				ts.Check(diff.Text(f.Name(), args[1], expected, actual, &sb))
+				ts.Fatalf(sb.String())
+			}
+			for i := range exLines {
+				// Skip liquibase changeset comments since they contain a timestamp.
+				if reLiquibaseChangeset.MatchString(acLines[i]) {
+					continue
+				}
+				if exLines[i] != acLines[i] {
+					var sb strings.Builder
+					ts.Check(diff.Text(f.Name(), args[1], expected, actual, &sb))
+					ts.Fatalf(sb.String())
+				}
 			}
-			ts.Check(diff.Text(f.Name(), args[1], expected, actual, &sb))
-			ts.Fatalf(sb.String())
+			return
 		}
 		current++
 	}
diff --git a/internal/integration/testdata/mysql/cli-migrate-diff-format.txt b/internal/integration/testdata/mysql/cli-migrate-diff-format.txt
index 36d431e5..6ed058a4 100644
--- a/internal/integration/testdata/mysql/cli-migrate-diff-format.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-diff-format.txt
@@ -28,6 +28,26 @@ cmpmig 0 dbmate/1.sql
 atlas migrate diff --dev-url URL --to file://2.hcl --dir-format dbmate second
 cmpmig 1 dbmate/2.sql
 
+rm migrations
+mkdir migrations
+
+atlas migrate diff --dev-url URL --to file://1.hcl --dir-format flyway first
+cmpmig 0 flyway/U1.sql
+cmpmig 1 flyway/V1.sql
+
+atlas migrate diff --dev-url URL --to file://2.hcl --dir-format flyway second
+cmpmig 1 flyway/U2.sql
+cmpmig 3 flyway/V2.sql
+
+rm migrations
+mkdir migrations
+
+atlas migrate diff --dev-url URL --to file://1.hcl --dir-format liquibase first
+cmpmig 0 liquibase/1.sql
+
+atlas migrate diff --dev-url URL --to file://2.hcl --dir-format liquibase second
+cmpmig 1 liquibase/2.sql
+
 -- golang-migrate/1.up.sql --
 -- create "users" table
 CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_general_ci;
@@ -80,6 +100,36 @@ ALTER TABLE `users` ADD COLUMN `email` varchar(100) NULL;
 -- reverse: modify "users" table
 ALTER TABLE `users` DROP COLUMN `email`;
 
+-- flyway/V1.sql --
+-- create "users" table
+CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_general_ci;
+
+-- flyway/U1.sql --
+-- reverse: create "users" table
+DROP TABLE `users`;
+
+-- flyway/V2.sql --
+-- modify "users" table
+ALTER TABLE `users` ADD COLUMN `email` varchar(100) NULL;
+
+-- flyway/U2.sql --
+-- reverse: modify "users" table
+ALTER TABLE `users` DROP COLUMN `email`;
+
+-- liquibase/1.sql --
+--liquibase formatted sql
+--changeset atlas:0-0
+--comment: create "users" table
+CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_general_ci;
+--rollback: DROP TABLE `users`;
+
+-- liquibase/2.sql --
+--liquibase formatted sql
+--changeset atlas:0-0
+--comment: modify "users" table
+ALTER TABLE `users` ADD COLUMN `email` varchar(100) NULL;
+--rollback: ALTER TABLE `users` DROP COLUMN `email`;
+
 -- 1.hcl --
 schema "script_cli_migrate_diff_format" {}
 
diff --git a/sql/sqltool/testdata/flyway/R__views.sql b/sql/sqltool/testdata/flyway/R__views.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/sql/sqltool/testdata/flyway/U1__initial.sql b/sql/sqltool/testdata/flyway/U1__initial.sql
new file mode 100644
index 00000000..1b52286d
--- /dev/null
+++ b/sql/sqltool/testdata/flyway/U1__initial.sql
@@ -0,0 +1,1 @@
+DROP TABLE tbl;
\ No newline at end of file
diff --git a/sql/sqltool/testdata/flyway/V1__initial.sql b/sql/sqltool/testdata/flyway/V1__initial.sql
new file mode 100644
index 00000000..309932f6
--- /dev/null
+++ b/sql/sqltool/testdata/flyway/V1__initial.sql
@@ -0,0 +1,16 @@
+-- migrate:up
+CREATE TABLE post
+(
+    id    int NOT NULL,
+    title text,
+    body  text,
+    PRIMARY KEY (id)
+);
+
+ALTER TABLE post ADD created_at TIMESTAMP NOT NULL;
+
+INSERT INTO post (title) VALUES (
+'This is
+my multiline
+
+value');
diff --git a/sql/sqltool/testdata/flyway/V2__second_migration.sql b/sql/sqltool/testdata/flyway/V2__second_migration.sql
new file mode 100644
index 00000000..f9fa036e
--- /dev/null
+++ b/sql/sqltool/testdata/flyway/V2__second_migration.sql
@@ -0,0 +1,9 @@
+
+
+
+-- migrate:up
+
+
+
+
+CREATE TABLE tbl_2 (col INT);
\ No newline at end of file
diff --git a/sql/sqltool/testdata/flyway/atlas.sum b/sql/sqltool/testdata/flyway/atlas.sum
new file mode 100644
index 00000000..5647210a
--- /dev/null
+++ b/sql/sqltool/testdata/flyway/atlas.sum
@@ -0,0 +1,5 @@
+h1:daCHVoEL4e6azCWER8r5olBEX88NWn5I73YbuGVOJ24=
+R__views.sql h1:BNxp8EUGUbTP09MnMkdyGiMfIZSQdZ1OoNfj5k1lM/s=
+U1__initial.sql h1:i5S73/9af0A4xLl3V7nNhpmbgUGQQpkpAHUnvNaEEC8=
+V1__initial.sql h1:7AOLWFgFgfSztLzeapDhUcDnV/0HieHnWajnXFXGl4U=
+V2__second_migration.sql h1:wXjN9rvi8u9I1HvN6qkG6PrYBXO7GAoW4ALSOE2I9vY=
diff --git a/sql/sqltool/testdata/liquibase/1_initial.sql b/sql/sqltool/testdata/liquibase/1_initial.sql
new file mode 100644
index 00000000..f7f669c9
--- /dev/null
+++ b/sql/sqltool/testdata/liquibase/1_initial.sql
@@ -0,0 +1,22 @@
+--liquibase formatted sql
+
+--changeset atlas:1-1
+CREATE TABLE post
+(
+    id    int NOT NULL,
+    title text,
+    body  text,
+    PRIMARY KEY (id)
+);
+--rollback: DROP TABLE post;
+
+--changeset atlas:1-2
+ALTER TABLE post ADD created_at TIMESTAMP NOT NULL;
+--rollback: ALTER TABLE post DROP created_at;
+
+--changeset atlas:1-3
+INSERT INTO post (title) VALUES (
+'This is
+my multiline
+
+value');
diff --git a/sql/sqltool/testdata/liquibase/2_second_migration.sql b/sql/sqltool/testdata/liquibase/2_second_migration.sql
new file mode 100644
index 00000000..90149119
--- /dev/null
+++ b/sql/sqltool/testdata/liquibase/2_second_migration.sql
@@ -0,0 +1,5 @@
+--liquibase formatted sql
+
+--changeset atlas:2-1
+CREATE TABLE tbl_2 (col INT);
+--rollback DROP TABLE tbl_2;
diff --git a/sql/sqltool/testdata/liquibase/atlas.sum b/sql/sqltool/testdata/liquibase/atlas.sum
new file mode 100644
index 00000000..3e8d88d8
--- /dev/null
+++ b/sql/sqltool/testdata/liquibase/atlas.sum
@@ -0,0 +1,3 @@
+h1:6I2+EO1XlJwbhoffBnOS8+i/IP3jlrGgI2DIJvRpcPs=
+1_initial.sql h1:WhCKKuYeV1R6mHBSeM+MNwdlyKFPfSD8I55+pxFBiOA=
+2_second_migration.sql h1:dyEY5EIahGAWgcDS3k5CcD7JGKSyaXL6GeG4AvyZObY=
diff --git a/sql/sqltool/tool_test.go b/sql/sqltool/tool_test.go
index c481ec72..0f9ea99d 100644
--- a/sql/sqltool/tool_test.go
+++ b/sql/sqltool/tool_test.go
@@ -100,7 +100,7 @@ CREATE TABLE t2(c int);
 		},
 		{
 			"amacneil/dbmate",
-			sqltool.DbmateFormatter,
+			sqltool.DBMateFormatter,
 			map[string]string{
 				v + "_tooling-plan.sql": `-- migrate:up
 -- create table t1
@@ -169,6 +169,42 @@ func TestScanners(t *testing.T) {
 				{"CREATE\nOR REPLACE FUNCTION histories_partition_creation( DATE, DATE )\nreturns void AS $$\nDECLARE\ncreate_query text;\nBEGIN\nFOR create_query IN\nSELECT 'CREATE TABLE IF NOT EXISTS histories_'\n           || TO_CHAR(d, 'YYYY_MM')\n           || ' ( CHECK( created_at >= timestamp '''\n           || TO_CHAR(d, 'YYYY-MM-DD 00:00:00')\n           || ''' AND created_at < timestamp '''\n           || TO_CHAR(d + INTERVAL '1 month', 'YYYY-MM-DD 00:00:00')\n           || ''' ) ) inherits ( histories );'\nFROM generate_series($1, $2, '1 month') AS d LOOP\n    EXECUTE create_query;\nEND LOOP;  -- LOOP END\nEND;         -- FUNCTION END\n$$\nlanguage plpgsql;"},
 			},
 		},
+		{
+			name: "flyway",
+			dir: func() migrate.Dir {
+				d, err := sqltool.NewFlywayDir("testdata/flyway")
+				require.NoError(t, err)
+				return d
+			}(),
+			versions:     []string{"1", "2"},
+			descriptions: []string{"initial", "second_migration"},
+			stmts: [][]string{
+				{
+					"CREATE TABLE post\n(\n    id    int NOT NULL,\n    title text,\n    body  text,\n    PRIMARY KEY (id)\n);",
+					"ALTER TABLE post ADD created_at TIMESTAMP NOT NULL;",
+					"INSERT INTO post (title) VALUES (\n'This is\nmy multiline\n\nvalue');",
+				},
+				{"CREATE TABLE tbl_2 (col INT);"},
+			},
+		},
+		{
+			name: "liquibase",
+			dir: func() migrate.Dir {
+				d, err := sqltool.NewLiquibaseDir("testdata/liquibase")
+				require.NoError(t, err)
+				return d
+			}(),
+			versions:     []string{"1", "2"},
+			descriptions: []string{"initial", "second_migration"},
+			stmts: [][]string{
+				{
+					"CREATE TABLE post\n(\n    id    int NOT NULL,\n    title text,\n    body  text,\n    PRIMARY KEY (id)\n);",
+					"ALTER TABLE post ADD created_at TIMESTAMP NOT NULL;",
+					"INSERT INTO post (title) VALUES (\n'This is\nmy multiline\n\nvalue');",
+				},
+				{"CREATE TABLE tbl_2 (col INT);"},
+			},
+		},
 		{
 			name: "dbmate",
 			dir: func() migrate.Dir {