Repository: ariga/atlas
Pull request #1058 (2022-08-16 11:30:57)

sql/sqltool: support replaying of flyway migration files
Waits on #1057 


Patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index 7bcf8daf..74b81106 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -738,6 +738,7 @@ 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:
 	case formatDbmate:
 		f = func() (migrate.Dir, error) { return sqltool.NewDBMateDir(parts[1]) }
@@ -945,7 +946,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/md/integrations/github-actions.mdx b/doc/md/integrations/github-actions.mdx
new file mode 100644
index 00000000..59324c26
--- /dev/null
+++ b/doc/md/integrations/github-actions.mdx
@@ -0,0 +1,219 @@
+---
+title: GitHub Actions
+id: github-actions
+slug: /integrations/github-actions
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+[GitHub Actions](https://github.com/features/actions) is a popular CI/CD 
+product from GitHub. With GitHub Actions, users can easily define workflows
+that are triggered in various lifecycle events related to a Git repository.
+For example, many teams configure GitHub actions to run all unit tests in
+a repository on each change that is committed to a repository. 
+
+One of the powerful features of GitHub Actions is its extensibility: it is
+very easy to package a piece of functionality as a module (called an "action")
+that can later be re-used by many projects. 
+
+## CI for database schema changes
+
+Teams using GitHub that wish to ensure all changes to their database schema are safe
+can use the [`atlas-action`](https://github.com/ariga/atlas-action) GitHub Action.
+
+This action is used for [linting migration directories](/versioned/lint)
+using the `atlas migrate lint` command. This command  validates and analyzes the contents
+of migration directories and generates insights and diagnostics on the selected changes:
+
+* Ensure the migration history can be replayed from any point at time.
+* Protect from unexpected history changes when concurrent migrations are written to the migration directory by
+  multiple team members. Read more about the consistency checks in the section below.
+* Detect whether destructive or irreversible changes have been made or whether they are dependent on tables'  
+  contents and can cause a migration failure.
+
+### Supported directory formats
+
+This action supports analyzing migration directories in formats
+accepted by different schema migration tools:
+* [Atlas](https://atlasgo.io)
+* [golang-migrate](https://github.com/golang-migrate/migrate)
+* [goose](https://github.com/pressly/goose)
+* [dbmate](https://github.com/amacneil/dbmate)
+
+### Usage
+
+Add `.github/workflows/atlas-ci.yaml` to your repo with the following contents:
+<Tabs
+defaultValue="mysql"
+values={[
+    {label: 'MySQL', value: 'mysql'},
+    {label: 'MariaDB', value: 'maria'},
+    {label: 'PostgreSQL', value: 'postgres'},
+]}>
+<TabItem value="mysql">
+
+```yaml
+name: Atlas CI
+on:
+  # Run whenever code is changed in the master branch,
+  # change this to your root branch.
+  push:
+    branches:
+      - master
+  # Run on PRs where something changed under the `path/to/migration/dir/` directory.
+  pull_request:
+    paths:
+      - 'path/to/migration/dir/*'
+jobs:
+  lint:
+    services:
+      # Spin up a mysql:8.0.29 container to be used as the dev-database for analysis.
+      mysql:
+        image: mysql:8.0.29
+        env:
+          MYSQL_ROOT_PASSWORD: pass
+          MYSQL_DATABASE: test
+        ports:
+          - "3307:3306"
+        options: >-
+          --health-cmd "mysqladmin ping -ppass"
+          --health-interval 10s
+          --health-start-period 10s
+          --health-timeout 5s
+          --health-retries 10
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3.0.1
+        with:
+          fetch-depth: 0 # Mandatory unless "latest" is set below.
+      - uses: ariga/atlas-action@master
+        with:
+          dir: path/to/migrations
+          dir-format: golang-migrate # Or: atlas, goose, dbmate
+          dev-url: mysql://root:pass@localhost:3307/test
+```
+
+</TabItem>
+<TabItem value="maria">
+
+```yaml
+name: Atlas CI
+on:
+  # Run whenever code is changed in the master branch,
+  # change this to your root branch.
+  push:
+    branches:
+      - master
+  # Run on PRs where something changed under the `path/to/migration/dir/` directory.
+  pull_request:
+    paths:
+      - 'path/to/migration/dir/*'
+jobs:
+  lint:
+    services:
+      # Spin up a maria:10.7 container to be used as the dev-database for analysis.
+      maria107:
+        image: mariadb:10.7
+        env:
+          MYSQL_DATABASE: test
+          MYSQL_ROOT_PASSWORD: pass
+        ports:
+          - 4306:3306
+        options: >-
+          --health-cmd "mysqladmin ping -ppass"
+          --health-interval 10s
+          --health-start-period 10s
+          --health-timeout 5s
+          --health-retries 10
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3.0.1
+        with:
+          fetch-depth: 0 # Mandatory unless "latest" is set below.
+      - uses: ariga/atlas-action@master
+        with:
+          dir: path/to/migrations
+          dir-format: golang-migrate # Or: atlas, goose, dbmate
+          dev-url: maria://root:pass@localhost:4306/test
+```
+
+</TabItem>
+<TabItem value="postgres">
+
+```yaml
+name: Atlas CI
+on:
+  # Run whenever code is changed in the master branch,
+  # change this to your root branch.
+  push:
+    branches:
+      - master
+  # Run on PRs where something changed under the `path/to/migration/dir/` directory.
+  pull_request:
+    paths:
+      - 'path/to/migration/dir/*'
+jobs:
+  lint:
+    services:
+      # Spin up a postgres:10 container to be used as the dev-database for analysis.
+      postgres10:
+        image: postgres:10
+        env:
+          POSTGRES_DB: test
+          POSTGRES_PASSWORD: pass
+        ports:
+          - 5430:5432
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3.0.1
+        with:
+          fetch-depth: 0 # Mandatory unless "latest" is set below.
+      - uses: ariga/atlas-action@master
+        with:
+          dir: path/to/migrations
+          dir-format: golang-migrate # Or: atlas, goose, dbmate
+          dev-url: postgres://postgres:pass@localhost:5430/test?sslmode=disable
+```
+
+</TabItem>
+</Tabs>
+
+### Configuration
+
+Configure the action by passing input parameters in the `with:` block.
+
+#### `dir`
+
+Sets the directory that contains the migration scripts to analyze.
+
+#### `dir-format`
+
+Sets the format of the migration directory. Options: `atlas` (default),
+`golang-migrate`, `goose` or `dbmate`. Coming soon: `flyway`, `liquibase`.
+
+#### `dev-url`
+
+The URL of the dev-database to use for analysis.
+
+* Read about [Atlas URL formats](/concepts/url)
+* Read about [dev-databases](/concepts/dev-database)
+
+#### `latest`
+
+Use the `latest` mode to decide which files to analyze. By default,
+Atlas will use `git-base` to analyze any files that are present in the
+diff between the base branch and the current.
+
+Unless this option is set, the base branch (`master`/`main`/etc) must
+be checked out locally or you will see an error such as:
+```
+Atlas failed with code 1: Error: git diff: exit status 128
+```
+
+The full list of input options can be found in [action.yml](https://github.com/ariga/atlas-action/blob/master/action.yml).
\ No newline at end of file
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 9c6b7788..69aae998 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -66,6 +66,7 @@ module.exports = {
             label: 'Integrations',
             collapsed: false,
             items: [
+                {type: 'doc', id: 'integrations/github-actions', label: 'GitHub Actions'},
                 {type: 'doc', id: 'integrations/terraform-provider', label: 'Terraform Provider'},
                 {type: 'doc', id: 'integrations/go-api', label: 'Go API'},
             ]
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..c2c645a6 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,95 @@ 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")
+}
+
+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/testdata/mysql/cli-migrate-diff-format.txt b/internal/integration/testdata/mysql/cli-migrate-diff-format.txt
index 36d431e5..649a0747 100644
--- a/internal/integration/testdata/mysql/cli-migrate-diff-format.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-diff-format.txt
@@ -28,6 +28,17 @@ 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
+
 -- 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 +91,22 @@ 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`;
+
 -- 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/tool_test.go b/sql/sqltool/tool_test.go
index c481ec72..5cc19e3b 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,24 @@ 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: "dbmate",
 			dir: func() migrate.Dir {