Repository: ariga/atlas
Pull request #986 (2022-07-26 16:44:49)

sql/migrate: initial work on baseline migrations
The next steps are filtering them on apply and supporting additional flag --baseline=version in `migrate apply`. 


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 7e736406..0c3559f6 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -8,7 +8,7 @@ issues:
     - path: _test\.go
       linters:
         - gosec
-    - path: sql/migrate/migrate.go
+    - path: sql/migrate/dir.go
       linters:
         - gosec
 
diff --git a/cmd/atlas/internal/migrate/migrate.go b/cmd/atlas/internal/migrate/migrate.go
index 093c20f4..ffeccad8 100644
--- a/cmd/atlas/internal/migrate/migrate.go
+++ b/cmd/atlas/internal/migrate/migrate.go
@@ -15,7 +15,6 @@ import (
 	"ariga.io/atlas/sql/sqlclient"
 	"entgo.io/ent/dialect"
 	"entgo.io/ent/dialect/sql"
-	entschema "entgo.io/ent/dialect/sql/schema"
 )
 
 const DefaultRevisionSchema = "atlas_schema_revisions"
@@ -65,7 +64,7 @@ func (r *EntRevisions) Init(ctx context.Context) error {
 	// If the driver does not support changing the schema (most likely SQLite) use the existing connection.
 	if err != nil && errors.Is(err, sqlclient.ErrUnsupported) {
 		r.ec = ent.NewClient(ent.Driver(sql.OpenDB(r.ac.Name, r.ac.DB)))
-		return r.ec.Schema.Create(ctx, entschema.WithAtlas(true))
+		return r.ec.Schema.Create(ctx)
 	}
 	// Driver does support changing schemas. Make sure the schema does exist before proceeding.
 	_, err = r.ac.InspectSchema(ctx, r.schema, &schema.InspectOptions{Mode: schema.InspectSchemas})
@@ -88,7 +87,7 @@ func (r *EntRevisions) Init(ctx context.Context) error {
 	}
 	r.ac.AddClosers(r.sc)
 	r.ec = ent.NewClient(ent.Driver(sql.OpenDB(r.sc.Name, r.sc.DB)))
-	return r.ec.Schema.Create(ctx, entschema.WithAtlas(true))
+	return r.ec.Schema.Create(ctx)
 }
 
 // ReadRevision reads a revision from the revisions table.
diff --git a/sql/migrate/dir.go b/sql/migrate/dir.go
new file mode 100644
index 00000000..64059d96
--- /dev/null
+++ b/sql/migrate/dir.go
@@ -0,0 +1,420 @@
+// 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 migrate
+
+import (
+	"bufio"
+	"bytes"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"text/template"
+	"time"
+)
+
+type (
+	// Dir wraps the functionality used to interact with a migration directory.
+	Dir interface {
+		fs.FS
+		// WriteFile writes the data to the named file.
+		WriteFile(string, []byte) error
+
+		// Files returns a set of files stored in this Dir to be executed on a database.
+		Files() ([]File, error)
+	}
+
+	// Formatter wraps the Format method.
+	Formatter interface {
+		// Format formats the given Plan into one or more migration files.
+		Format(*Plan) ([]File, error)
+	}
+
+	// File represents a single migration file.
+	File interface {
+		// Name returns the name of the migration file.
+		Name() string
+		// Desc returns the description of the migration File.
+		Desc() string
+		// Version returns the version of the migration File.
+		Version() string
+		// Baseline indicates if this file is marked as a baseline migration.
+		Baseline() bool
+		// Bytes returns the read content of the file.
+		Bytes() []byte
+		// Stmts returns the set of SQL statements this file holds.
+		Stmts() ([]string, error)
+	}
+)
+
+// LocalDir implements Dir for a local migration
+// directory with default Atlas formatting.
+type LocalDir struct {
+	path string
+}
+
+var _ Dir = (*LocalDir)(nil)
+
+// NewLocalDir returns a new the Dir used by a Planner to work on the given local path.
+func NewLocalDir(path string) (*LocalDir, error) {
+	fi, err := os.Stat(path)
+	if err != nil {
+		return nil, fmt.Errorf("sql/migrate: %w", err)
+	}
+	if !fi.IsDir() {
+		return nil, fmt.Errorf("sql/migrate: %q is not a dir", path)
+	}
+	return &LocalDir{path: path}, nil
+}
+
+// Path returns the local path used for opening this dir.
+func (d *LocalDir) Path() string {
+	return d.path
+}
+
+// Open implements fs.FS.
+func (d *LocalDir) Open(name string) (fs.File, error) {
+	return os.Open(filepath.Join(d.path, name))
+}
+
+// WriteFile implements Dir.WriteFile.
+func (d *LocalDir) WriteFile(name string, b []byte) error {
+	return os.WriteFile(filepath.Join(d.path, name), b, 0644)
+}
+
+// Files implements Dir.Files. It looks for all files with .sql suffix and orders them by filename.
+func (d *LocalDir) Files() ([]File, error) {
+	names, err := fs.Glob(d, "*.sql")
+	if err != nil {
+		return nil, err
+	}
+	// Sort files lexicographically.
+	sort.Slice(names, func(i, j int) bool {
+		return names[i] < names[j]
+	})
+	ret := make([]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] = &LocalFile{n: n, b: b}
+	}
+	return ret, nil
+}
+
+// LocalFile is used by LocalDir to implement the Scanner interface.
+type LocalFile struct {
+	n string
+	b []byte
+}
+
+var _ File = (*LocalFile)(nil)
+
+// NewLocalFile returns a new local file.
+func NewLocalFile(name string, data []byte) *LocalFile {
+	return &LocalFile{n: name, b: data}
+}
+
+// Name implements File.Name.
+func (f LocalFile) Name() string {
+	return f.n
+}
+
+// Baseline reports if this file marked as a baseline migration.
+func (f LocalFile) Baseline() bool {
+	_, ok := directive(string(f.b), directiveBaseline, directivePrefixSQL)
+	return ok
+}
+
+// Desc implements File.Desc.
+func (f LocalFile) Desc() string {
+	parts := strings.SplitN(f.n, "_", 2)
+	if len(parts) == 1 {
+		return ""
+	}
+	return strings.TrimSuffix(parts[1], ".sql")
+}
+
+// Version implements File.Version.
+func (f LocalFile) Version() string {
+	return strings.SplitN(f.n, "_", 2)[0]
+}
+
+// Stmts implements Scanner.Stmts. It reads migration file line-by-line and expects a statement to be one line only.
+func (f LocalFile) Stmts() ([]string, error) {
+	return stmts(string(f.b))
+}
+
+// Bytes returns local file data.
+func (f LocalFile) Bytes() []byte {
+	return f.b
+}
+
+var (
+	// templateFuncs contains the template.FuncMap for the DefaultFormatter.
+	templateFuncs = template.FuncMap{"now": func() string { return time.Now().UTC().Format("20060102150405") }}
+	// DefaultFormatter is a default implementation for Formatter.
+	DefaultFormatter = &TemplateFormatter{
+		templates: []struct{ N, C *template.Template }{
+			{
+				N: template.Must(template.New("").Funcs(templateFuncs).Parse(
+					"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
+				)),
+				C: template.Must(template.New("").Funcs(templateFuncs).Parse(
+					`{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}`,
+				)),
+			},
+		},
+	}
+)
+
+// TemplateFormatter implements Formatter by using templates.
+type TemplateFormatter struct {
+	templates []struct{ N, C *template.Template }
+}
+
+// NewTemplateFormatter creates a new Formatter working with the given templates.
+//
+//	migrate.NewTemplateFormatter(
+//		template.Must(template.New("").Parse("{{now.Unix}}{{.Name}}.sql")),                 // name template
+//		template.Must(template.New("").Parse("{{range .Changes}}{{println .Cmd}}{{end}}")), // content template
+//	)
+//
+func NewTemplateFormatter(templates ...*template.Template) (*TemplateFormatter, error) {
+	if n := len(templates); n == 0 || n%2 == 1 {
+		return nil, fmt.Errorf("zero or odd number of templates given")
+	}
+	t := new(TemplateFormatter)
+	for i := 0; i < len(templates); i += 2 {
+		t.templates = append(t.templates, struct{ N, C *template.Template }{templates[i], templates[i+1]})
+	}
+	return t, nil
+}
+
+// Format implements the Formatter interface.
+func (t *TemplateFormatter) Format(plan *Plan) ([]File, error) {
+	files := make([]File, 0, len(t.templates))
+	for _, tpl := range t.templates {
+		var n, b bytes.Buffer
+		if err := tpl.N.Execute(&n, plan); err != nil {
+			return nil, err
+		}
+		if err := tpl.C.Execute(&b, plan); err != nil {
+			return nil, err
+		}
+		files = append(files, &LocalFile{
+			n: n.String(),
+			b: b.Bytes(),
+		})
+	}
+	return files, nil
+}
+
+// HashFileName of the migration directory integrity sum file.
+const HashFileName = "atlas.sum"
+
+// HashFile represents the integrity sum file of the migration dir.
+type HashFile []struct{ N, H string }
+
+// HashSum reads the whole dir, sorts the files by name and creates a HashSum from its contents.
+// If a files first line matches the above regex, it will be excluded from hash creation.
+func HashSum(dir Dir) (HashFile, error) {
+	var (
+		hs HashFile
+		h  = sha256.New()
+	)
+	err := fs.WalkDir(dir, "", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		// If this is the integrity sum file do not include it into the sum.
+		if filepath.Base(path) == HashFileName {
+			return nil
+		}
+		if !d.IsDir() {
+			f, err := dir.Open(path)
+			if err != nil {
+				return err
+			}
+			defer f.Close()
+			if _, err := h.Write([]byte(path)); err != nil {
+				return err
+			}
+			c, err := ioutil.ReadAll(f)
+			if err != nil {
+				return err
+			}
+			// Check if this file contains an "atlas:sum" directive and if so, act to it.
+			if mode, ok := directive(string(c), directiveSum); ok && mode == sumModeIgnore {
+				return nil
+			}
+			if _, err := h.Write(c); err != nil {
+				return err
+			}
+			hs = append(hs, struct{ N, H string }{path, base64.StdEncoding.EncodeToString(h.Sum(nil))})
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return hs, nil
+}
+
+// WriteSumFile writes the given HashFile to the Dir. If the file does not exist, it is created.
+func WriteSumFile(dir Dir, sum HashFile) error {
+	b, err := sum.MarshalText()
+	if err != nil {
+		return err
+	}
+	return dir.WriteFile(HashFileName, b)
+}
+
+// Sum returns the checksum of the represented hash file.
+func (f HashFile) Sum() string {
+	sha := sha256.New()
+	for _, f := range f {
+		sha.Write([]byte(f.N))
+		sha.Write([]byte(f.H))
+	}
+	return base64.StdEncoding.EncodeToString(sha.Sum(nil))
+}
+
+// MarshalText implements encoding.TextMarshaler.
+func (f HashFile) MarshalText() ([]byte, error) {
+	buf := new(bytes.Buffer)
+	for _, f := range f {
+		fmt.Fprintf(buf, "%s h1:%s\n", f.N, f.H)
+	}
+	return []byte(fmt.Sprintf("h1:%s\n%s", f.Sum(), buf)), nil
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (f *HashFile) UnmarshalText(b []byte) error {
+	sc := bufio.NewScanner(bytes.NewReader(b))
+	// The first line contains the sum.
+	sc.Scan()
+	sum := strings.TrimPrefix(sc.Text(), "h1:")
+	for sc.Scan() {
+		li := strings.SplitN(sc.Text(), "h1:", 2)
+		if len(li) != 2 {
+			return ErrChecksumFormat
+		}
+		*f = append(*f, struct{ N, H string }{strings.TrimSpace(li[0]), li[1]})
+	}
+	if sum != f.Sum() {
+		return ErrChecksumMismatch
+	}
+	return sc.Err()
+}
+
+func (f HashFile) sumByName(n string) (string, error) {
+	for _, f := range f {
+		if f.N == n {
+			return f.H, nil
+		}
+	}
+	return "", errors.New("checksum not found")
+}
+
+var (
+	// ErrChecksumFormat is returned from Validate if the sum files format is invalid.
+	ErrChecksumFormat = errors.New("checksum file format invalid")
+	// ErrChecksumMismatch is returned from Validate if the hash sums don't match.
+	ErrChecksumMismatch = errors.New("checksum mismatch")
+	// ErrChecksumNotFound is returned from Validate if the hash file does not exist.
+	ErrChecksumNotFound = errors.New("checksum file not found")
+)
+
+// Validate checks if the migration dir is in sync with its sum file.
+// If they don't match ErrChecksumMismatch is returned.
+func Validate(dir Dir) error {
+	fh, err := readHashFile(dir)
+	if errors.Is(err, fs.ErrNotExist) {
+		// If there are no migration files yet this is okay.
+		files, err := fs.ReadDir(dir, "/")
+		if err != nil || len(files) > 0 {
+			return ErrChecksumNotFound
+		}
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+	mh, err := HashSum(dir)
+	if err != nil {
+		return err
+	}
+	if fh.Sum() != mh.Sum() {
+		return ErrChecksumMismatch
+	}
+	return nil
+}
+
+// FilesLastIndex returns the index of the last file
+// satisfying f(i), or -1 if none do.
+func FilesLastIndex(files []File, f func(File) bool) int {
+	for i := len(files) - 1; i >= 0; i-- {
+		if f(files[i]) {
+			return i
+		}
+	}
+	return -1
+}
+
+const (
+	directiveSum  = "sum"
+	sumModeIgnore = "ignore"
+	// atlas:baseline directive.
+	directiveBaseline = "baseline"
+	// atlas:delimiter directive.
+	directiveDelimiter = "delimiter"
+	directivePrefixSQL = "-- "
+)
+
+var reDirective = regexp.MustCompile(`^([ -~]*)atlas:(\w+)(?: +([ -~]*))*`)
+
+// directive searches in the content a line that matches a directive
+// with the given prefix and name. For example:
+//
+//	directive(c, "delimiter", "-- ")	// '-- atlas:delimiter.*'
+//	directive(c, "sum", "")				// 'atlas:sum.*'
+//	directive(c, "sum")					// '.*atlas:sum'
+//
+func directive(content, name string, prefix ...string) (string, bool) {
+	m := reDirective.FindStringSubmatch(content)
+	// In case the prefix was provided ensures it is matched.
+	if len(m) == 4 && m[2] == name && (len(prefix) == 0 || prefix[0] == m[1]) {
+		return m[3], true
+	}
+	return "", false
+}
+
+// readHashFile reads the HashFile from the given Dir.
+func readHashFile(dir Dir) (HashFile, error) {
+	f, err := dir.Open(HashFileName)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return nil, err
+	}
+	var fh HashFile
+	if err := fh.UnmarshalText(b); err != nil {
+		return nil, err
+	}
+	return fh, nil
+}
diff --git a/sql/migrate/lex.go b/sql/migrate/lex.go
index a8dfdd97..17f81a8b 100644
--- a/sql/migrate/lex.go
+++ b/sql/migrate/lex.go
@@ -42,28 +42,23 @@ type lex struct {
 }
 
 const (
-	eos          = -1
-	delimiter    = ";"
-	delimComment = "-- atlas:delimiter"
+	eos       = -1
+	delimiter = ";"
 )
 
 func newLex(input string) (*lex, error) {
 	delim := delimiter
-	input = strings.TrimSpace(input)
-	if strings.HasPrefix(input, delimComment) {
-		input = input[len(delimComment):]
-		if !strings.HasPrefix(input, " ") {
-			return nil, fmt.Errorf("expect space after %q, got: %s", delimComment, input[:1])
-		}
-		input = strings.TrimSpace(input)
-		i := strings.Index(input, "\n")
-		if i == -1 {
+	if d, ok := directive(input, directiveDelimiter, directivePrefixSQL); ok {
+		if d == "" {
 			return nil, errors.New("empty delimiter")
 		}
+		parts := strings.SplitN(input, "\n", 2)
+		if len(parts) == 1 {
+			return nil, fmt.Errorf("not input found after delimiter %q", d)
+		}
 		// Unescape delimiters. e.g. "\\n" => "\n".
-		delim = strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t").
-			Replace(input[:i])
-		input = strings.TrimSpace(input[i+1:])
+		delim = strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t").Replace(d)
+		input = parts[1]
 	}
 	l := &lex{input: input, delim: delim}
 	return l, nil
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 8d28e98c..1763c1be 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -5,22 +5,13 @@
 package migrate
 
 import (
-	"bufio"
-	"bytes"
 	"context"
 	"crypto/sha256"
 	"encoding/base64"
 	"errors"
 	"fmt"
-	"io"
-	"io/fs"
-	"io/ioutil"
 	"os"
-	"path/filepath"
-	"regexp"
-	"sort"
 	"strings"
-	"text/template"
 	"time"
 
 	"ariga.io/atlas/sql/schema"
@@ -140,36 +131,6 @@ func Conn(drv Driver, opts *schema.InspectRealmOption) StateReader {
 }
 
 type (
-	// Dir wraps the functionality used to interact with a migration directory.
-	Dir interface {
-		fs.FS
-		// WriteFile writes the data to the named file.
-		WriteFile(string, []byte) error
-
-		// Files returns a set of files stored in this Dir to be executed on a database.
-		Files() ([]File, error)
-	}
-
-	// Formatter wraps the Format method.
-	Formatter interface {
-		// Format formats the given Plan into one or more migration files.
-		Format(*Plan) ([]File, error)
-	}
-
-	// File represents a single migration file.
-	File interface {
-		// Name returns the name of the migration file.
-		Name() string
-		// Desc returns the description of the migration File.
-		Desc() string
-		// Version returns the version of the migration File.
-		Version() string
-		// Bytes returns the read content of the file.
-		Bytes() []byte
-		// Stmts returns the set of SQL statements this file holds.
-		Stmts() ([]string, error)
-	}
-
 	// A RevisionReadWriter reads and writes information about a Revisions to a persistent storage.
 	// If the implementation happens provide an "Init() error" method,
 	// it will be called once before attempting to read or write.
@@ -748,333 +709,6 @@ func LogIntro(l Logger, revs Revisions, files []File) error {
 	return nil
 }
 
-// LocalDir implements Dir for a local path. It implements the Scanner interface compatible with
-// migration files generated by the DefaultFormatter.
-type LocalDir struct {
-	path string
-}
-
-var _ Dir = (*LocalDir)(nil)
-
-// NewLocalDir returns a new the Dir used by a Planner to work on the given local path.
-func NewLocalDir(path string) (*LocalDir, error) {
-	fi, err := os.Stat(path)
-	if err != nil {
-		return nil, fmt.Errorf("sql/migrate: %w", err)
-	}
-	if !fi.IsDir() {
-		return nil, fmt.Errorf("sql/migrate: %q is not a dir", path)
-	}
-	return &LocalDir{path: path}, nil
-}
-
-// Path returns the local path used for opening this dir.
-func (d *LocalDir) Path() string {
-	return d.path
-}
-
-// Open implements fs.FS.
-func (d *LocalDir) Open(name string) (fs.File, error) {
-	return os.Open(filepath.Join(d.path, name))
-}
-
-// WriteFile implements Dir.WriteFile.
-func (d *LocalDir) WriteFile(name string, b []byte) error {
-	return os.WriteFile(filepath.Join(d.path, name), b, 0644)
-}
-
-// Files implements Scanner.Files. It looks for all files with .sql suffix and orders them by filename-
-func (d *LocalDir) Files() ([]File, error) {
-	names, err := fs.Glob(d, "*.sql")
-	if err != nil {
-		return nil, err
-	}
-	// Sort files lexicographically.
-	sort.Slice(names, func(i, j int) bool {
-		return names[i] < names[j]
-	})
-	ret := make([]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] = &LocalFile{n: n, b: b}
-	}
-	return ret, nil
-}
-
-// LocalFile is used by LocalDir to implement the Scanner interface.
-type LocalFile struct {
-	n string
-	b []byte
-}
-
-var _ File = (*LocalFile)(nil)
-
-// NewLocalFile returns a new local file.
-func NewLocalFile(name string, data []byte) *LocalFile {
-	return &LocalFile{n: name, b: data}
-}
-
-// Name implements File.Name.
-func (f LocalFile) Name() string {
-	return f.n
-}
-
-// Desc implements File.Desc.
-func (f LocalFile) Desc() string {
-	parts := strings.SplitN(f.n, "_", 2)
-	if len(parts) == 1 {
-		return ""
-	}
-	return strings.TrimSuffix(parts[1], ".sql")
-}
-
-// Version implements File.Version.
-func (f LocalFile) Version() string {
-	return strings.SplitN(f.n, "_", 2)[0]
-}
-
-// Stmts implements Scanner.Stmts. It reads migration file line-by-line and expects a statement to be one line only.
-func (f LocalFile) Stmts() ([]string, error) {
-	return stmts(string(f.b))
-}
-
-// Bytes returns local file data.
-func (f LocalFile) Bytes() []byte {
-	return f.b
-}
-
-var (
-	// templateFuncs contains the template.FuncMap for the DefaultFormatter.
-	templateFuncs = template.FuncMap{"now": func() string { return time.Now().UTC().Format("20060102150405") }}
-	// DefaultFormatter is a default implementation for Formatter.
-	DefaultFormatter = &TemplateFormatter{
-		templates: []struct{ N, C *template.Template }{
-			{
-				N: template.Must(template.New("").Funcs(templateFuncs).Parse(
-					"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
-				)),
-				C: template.Must(template.New("").Funcs(templateFuncs).Parse(
-					`{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}`,
-				)),
-			},
-		},
-	}
-)
-
-// TemplateFormatter implements Formatter by using templates.
-type TemplateFormatter struct {
-	templates []struct{ N, C *template.Template }
-}
-
-// NewTemplateFormatter creates a new Formatter working with the given templates.
-//
-//	migrate.NewTemplateFormatter(
-//		template.Must(template.New("").Parse("{{now.Unix}}{{.Name}}.sql")),                 // name template
-//		template.Must(template.New("").Parse("{{range .Changes}}{{println .Cmd}}{{end}}")), // content template
-//	)
-//
-func NewTemplateFormatter(templates ...*template.Template) (*TemplateFormatter, error) {
-	if n := len(templates); n == 0 || n%2 == 1 {
-		return nil, fmt.Errorf("zero or odd number of templates given")
-	}
-	t := new(TemplateFormatter)
-	for i := 0; i < len(templates); i += 2 {
-		t.templates = append(t.templates, struct{ N, C *template.Template }{templates[i], templates[i+1]})
-	}
-	return t, nil
-}
-
-// Format implements the Formatter interface.
-func (t *TemplateFormatter) Format(plan *Plan) ([]File, error) {
-	files := make([]File, 0, len(t.templates))
-	for _, tpl := range t.templates {
-		var n, b bytes.Buffer
-		if err := tpl.N.Execute(&n, plan); err != nil {
-			return nil, err
-		}
-		if err := tpl.C.Execute(&b, plan); err != nil {
-			return nil, err
-		}
-		files = append(files, &LocalFile{
-			n: n.String(),
-			b: b.Bytes(),
-		})
-	}
-	return files, nil
-}
-
-const (
-	// HashFileName of the migration directory integrity sum file.
-	HashFileName = "atlas.sum"
-	// Directive used it a file should be excluded by the sum computation.
-	directiveNone = "ignore"
-)
-
-// Determine if an "atlas:sum" directive is used on the file.
-var reSumDirective = regexp.MustCompile(`atlas:sum ([a-zA-Z-]*)`)
-
-// HashFile represents the integrity sum file of the migration dir.
-type HashFile []struct{ N, H string }
-
-// HashSum reads the whole dir, sorts the files by name and creates a HashSum from its contents.
-// If a files first line matches the above regex, it will be excluded from hash creation.
-func HashSum(dir Dir) (HashFile, error) {
-	var (
-		hs HashFile
-		h  = sha256.New()
-	)
-	err := fs.WalkDir(dir, "", func(path string, d fs.DirEntry, err error) error {
-		if err != nil {
-			return err
-		}
-		// If this is the integrity sum file do not include it into the sum.
-		if filepath.Base(path) == HashFileName {
-			return nil
-		}
-		if !d.IsDir() {
-			f, err := dir.Open(path)
-			if err != nil {
-				return err
-			}
-			defer f.Close()
-			if _, err := h.Write([]byte(path)); err != nil {
-				return err
-			}
-			c, err := ioutil.ReadAll(f)
-			if err != nil {
-				return err
-			}
-			// Check if this file contains an "atlas:sum" directive and if so, act to it.
-			if drctv := reSumDirective.FindSubmatch(bytes.SplitN(c, []byte("\n"), 2)[0]); len(drctv) > 0 {
-				switch string(drctv[1]) {
-				case directiveNone:
-					return nil
-				}
-			}
-			if _, err := h.Write(c); err != nil {
-				return err
-			}
-			hs = append(hs, struct{ N, H string }{path, base64.StdEncoding.EncodeToString(h.Sum(nil))})
-		}
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	return hs, nil
-}
-
-// WriteSumFile writes the given HashFile to the Dir. If the file does not exist, it is created.
-func WriteSumFile(dir Dir, sum HashFile) error {
-	b, err := sum.MarshalText()
-	if err != nil {
-		return err
-	}
-	return dir.WriteFile(HashFileName, b)
-}
-
-// Sum returns the checksum of the represented hash file.
-func (f HashFile) Sum() string {
-	sha := sha256.New()
-	for _, f := range f {
-		sha.Write([]byte(f.N))
-		sha.Write([]byte(f.H))
-	}
-	return base64.StdEncoding.EncodeToString(sha.Sum(nil))
-}
-
-// MarshalText implements encoding.TextMarshaler.
-func (f HashFile) MarshalText() ([]byte, error) {
-	buf := new(bytes.Buffer)
-	for _, f := range f {
-		fmt.Fprintf(buf, "%s h1:%s\n", f.N, f.H)
-	}
-	return []byte(fmt.Sprintf("h1:%s\n%s", f.Sum(), buf)), nil
-}
-
-// UnmarshalText implements encoding.TextUnmarshaler.
-func (f *HashFile) UnmarshalText(b []byte) error {
-	sc := bufio.NewScanner(bytes.NewReader(b))
-	// The first line contains the sum.
-	sc.Scan()
-	sum := strings.TrimPrefix(sc.Text(), "h1:")
-	for sc.Scan() {
-		li := strings.SplitN(sc.Text(), "h1:", 2)
-		if len(li) != 2 {
-			return ErrChecksumFormat
-		}
-		*f = append(*f, struct{ N, H string }{strings.TrimSpace(li[0]), li[1]})
-	}
-	if sum != f.Sum() {
-		return ErrChecksumMismatch
-	}
-	return sc.Err()
-}
-
-func (f HashFile) sumByName(n string) (string, error) {
-	for _, f := range f {
-		if f.N == n {
-			return f.H, nil
-		}
-	}
-	return "", errors.New("checksum not found")
-}
-
-var (
-	// ErrChecksumFormat is returned from Validate if the sum files format is invalid.
-	ErrChecksumFormat = errors.New("checksum file format invalid")
-	// ErrChecksumMismatch is returned from Validate if the hash sums don't match.
-	ErrChecksumMismatch = errors.New("checksum mismatch")
-	// ErrChecksumNotFound is returned from Validate if the hash file does not exist.
-	ErrChecksumNotFound = errors.New("checksum file not found")
-)
-
-// Validate checks if the migration dir is in sync with its sum file.
-// If they don't match ErrChecksumMismatch is returned.
-func Validate(dir Dir) error {
-	fh, err := readHashFile(dir)
-	if errors.Is(err, fs.ErrNotExist) {
-		// If there are no migration files yet this is okay.
-		files, err := fs.ReadDir(dir, "/")
-		if err != nil || len(files) > 0 {
-			return ErrChecksumNotFound
-		}
-		return nil
-	}
-	if err != nil {
-		return err
-	}
-	mh, err := HashSum(dir)
-	if err != nil {
-		return err
-	}
-	if fh.Sum() != mh.Sum() {
-		return ErrChecksumMismatch
-	}
-	return nil
-}
-
-// readHashFile reads the HashFile from the given Dir.
-func readHashFile(dir Dir) (HashFile, error) {
-	f, err := dir.Open(HashFileName)
-	if err != nil {
-		return nil, err
-	}
-	defer f.Close()
-	b, err := io.ReadAll(f)
-	if err != nil {
-		return nil, err
-	}
-	var fh HashFile
-	if err := fh.UnmarshalText(b); err != nil {
-		return nil, err
-	}
-	return fh, nil
-}
-
 func wrap(err1, err2 error) error {
 	if err2 != nil {
 		return fmt.Errorf("sql/migrate: %w: %v", err2, err1)
diff --git a/sql/sqltool/tool.go b/sql/sqltool/tool.go
index 95c06abe..b3dc61c4 100644
--- a/sql/sqltool/tool.go
+++ b/sql/sqltool/tool.go
@@ -16,14 +16,14 @@ import (
 )
 
 var (
-	// GolangMigrateFormatter is a migrate.Formatter compatible with golang-migrate/migrate.
+	// GolangMigrateFormatter returns migrate.Formatter compatible with golang-migrate/migrate.
 	GolangMigrateFormatter = 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 }}`,
 	)
-	// GooseFormatter returns a migrate.Formatter compatible with pressly/goose.
+	// GooseFormatter returns migrate.Formatter compatible with pressly/goose.
 	GooseFormatter = templateFormatter(
 		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
 		`-- +goose Up
@@ -31,14 +31,14 @@ var (
 -- +goose Down
 {{ range rev .Changes }}{{ if .Reverse }}{{ with .Comment }}-- reverse: {{ println . }}{{ end }}{{ printf "%s;\n" .Reverse }}{{ end }}{{ end }}`,
 	)
-	// FlywayFormatter returns a migrate.Formatter compatible with Flyway.
+	// FlywayFormatter returns migrate.Formatter compatible with Flyway.
 	FlywayFormatter = templateFormatter(
 		"V{{ now }}{{ with .Name }}__{{ . }}{{ end }}.sql",
 		`{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ printf "%s;\n" .Cmd }}{{ end }}`,
 		"U{{ now }}{{ with .Name }}__{{ . }}{{ end }}.sql",
 		`{{ range rev .Changes }}{{ if .Reverse }}{{ with .Comment }}-- reverse: {{ println . }}{{ end }}{{ printf "%s;\n" .Reverse }}{{ end }}{{ end }}`,
 	)
-	// LiquibaseFormatter returns a migrate.Formatter compatible with Liquibase.
+	// LiquibaseFormatter returns migrate.Formatter compatible with Liquibase.
 	LiquibaseFormatter = templateFormatter(
 		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
 		`{{- $now := now -}}
@@ -51,7 +51,7 @@ var (
 {{ with $change.Reverse }}--rollback: {{ . }};{{ end }}
 {{ end }}`,
 	)
-	// DbmateFormatter returns a migrate.Formatter compatible with amacneil/dbmate.
+	// DbmateFormatter returns migrate.Formatter compatible with amacneil/dbmate.
 	DbmateFormatter = templateFormatter(
 		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
 		`-- migrate:up


Test patch:
diff --git a/sql/migrate/dir_test.go b/sql/migrate/dir_test.go
new file mode 100644
index 00000000..1a29908d
--- /dev/null
+++ b/sql/migrate/dir_test.go
@@ -0,0 +1,197 @@
+// 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 migrate_test
+
+import (
+	_ "embed"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"ariga.io/atlas/sql/migrate"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestHashSum(t *testing.T) {
+	// Sum file gets created.
+	p := t.TempDir()
+	d, err := migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	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 := time.Now().UTC().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"))
+
+	// Disable sum.
+	p = t.TempDir()
+	d, err = migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	pl = migrate.NewPlanner(nil, d, migrate.DisableChecksum())
+	require.NotNil(t, pl)
+	require.NoError(t, pl.WritePlan(plan))
+	require.Equal(t, 1, countFiles(t, d))
+	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
+
+	// Files with directive in first line get ignored.
+	p = t.TempDir()
+	d, err = migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	pl = migrate.NewPlanner(nil, d)
+	require.NotNil(t, pl)
+	require.NoError(t, os.WriteFile(filepath.Join(p, "include"), []byte("//atlas:sum\nfoo"), 0600))
+	require.NoError(t, os.WriteFile(filepath.Join(p, "exclude_1"), []byte("//atlas:sum ignore bar"), 0600))
+	require.NoError(t, os.WriteFile(filepath.Join(p, "exclude_2"), []byte("//atlas:sum ignore\nbar"), 0600))
+	require.NoError(t, os.WriteFile(filepath.Join(p, "exclude_3"), []byte("atlas:sum ignore"), 0600))
+	require.NoError(t, pl.WritePlan(plan))
+	require.Equal(t, 6, countFiles(t, d))
+	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
+	c, err := os.ReadFile(filepath.Join(p, "atlas.sum"))
+	require.NoError(t, err)
+	require.Contains(t, string(c), "include")
+	require.NotContains(t, string(c), "exclude_2")
+	require.NotContains(t, string(c), "exclude_2")
+	require.NotContains(t, string(c), "exclude_3")
+}
+
+//go:embed testdata/migrate/atlas.sum
+var hash []byte
+
+func TestValidate(t *testing.T) {
+	// Add the sum file form the testdata/migrate dir without any files in it - should fail.
+	p := t.TempDir()
+	d, err := migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	require.NoError(t, d.WriteFile("atlas.sum", hash))
+	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
+
+	td := "testdata/migrate"
+	d, err = migrate.NewLocalDir(td)
+	require.NoError(t, err)
+
+	// testdata/migrate is valid.
+	require.Nil(t, migrate.Validate(d))
+
+	// Making a manual change to the sum file should raise validation error.
+	f, err := os.OpenFile(filepath.Join(td, "atlas.sum"), os.O_RDWR, os.ModeAppend)
+	require.NoError(t, err)
+	_, err = f.WriteString("foo")
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+	t.Cleanup(func() {
+		require.NoError(t, os.WriteFile(filepath.Join(td, "atlas.sum"), hash, 0644))
+	})
+	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
+	require.NoError(t, os.WriteFile(filepath.Join(td, "atlas.sum"), hash, 0644))
+	f, err = os.OpenFile(filepath.Join(td, "atlas.sum"), os.O_APPEND|os.O_WRONLY, os.ModeAppend)
+	require.NoError(t, err)
+	_, err = f.WriteString("foo")
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+	require.Equal(t, migrate.ErrChecksumFormat, migrate.Validate(d))
+	require.NoError(t, os.WriteFile(filepath.Join(td, "atlas.sum"), hash, 0644))
+
+	// Changing the filename should raise validation error.
+	require.NoError(t, os.Rename(filepath.Join(td, "1_initial.up.sql"), filepath.Join(td, "1_first.up.sql")))
+	t.Cleanup(func() {
+		require.NoError(t, os.Rename(filepath.Join(td, "1_first.up.sql"), filepath.Join(td, "1_initial.up.sql")))
+	})
+	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
+
+	// Removing it as well (move it out of the dir).
+	require.NoError(t, os.Rename(filepath.Join(td, "1_first.up.sql"), filepath.Join(td, "..", "bak")))
+	t.Cleanup(func() {
+		require.NoError(t, os.Rename(filepath.Join(td, "..", "bak"), filepath.Join(td, "1_first.up.sql")))
+	})
+	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
+}
+
+func TestHash_MarshalText(t *testing.T) {
+	d, err := migrate.NewLocalDir("testdata/migrate")
+	require.NoError(t, err)
+	h, err := migrate.HashSum(d)
+	require.NoError(t, err)
+	ac, err := h.MarshalText()
+	require.Equal(t, hash, ac)
+}
+
+func TestHash_UnmarshalText(t *testing.T) {
+	d, err := migrate.NewLocalDir("testdata/migrate")
+	require.NoError(t, err)
+	h, err := migrate.HashSum(d)
+	require.NoError(t, err)
+	var ac migrate.HashFile
+	require.NoError(t, ac.UnmarshalText(hash))
+	require.Equal(t, h, ac)
+}
+
+func TestLocalDir(t *testing.T) {
+	// Files don't work.
+	d, err := migrate.NewLocalDir("migrate.go")
+	require.ErrorContains(t, err, "sql/migrate: \"migrate.go\" is not a dir")
+	require.Nil(t, d)
+
+	// Does not create a dir for you.
+	d, err = migrate.NewLocalDir("foo/bar")
+	require.EqualError(t, err, "sql/migrate: stat foo/bar: no such file or directory")
+	require.Nil(t, d)
+
+	// Open and WriteFile work.
+	d, err = migrate.NewLocalDir(t.TempDir())
+	require.NoError(t, err)
+	require.NotNil(t, d)
+	require.NoError(t, d.WriteFile("name", []byte("content")))
+	f, err := d.Open("name")
+	require.NoError(t, err)
+	i, err := f.Stat()
+	require.NoError(t, err)
+	require.Equal(t, i.Name(), "name")
+	c, err := io.ReadAll(f)
+	require.NoError(t, err)
+	require.Equal(t, "content", string(c))
+
+	// Default Dir implementation.
+	d, err = migrate.NewLocalDir("testdata/migrate/sub")
+	require.NoError(t, err)
+	require.NotNil(t, d)
+
+	files, err := d.Files()
+	require.NoError(t, err)
+	require.Len(t, files, 3)
+	require.Equal(t, "1.a_sub.up.sql", files[0].Name())
+	require.Equal(t, "2.10.x-20_description.sql", files[1].Name())
+	require.Equal(t, "3_partly.sql", files[2].Name())
+
+	stmts, err := files[0].Stmts()
+	require.NoError(t, err)
+	require.Equal(t, []string{"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;"}, stmts)
+	require.Equal(t, "1.a", files[0].Version())
+	require.Equal(t, "sub.up", files[0].Desc())
+
+	stmts, err = files[1].Stmts()
+	require.NoError(t, err)
+	require.Equal(t, []string{"ALTER TABLE t_sub ADD c2 int;"}, stmts)
+	require.Equal(t, "2.10.x-20", files[1].Version())
+	require.Equal(t, "description", files[1].Desc())
+}
+
+func TestLocalFile_Baseline(t *testing.T) {
+	dir, err := migrate.NewLocalDir("testdata/baseline")
+	require.NoError(t, err)
+	files, err := dir.Files()
+	require.NoError(t, err)
+	idx := migrate.FilesLastIndex(files, func(f migrate.File) bool {
+		return f.Baseline()
+	})
+	require.Equal(t, 2, idx)
+	require.True(t, files[idx].Baseline())
+	require.False(t, files[idx+1].Baseline())
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 3c8e6c78..c11b30f0 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -9,9 +9,7 @@ import (
 	"database/sql"
 	_ "embed"
 	"errors"
-	"io"
 	"io/fs"
-	"os"
 	"path/filepath"
 	"testing"
 	"text/template"
@@ -89,120 +87,6 @@ func TestPlanner_Plan(t *testing.T) {
 	require.Equal(t, drv.plan, plan)
 }
 
-func TestHashSum(t *testing.T) {
-	// Sum file gets created.
-	p := t.TempDir()
-	d, err := migrate.NewLocalDir(p)
-	require.NoError(t, err)
-	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 := time.Now().UTC().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"))
-
-	// Disable sum.
-	p = t.TempDir()
-	d, err = migrate.NewLocalDir(p)
-	require.NoError(t, err)
-	pl = migrate.NewPlanner(nil, d, migrate.DisableChecksum())
-	require.NotNil(t, pl)
-	require.NoError(t, pl.WritePlan(plan))
-	require.Equal(t, 1, countFiles(t, d))
-	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
-
-	// Files with directive in first line get ignored.
-	p = t.TempDir()
-	d, err = migrate.NewLocalDir(p)
-	require.NoError(t, err)
-	pl = migrate.NewPlanner(nil, d)
-	require.NotNil(t, pl)
-	require.NoError(t, os.WriteFile(filepath.Join(p, "include"), []byte("//atlas:sum\nfoo"), 0600))
-	require.NoError(t, os.WriteFile(filepath.Join(p, "exclude_1"), []byte("//atlas:sum ignore bar"), 0600))
-	require.NoError(t, os.WriteFile(filepath.Join(p, "exclude_2"), []byte("//atlas:sum ignore\nbar"), 0600))
-	require.NoError(t, pl.WritePlan(plan))
-	require.Equal(t, 5, countFiles(t, d))
-	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
-	c, err := os.ReadFile(filepath.Join(p, "atlas.sum"))
-	require.NoError(t, err)
-	require.Contains(t, string(c), "include")
-	require.NotContains(t, string(c), "exclude_2")
-	require.NotContains(t, string(c), "exclude_2")
-}
-
-//go:embed testdata/migrate/atlas.sum
-var hash []byte
-
-func TestValidate(t *testing.T) {
-	// Add the sum file form the testdata/migrate dir without any files in it - should fail.
-	p := t.TempDir()
-	d, err := migrate.NewLocalDir(p)
-	require.NoError(t, err)
-	require.NoError(t, d.WriteFile("atlas.sum", hash))
-	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
-
-	td := "testdata/migrate"
-	d, err = migrate.NewLocalDir(td)
-	require.NoError(t, err)
-
-	// testdata/migrate is valid.
-	require.Nil(t, migrate.Validate(d))
-
-	// Making a manual change to the sum file should raise validation error.
-	f, err := os.OpenFile(filepath.Join(td, "atlas.sum"), os.O_RDWR, os.ModeAppend)
-	require.NoError(t, err)
-	_, err = f.WriteString("foo")
-	require.NoError(t, err)
-	require.NoError(t, f.Close())
-	t.Cleanup(func() {
-		require.NoError(t, os.WriteFile(filepath.Join(td, "atlas.sum"), hash, 0644))
-	})
-	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
-	require.NoError(t, os.WriteFile(filepath.Join(td, "atlas.sum"), hash, 0644))
-	f, err = os.OpenFile(filepath.Join(td, "atlas.sum"), os.O_APPEND|os.O_WRONLY, os.ModeAppend)
-	require.NoError(t, err)
-	_, err = f.WriteString("foo")
-	require.NoError(t, err)
-	require.NoError(t, f.Close())
-	require.Equal(t, migrate.ErrChecksumFormat, migrate.Validate(d))
-	require.NoError(t, os.WriteFile(filepath.Join(td, "atlas.sum"), hash, 0644))
-
-	// Changing the filename should raise validation error.
-	require.NoError(t, os.Rename(filepath.Join(td, "1_initial.up.sql"), filepath.Join(td, "1_first.up.sql")))
-	t.Cleanup(func() {
-		require.NoError(t, os.Rename(filepath.Join(td, "1_first.up.sql"), filepath.Join(td, "1_initial.up.sql")))
-	})
-	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
-
-	// Removing it as well (move it out of the dir).
-	require.NoError(t, os.Rename(filepath.Join(td, "1_first.up.sql"), filepath.Join(td, "..", "bak")))
-	t.Cleanup(func() {
-		require.NoError(t, os.Rename(filepath.Join(td, "..", "bak"), filepath.Join(td, "1_first.up.sql")))
-	})
-	require.Equal(t, migrate.ErrChecksumMismatch, migrate.Validate(d))
-}
-
-func TestHash_MarshalText(t *testing.T) {
-	d, err := migrate.NewLocalDir("testdata/migrate")
-	require.NoError(t, err)
-	h, err := migrate.HashSum(d)
-	require.NoError(t, err)
-	ac, err := h.MarshalText()
-	require.Equal(t, hash, ac)
-}
-
-func TestHash_UnmarshalText(t *testing.T) {
-	d, err := migrate.NewLocalDir("testdata/migrate")
-	require.NoError(t, err)
-	h, err := migrate.HashSum(d)
-	require.NoError(t, err)
-	var ac migrate.HashFile
-	require.NoError(t, ac.UnmarshalText(hash))
-	require.Equal(t, h, ac)
-}
-
 func TestExecutor_ReadState(t *testing.T) {
 	ctx := context.Background()
 	d, err := migrate.NewLocalDir("testdata/migrate")
@@ -241,56 +125,6 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.True(t, drv.released())
 }
 
-func TestLocalDir(t *testing.T) {
-	// Files don't work.
-	d, err := migrate.NewLocalDir("migrate.go")
-	require.ErrorContains(t, err, "sql/migrate: \"migrate.go\" is not a dir")
-	require.Nil(t, d)
-
-	// Does not create a dir for you.
-	d, err = migrate.NewLocalDir("foo/bar")
-	require.EqualError(t, err, "sql/migrate: stat foo/bar: no such file or directory")
-	require.Nil(t, d)
-
-	// Open and WriteFile work.
-	d, err = migrate.NewLocalDir(t.TempDir())
-	require.NoError(t, err)
-	require.NotNil(t, d)
-	require.NoError(t, d.WriteFile("name", []byte("content")))
-	f, err := d.Open("name")
-	require.NoError(t, err)
-	i, err := f.Stat()
-	require.NoError(t, err)
-	require.Equal(t, i.Name(), "name")
-	c, err := io.ReadAll(f)
-	require.NoError(t, err)
-	require.Equal(t, "content", string(c))
-
-	// Default Scanner implementation.
-	d, err = migrate.NewLocalDir("testdata/migrate/sub")
-	require.NoError(t, err)
-	require.NotNil(t, d)
-
-	files, err := d.Files()
-	require.NoError(t, err)
-	require.Len(t, files, 3)
-	require.Equal(t, "1.a_sub.up.sql", files[0].Name())
-	require.Equal(t, "2.10.x-20_description.sql", files[1].Name())
-	require.Equal(t, "3_partly.sql", files[2].Name())
-
-	stmts, err := files[0].Stmts()
-	require.NoError(t, err)
-	require.Equal(t, []string{"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;"}, stmts)
-	require.Equal(t, "1.a", files[0].Version())
-	require.Equal(t, "sub.up", files[0].Desc())
-
-	stmts, err = files[1].Stmts()
-	require.NoError(t, err)
-	require.Equal(t, []string{"ALTER TABLE t_sub ADD c2 int;"}, stmts)
-	require.Equal(t, "2.10.x-20", files[1].Version())
-	require.Equal(t, "description", files[1].Desc())
-}
-
 func TestExecutor_Pending(t *testing.T) {
 	var (
 		drv  = &lockMockDriver{&mockDriver{}}
diff --git a/sql/migrate/testdata/baseline/1_v1.sql b/sql/migrate/testdata/baseline/1_v1.sql
new file mode 100644
index 00000000..4dc4d431
--- /dev/null
+++ b/sql/migrate/testdata/baseline/1_v1.sql
@@ -0,0 +1,2 @@
+CREATE DATABASE market;
+CREATE TABLE orders (name varchar(255));
diff --git a/sql/migrate/testdata/baseline/2_v2.sql b/sql/migrate/testdata/baseline/2_v2.sql
new file mode 100644
index 00000000..3303abe4
--- /dev/null
+++ b/sql/migrate/testdata/baseline/2_v2.sql
@@ -0,0 +1,1 @@
+CREATE TABLE products (name varchar(255), price decimal);
diff --git a/sql/migrate/testdata/baseline/3_baseline.sql b/sql/migrate/testdata/baseline/3_baseline.sql
new file mode 100644
index 00000000..5f6eefc9
--- /dev/null
+++ b/sql/migrate/testdata/baseline/3_baseline.sql
@@ -0,0 +1,6 @@
+-- atlas:baseline
+
+CREATE DATABASE market;
+CREATE TABLE orders (name varchar(255));
+CREATE TABLE products (name varchar(255), price decimal);
+
diff --git a/sql/migrate/testdata/baseline/4_initial.sql b/sql/migrate/testdata/baseline/4_initial.sql
new file mode 100644
index 00000000..4e2c3634
--- /dev/null
+++ b/sql/migrate/testdata/baseline/4_initial.sql
@@ -0,0 +1,1 @@
+ALTER TABLE orders ADD COLUMN product_id bigint;
\ No newline at end of file