Repository: ariga/atlas
Pull request #624 (2022-03-04 09:27:42)

sql/migrate: add hash integrity file
Suppose you have multiple teams develop a feature in parallel and both of them need a migration. If Team A and Team B do not check in with each other we might end up with a broken migration directory since new files do not raise a merge conflict for example.

With this PR every time a new migration file is created programatically, a hash is computed. This is done by walking over the whole migration dir in lexicographic order and hashing the contents. That file is then checked into version control. In the above case the second merging team will have to resolve a merge conflict on the hashfile. Atlas itself will get a `Validate` method to check if the hash and the migration directory matches.

For now this only works with programmtically created migration files, but I will add a way to re-compute the hash for the migration directory since this is needed when editing a file, adding one by hand or resolving the above mentioned conflict.

The `Hash` interface is needed, since one might not want to save the integrity file in the migration directory, for example when the migration directory does live in another repo / s3 bucket / whatever.

a8m: > plus the contents of the new migration files.

Maybe, migration filename? because content can be changed by the user. e.g. `CREATE INDEX` => `CREATE INDEX CONCURRENTLY`, I would expect atlas generate the hash / update lock file, after running `atlas diff`.

Update: we talked about it over the phone, but I feel like the process will be simpler if we'll use the filenames in the lock file. 

A few thoughts on the file format - I think we should keep a list of filenames (with hashes) if we want to fail in vcs merge (due to conflicts). i.e. a file with one line is not good enough. Why the hash? because we don't want the user to resolve the git conflict manully, it should be generated by atlas.


Example:
(We can use any format. HCL or simple text are both fine imo).
```text
1_add_t1: 0OppXMNlmlGpAvSC7aaebg==
2_add_t2: iu0r71q7BjEagxdWWomwjA==
<<<<<<< HEAD
3_add_t3: OV+o/g6txWQyZ/kKh+etmw==
=======
3_edit_t2: WhXwXVbR4koUTEMj3N0JVQ==
>>>>>>> ...
```
In this case, I expect atlas to provide a way to clean current (local) migration file, let's say with a command like `reset` or `rm`, but it can also be done manaully. After that, the user can run again `atlas migrate diff 3_edit_t2` and get its new updated migration file.

But what if the user check the latest remote migration and see that there are not conflicts with its local (in development) changes? I think in this case, we should provide a way to `resolve`/`merge` this state. i.e. calcualte the hash and update the lock file.

> For now this only works with programmtically created migration files, but I will add a way to re-compute the hash for the migration directory since this is needed when editing a file, adding one by hand or resolving the above mentioned conflict.

Maybe we can introduce a new command/flag, `atlas migrate new ...`/`atlas migrate diff ... --new`

masseelch: > ```
1_add_t1: 0OppXMNlmlGpAvSC7aaebg==
2_add_t2: iu0r71q7BjEagxdWWomwjA==
<<<<<<< HEAD
3_add_t3: OV+o/g6txWQyZ/kKh+etmw==
=======
3_edit_t2: WhXwXVbR4koUTEMj3N0JVQ==
>>>>>>> ...
```

This format won't raise a merge conflict if there have been two lines added cause git handles that correctly, if I am not mistaken. So this won't work if there isn't an extra line with a complete hash somewhere, correct?

An idea to keep the file human readable is to take your format but add a global has as first line. 

> In this case, I expect atlas to provide a way to clean current (local) migration file, let's say [...]

We don't need that if we just add a command to (force) compute the integrity hash file. Whenever the user did a manual edit to a file (edit, create, delete), he needs to run the command to create a valid hash file. 

We can then add a `atlas migrate validate` command to check files and hash against each other. This can also be run in CI similar to the `go generate` checks.

> Maybe we can introduce a new command/flag, `atlas migrate new ...`/`atlas migrate diff ... --new`/`atlas migrate diff ... --empty`

With my idea we didn't need it. I think we should not add it unless the users request it. 

Thoughts? Also @rotemtam, @yonidavidson 
a8m: > This format won't raise a merge conflict if there have been two lines added cause git handles that correctly, if I am not mistaken. So this won't work if there isn't an extra line with a complete hash somewhere, correct?

You're right, ignore the example. We can use a JSON format, or edit a line at the head of the file to cause the conflict. Having a `validate` command is useful, but we need an extra check here.

> An idea to keep the file human readable is to take your format but add a global hash as first line.

Exactly. But having a list of migration files is not only for making the file human-readable, it's necessary for keeping the history of the migrations. If users want to change the history (e.g. compaction), it can be done using `atlas`.

> With my idea we didn't need it. I think we should not add it unless the users request it.

Having just one hash of the migration directory, make it possible to change the history without noticing it. We can still provide a way to re-generate the lock file (recompute all hashes), and edit the history, but this should be done with an explicit command IMO. 
yonidavidson: Trying to get things in order (for me to understand).
1. Migration files: each file will have timestamp_user_specific_name.sql
2. Lock file (like go.mod?) with each file and it's hash ordered by creation time.
3. Atlas will have helper commands to handle this. "atlas migration tidy"? for example" The lock file will have to be by "DB/Schema/Realm"? maybe it should be external since a few projects might change same DB and they need to share lock file no ?
a8m: > Atlas will have helper commands to handle this. "atlas migration tidy"? for example" The lock file will have to be by "DB/Schema/Realm"? maybe it should be external since a few projects might change same DB and they need to share lock file no ?

For a migration directory. 

@yonidavidson, I think it's best to jump on a quick meeting tomorrow, discuss this and unblock you @masseelch. Let me know what do you think. 

Patch:
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 909b38ec..c7a7ca0e 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -5,8 +5,11 @@
 package migrate
 
 import (
+	"bufio"
 	"bytes"
 	"context"
+	"crypto/sha256"
+	"encoding/base64"
 	"errors"
 	"fmt"
 	"io"
@@ -156,6 +159,7 @@ type (
 		dir Dir         // where migration files are stored and read from
 		fmt Formatter   // how to format a plan to migration files
 		dsr StateReader // how to read a state from the migration directory
+		sum bool        // whether to create a sum file for the migration directory
 	}
 
 	// PlannerOption allows managing a Planner using functional arguments.
@@ -164,7 +168,7 @@ type (
 
 // NewPlanner creates a new Planner.
 func NewPlanner(drv Driver, dir Dir, opts ...PlannerOption) *Planner {
-	p := &Planner{drv: drv, dir: dir}
+	p := &Planner{drv: drv, dir: dir, sum: true}
 	for _, opt := range opts {
 		opt(p)
 	}
@@ -191,6 +195,43 @@ func WithStateReader(dsr StateReader) PlannerOption {
 	}
 }
 
+// DisableChecksum disables the hash-sum functionality for the migration directory.
+func DisableChecksum() PlannerOption {
+	return func(p *Planner) {
+		p.sum = false
+	}
+}
+
+// GlobStateReader creates a StateReader that loads all files from Dir matching
+// glob in lexicographic order and uses the Driver to create a migration state.
+func GlobStateReader(dir Dir, drv Driver, glob string) StateReaderFunc {
+	return func(ctx context.Context) (*schema.Realm, error) {
+		names, err := fs.Glob(dir, glob)
+		if err != nil {
+			return nil, err
+		}
+		// Sort files lexicographically.
+		sort.Slice(names, func(i, j int) bool {
+			return names[i] < names[j]
+		})
+		for _, n := range names {
+			f, err := dir.Open(n)
+			if err != nil {
+				return nil, err
+			}
+			b, err := io.ReadAll(f)
+			f.Close()
+			if err != nil {
+				return nil, err
+			}
+			if _, err := drv.ExecContext(ctx, string(b)); err != nil {
+				return nil, err
+			}
+		}
+		return drv.InspectRealm(ctx, nil)
+	}
+}
+
 // Plan calculates the migration Plan required for moving the current state (from) state to
 // the next state (to). A StateReader can be a directory, static schema elements or a Driver connection.
 func (p *Planner) Plan(ctx context.Context, name string, to StateReader) (*Plan, error) {
@@ -227,6 +268,17 @@ func (p *Planner) WritePlan(plan *Plan) error {
 			return err
 		}
 	}
+	if p.sum {
+		sum, err := HashSum(p.dir)
+		if err != nil {
+			return err
+		}
+		b, err := sum.MarshalText()
+		if err != nil {
+			return err
+		}
+		return p.dir.WriteFile(hashFile, b)
+	}
 	return nil
 }
 
@@ -238,10 +290,11 @@ type LocalDir struct {
 // 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 == os.ErrNotExist {
+	if os.IsNotExist(err) {
 		if err := os.MkdirAll(path, 0755); err != nil {
 			return nil, err
 		}
+		return &LocalDir{dir: path}, nil
 	} else if err != nil {
 		return nil, err
 	}
@@ -251,36 +304,6 @@ func NewLocalDir(path string) (*LocalDir, error) {
 	return &LocalDir{dir: path}, nil
 }
 
-// GlobStateReader creates a StateReader that loads all files from Dir matching
-// glob in lexicographic order and uses the Driver to create a migration state.
-func GlobStateReader(dir Dir, drv Driver, glob string) StateReaderFunc {
-	return func(ctx context.Context) (*schema.Realm, error) {
-		names, err := fs.Glob(dir, glob)
-		if err != nil {
-			return nil, err
-		}
-		// Sort files lexicographically.
-		sort.Slice(names, func(i, j int) bool {
-			return names[i] < names[j]
-		})
-		for _, n := range names {
-			f, err := dir.Open(n)
-			if err != nil {
-				return nil, err
-			}
-			b, err := io.ReadAll(f)
-			f.Close()
-			if err != nil {
-				return nil, err
-			}
-			if _, err := drv.ExecContext(ctx, string(b)); err != nil {
-				return nil, err
-			}
-		}
-		return drv.InspectRealm(ctx, nil)
-	}
-}
-
 // Open implements fs.FS.
 func (d *LocalDir) Open(name string) (fs.File, error) {
 	return os.Open(filepath.Join(d.dir, name))
@@ -348,7 +371,7 @@ func NewTemplateFormatter(templates ...*template.Template) (*TemplateFormatter,
 
 // Format implements the Formatter interface.
 func (t *TemplateFormatter) Format(plan *Plan) ([]File, error) {
-	fs := make([]File, 0, len(t.templates))
+	files := make([]File, 0, len(t.templates))
 	for _, tpl := range t.templates {
 		var n, c bytes.Buffer
 		if err := tpl.N.Execute(&n, plan); err != nil {
@@ -357,12 +380,12 @@ func (t *TemplateFormatter) Format(plan *Plan) ([]File, error) {
 		if err := tpl.C.Execute(&c, plan); err != nil {
 			return nil, err
 		}
-		fs = append(fs, &templateFile{
+		files = append(files, &templateFile{
 			Buffer: &c,
 			n:      n.String(),
 		})
 	}
-	return fs, nil
+	return files, nil
 }
 
 type templateFile struct {
@@ -373,6 +396,106 @@ type templateFile struct {
 // Name implements the File interface.
 func (f *templateFile) Name() string { return f.n }
 
+// Filename of the migration directory integrity sum file.
+const hashFile = "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.
+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) == hashFile {
+			return nil
+		}
+		if !d.IsDir() {
+			f, err := dir.Open(path)
+			if err != nil {
+				return err
+			}
+			defer f.Close()
+			if _, err = io.Copy(h, f); 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
+}
+
+// ErrChecksumMismatch is returned from Validate if the hash sums don't match.
+var ErrChecksumMismatch = errors.New("checksum mismatch")
+
+// 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 {
+	f, err := dir.Open(hashFile)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return err
+	}
+	var fh HashFile
+	if err := fh.UnmarshalText(b); err != nil {
+		return nil
+	}
+	mh, err := HashSum(dir)
+	if err != nil {
+		return err
+	}
+	if fh.Sum() != mh.Sum() {
+		return ErrChecksumMismatch
+	}
+	return nil
+}
+
+// Sum returns the checksum of the represented hash file.
+func (s HashFile) Sum() string {
+	sha := sha256.New()
+	for _, f := range s {
+		sha.Write([]byte(f.H))
+	}
+	return base64.StdEncoding.EncodeToString(sha.Sum(nil))
+}
+
+// MarshalText implements encoding.TextMarshaler.
+func (s HashFile) MarshalText() ([]byte, error) {
+	buf := new(bytes.Buffer)
+	for _, f := range s {
+		fmt.Fprintf(buf, "%s h1:%s\n", f.N, f.H)
+	}
+	return []byte(fmt.Sprintf("sum h1:%s\n%s", s.Sum(), buf)), nil
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (s *HashFile) UnmarshalText(b []byte) error {
+	sc := bufio.NewScanner(bytes.NewReader(b))
+	sc.Scan() // skip sum
+	for sc.Scan() {
+		li := strings.SplitN(sc.Text(), "h1:", 2)
+		if len(li) != 2 {
+			return errors.New("invalid sum file")
+		}
+		*s = append(*s, struct{ N, H string }{strings.TrimSpace(li[0]), li[1]})
+	}
+	return sc.Err()
+}
+
 // reverse changes for the down migration.
 func reverse(changes []*Change) []*Change {
 	n := len(changes)


Test patch:
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index a15087bf..66443379 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -5,12 +5,13 @@
 package migrate_test
 
 import (
-	"bytes"
 	"context"
 	"database/sql"
+	_ "embed"
 	"io"
 	"io/fs"
 	"os"
+	"path/filepath"
 	"strconv"
 	"testing"
 	"text/template"
@@ -23,7 +24,9 @@ import (
 )
 
 func TestPlanner_WritePlan(t *testing.T) {
-	mfs := &mockFS{}
+	p := t.TempDir()
+	d, err := migrate.NewLocalDir(p)
+	require.NoError(t, err)
 	plan := &migrate.Plan{
 		Name: "add_t1_and_t2",
 		Changes: []*migrate.Change{
@@ -33,19 +36,13 @@ func TestPlanner_WritePlan(t *testing.T) {
 	}
 
 	// DefaultFormatter
-	pl := migrate.NewPlanner(nil, mfs)
+	pl := migrate.NewPlanner(nil, d, migrate.DisableChecksum())
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
 	v := strconv.FormatInt(time.Now().Unix(), 10)
-	require.Len(t, mfs.files, 2)
-	require.Equal(t, &file{
-		n: v + "_add_t1_and_t2.up.sql",
-		b: bytes.NewBufferString("CREATE TABLE t1(c int);\nCREATE TABLE t2(c int);\n"),
-	}, mfs.files[0])
-	require.Equal(t, &file{
-		n: v + "_add_t1_and_t2.down.sql",
-		b: bytes.NewBufferString("DROP TABLE t2;\nDROP TABLE t1 IF EXISTS;\n"),
-	}, mfs.files[1])
+	require.Equal(t, countFiles(t, d), 2)
+	requireFileEqual(t, filepath.Join(p, v+"_add_t1_and_t2.up.sql"), "CREATE TABLE t1(c int);\nCREATE TABLE t2(c int);\n")
+	requireFileEqual(t, filepath.Join(p, v+"_add_t1_and_t2.down.sql"), "DROP TABLE t2;\nDROP TABLE t1 IF EXISTS;\n")
 
 	// Custom formatter (creates only "up" migration files).
 	fmt, err := migrate.NewTemplateFormatter(
@@ -53,25 +50,23 @@ func TestPlanner_WritePlan(t *testing.T) {
 		template.Must(template.New("").Parse("{{range .Changes}}{{println .Cmd}}{{end}}")),
 	)
 	require.NoError(t, err)
-	pl = migrate.NewPlanner(nil, mfs, migrate.WithFormatter(fmt))
+	pl = migrate.NewPlanner(nil, d, migrate.WithFormatter(fmt), migrate.DisableChecksum())
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
-	require.Len(t, mfs.files, 3)
-	require.Equal(t, &file{
-		n: "add_t1_and_t2.sql",
-		b: bytes.NewBufferString("CREATE TABLE t1(c int);\nCREATE TABLE t2(c int)\n"),
-	}, mfs.files[2])
+	require.Equal(t, countFiles(t, d), 3)
+	requireFileEqual(t, filepath.Join(p, "add_t1_and_t2.sql"), "CREATE TABLE t1(c int);\nCREATE TABLE t2(c int)\n")
 }
 
 func TestPlanner_Plan(t *testing.T) {
 	var (
-		mfs = &mockFS{}
 		drv = &mockDriver{}
 		ctx = context.Background()
 	)
+	d, err := migrate.NewLocalDir(t.TempDir())
+	require.NoError(t, err)
 
 	// nothing to do
-	pl := migrate.NewPlanner(drv, mfs)
+	pl := migrate.NewPlanner(drv, d)
 	plan, err := pl.Plan(ctx, "empty", migrate.Realm(nil))
 	require.ErrorIs(t, err, migrate.ErrNoPlan)
 	require.Nil(t, plan)
@@ -92,6 +87,65 @@ func TestPlanner_Plan(t *testing.T) {
 	require.Equal(t, drv.plan, plan)
 }
 
+func TestHash(t *testing.T) {
+	p := t.TempDir()
+	d, err := migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	plan := &migrate.Plan{Name: "plan", Changes: []*migrate.Change{{Cmd: "cmd", Reverse: "rev"}}}
+	pl := migrate.NewPlanner(nil, d)
+	require.NotNil(t, pl)
+	require.NoError(t, pl.WritePlan(plan))
+	v := strconv.FormatInt(time.Now().Unix(), 10)
+	require.Equal(t, countFiles(t, d), 3)
+	requireFileEqual(t, filepath.Join(p, v+"_plan.up.sql"), "cmd;\n")
+	requireFileEqual(t, filepath.Join(p, v+"_plan.down.sql"), "rev;\n")
+	require.FileExists(t, filepath.Join(p, "atlas.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, countFiles(t, d), 2)
+	requireFileEqual(t, filepath.Join(p, v+"_plan.up.sql"), "cmd;\n")
+	requireFileEqual(t, filepath.Join(p, v+"_plan.down.sql"), "rev;\n")
+}
+
+func TestValidate(t *testing.T) {
+	d, err := migrate.NewLocalDir("testdata")
+	require.NoError(t, err)
+	require.Nil(t, migrate.Validate(d))
+
+	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))
+}
+
+//go:embed testdata/atlas.sum
+var hash []byte
+
+func TestHash_MarshalText(t *testing.T) {
+	d, err := migrate.NewLocalDir("testdata")
+	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")
+	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 TestGlobStateReader(t *testing.T) {
 	var (
 		drv = &mockDriver{}
@@ -110,8 +164,8 @@ func TestGlobStateReader(t *testing.T) {
 }
 
 func TestLocalDir(t *testing.T) {
-	d, err := migrate.NewLocalDir("does_not_exist")
-	require.ErrorIs(t, err, os.ErrNotExist)
+	d, err := migrate.NewLocalDir("migrate.go")
+	require.ErrorContains(t, err, "sql/migrate: \"migrate.go\" is not a dir")
 	require.Nil(t, d)
 
 	d, err = migrate.NewLocalDir(t.TempDir())
@@ -128,44 +182,15 @@ func TestLocalDir(t *testing.T) {
 	require.Equal(t, "content", string(c))
 }
 
-type (
-	mockFS struct {
-		files []*file
-	}
-	file struct {
-		n string
-		b *bytes.Buffer
-	}
-	fileInfo struct {
-		n string
-	}
-)
+type mockHashFS struct{ hash []byte }
 
-func (f *file) Read(b []byte) (int, error) { return f.b.Read(b) }
-func (f *file) Close() error               { return nil }
-func (f *file) Stat() (fs.FileInfo, error) { return fileInfo{f.n}, nil }
-
-func (i fileInfo) Name() string       { return i.n }
-func (i fileInfo) Size() int64        { return 0 }
-func (i fileInfo) Mode() fs.FileMode  { return 0 }
-func (i fileInfo) ModTime() time.Time { return time.Time{} }
-func (i fileInfo) IsDir() bool        { return false }
-func (i fileInfo) Sys() interface{}   { return nil }
-
-func (fs *mockFS) Open(name string) (fs.File, error) {
-	for _, f := range fs.files {
-		if f.n == name {
-			return f, nil
-		}
-	}
-	f := &file{n: name, b: new(bytes.Buffer)}
-	fs.files = append(fs.files, f)
-	return f, nil
+func (h *mockHashFS) WriteHash(b []byte) error {
+	h.hash = b
+	return nil
 }
 
-func (fs *mockFS) WriteFile(name string, d []byte) error {
-	fs.files = append(fs.files, &file{n: name, b: bytes.NewBuffer(d)})
-	return nil
+func (h *mockHashFS) ReadHash() ([]byte, error) {
+	return h.hash, nil
 }
 
 type mockDriver struct {
@@ -189,3 +214,15 @@ func (m mockDriver) RealmDiff(_, _ *schema.Realm) ([]schema.Change, error) {
 func (m mockDriver) PlanChanges(context.Context, string, []schema.Change) (*migrate.Plan, error) {
 	return m.plan, nil
 }
+
+func countFiles(t *testing.T, d migrate.Dir) int {
+	files, err := fs.ReadDir(d, "")
+	require.NoError(t, err)
+	return len(files)
+}
+
+func requireFileEqual(t *testing.T, name, contents string) {
+	c, err := os.ReadFile(name)
+	require.NoError(t, err)
+	require.Equal(t, contents, string(c))
+}
diff --git a/sql/migrate/testdata/1.down.sql b/sql/migrate/testdata/1_initial.down.sql
similarity index 100%
rename from sql/migrate/testdata/1.down.sql
rename to sql/migrate/testdata/1_initial.down.sql
diff --git a/sql/migrate/testdata/1.up.sql b/sql/migrate/testdata/1_initial.up.sql
similarity index 100%
rename from sql/migrate/testdata/1.up.sql
rename to sql/migrate/testdata/1_initial.up.sql
diff --git a/sql/migrate/testdata/atlas.sum b/sql/migrate/testdata/atlas.sum
new file mode 100644
index 00000000..c513cf1d
--- /dev/null
+++ b/sql/migrate/testdata/atlas.sum
@@ -0,0 +1,4 @@
+sum h1:DPwupu/FZJZYDnpuzD/l7aOg0jW2vhZxnajKPqhtKXI=
+1_initial.down.sql h1:EZiUHYOg5uOulzAVoV3ASKISA07Yh1ssDovA9hFGm1g=
+1_initial.up.sql h1:jDnx3o9bPJxVl9R0zpqnB+ETv1uyHR8JmvDw2G+kJEk=
+sub/1.a_sub-up.sql h1:XTo+9ZV78c5t2cuZCqcvTSwkQEf0B2L9Didd8p8N2gc=
diff --git a/sql/migrate/testdata/sub/1.a_sub-up.sql b/sql/migrate/testdata/sub/1.a_sub-up.sql
new file mode 100644
index 00000000..07c223a8
--- /dev/null
+++ b/sql/migrate/testdata/sub/1.a_sub-up.sql
@@ -0,0 +1,1 @@
+CREATE TABLE t_sub(c int);
\ No newline at end of file