Repository: ariga/atlas
Pull request #849 (2022-06-08 09:52:32)

cmd/atlascmd: add migrate apply command
This is what it looks like in action for a TTY:
![image](https://user-images.githubusercontent.com/12862103/172607345-6c1cf31f-91c1-426d-817a-73cb29d90dcb.png)

And one for text:
![image](https://user-images.githubusercontent.com/12862103/172609071-99c10eaf-ea24-4e93-8627-f9c75a8fac4f.png)




Patch:
diff --git a/cmd/atlasci/internal/ci/ci.go b/cmd/atlasci/internal/ci/ci.go
index 8a08f9e5..02e6ebf4 100644
--- a/cmd/atlasci/internal/ci/ci.go
+++ b/cmd/atlasci/internal/ci/ci.go
@@ -14,7 +14,7 @@ import (
 	"sort"
 
 	"ariga.io/atlas/sql/migrate"
-	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
 	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/go-git/go-git/v5"
@@ -34,7 +34,7 @@ type (
 	// It will also label migration files as either "generated" or "handcrafted".
 	ChangeLoader interface {
 		// LoadChanges converts each of the given migration files into one Changes.
-		LoadChanges(context.Context, []migrate.File) ([]*File, error)
+		LoadChanges(context.Context, []migrate.File) ([]*sqlcheck.File, error)
 	}
 
 	// DirScanner stitches migrate.Dir and migrate.Scanner into one interface.
@@ -42,20 +42,6 @@ type (
 		migrate.Dir
 		migrate.Scanner
 	}
-
-	// File represents a parsed version of a migration file.
-	File struct {
-		migrate.File
-		// Changes is the list of changes this file represents.
-		Changes []*Change
-	}
-
-	// A Change in a migration file.
-	Change struct {
-		schema.Changes        // The actual changes.
-		Stmt           string // The SQL statement generated this change.
-		Pos            int    // The position of the statement in the file.
-	}
 )
 
 type (
@@ -201,14 +187,14 @@ type DevLoader struct {
 }
 
 // LoadChanges implements the ChangesLoader interface.
-func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*File, error) {
-	diff := make([]*File, len(files))
+func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*sqlcheck.File, error) {
+	diff := make([]*sqlcheck.File, len(files))
 	current, err := d.Dev.InspectRealm(ctx, nil)
 	if err != nil {
 		return nil, err
 	}
 	for i, f := range files {
-		diff[i] = &File{
+		diff[i] = &sqlcheck.File{
 			File: f,
 		}
 		stmts, err := d.Scan.Stmts(f)
@@ -234,7 +220,7 @@ func (d *DevLoader) LoadChanges(ctx context.Context, files []migrate.File) ([]*F
 				if err != nil {
 					return nil, err
 				}
-				diff[i].Changes = append(diff[i].Changes, &Change{
+				diff[i].Changes = append(diff[i].Changes, &sqlcheck.Change{
 					Pos:     p,
 					Stmt:    s,
 					Changes: changes,
diff --git a/cmd/atlasci/internal/ci/run.go b/cmd/atlasci/internal/ci/run.go
new file mode 100644
index 00000000..5fe372bf
--- /dev/null
+++ b/cmd/atlasci/internal/ci/run.go
@@ -0,0 +1,64 @@
+// 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 ci
+
+import (
+	"context"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+// Runner is used to execute CI jobs.
+type Runner struct {
+	// DevClient configures the "dev driver" to calculate
+	// migration changes by the driver.
+	Dev *sqlclient.Client
+
+	// RunChangeDetector configures the ChangeDetector to
+	// be used by the runner.
+	ChangeDetector ChangeDetector
+
+	// Scan is used for scanning the migration directory.
+	Scan migrate.Scanner
+
+	// Analyzer defines the analysis to be run in the CI job.
+	Analyzer sqlcheck.Analyzer
+
+	// Reporter is used to report diagnostics in the CI.
+	Reporter sqlcheck.Reporter
+}
+
+// Run executes the CI job.
+func (r *Runner) Run(ctx context.Context) error {
+	base, feat, err := r.ChangeDetector.DetectChanges(ctx)
+	if err != nil {
+		return err
+	}
+	// Bring the dev environment to the base point.
+	for _, f := range base {
+		stmt, err := r.Scan.Stmts(f)
+		if err != nil {
+			return err
+		}
+		for _, s := range stmt {
+			if _, err := r.Dev.ExecContext(ctx, s); err != nil {
+				return err
+			}
+		}
+	}
+	l := &DevLoader{Dev: r.Dev, Scan: r.Scan}
+	files, err := l.LoadChanges(ctx, feat)
+	if err != nil {
+		return err
+	}
+	for _, f := range files {
+		if err := r.Analyzer.Analyze(ctx, &sqlcheck.Pass{File: f, Dev: r.Dev, Report: r.Reporter}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/cmd/atlascmd/migrate.go b/cmd/atlascmd/migrate.go
index b2be3f41..d735224f 100644
--- a/cmd/atlascmd/migrate.go
+++ b/cmd/atlascmd/migrate.go
@@ -11,37 +11,45 @@ import (
 	"io"
 	"io/ioutil"
 	"path/filepath"
+	"strconv"
 	"strings"
+	"time"
 
+	entmigrate "ariga.io/atlas/cmd/atlascmd/migrate"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
 	"ariga.io/atlas/sql/sqltool"
+	"github.com/fatih/color"
 	"github.com/spf13/pflag"
 
 	"github.com/spf13/cobra"
 )
 
 const (
-	migrateFlagDevURL      = "dev-url"
-	migrateFlagDir         = "dir"
-	migrateFlagForce       = "force"
-	migrateFlagFormat      = "format"
-	migrateFlagSchema      = "schema"
-	migrateDiffFlagTo      = "to"
-	migrateDiffFlagVerbose = "verbose"
+	migrateFlagDevURL          = "dev-url"
+	migrateFlagDir             = "dir"
+	migrateFlagForce           = "force"
+	migrateFlagFormat          = "format"
+	migrateFlagLog             = "log"
+	migrateFlagRevisionsSchema = "revisions-schema"
+	migrateFlagTo              = "to"
+	migrateFlagSchema          = "schema"
+	migrateDiffFlagVerbose     = "verbose"
 )
 
 var (
 	// MigrateFlags are the flags used in MigrateCmd (and sub-commands).
 	MigrateFlags struct {
-		DirURL  string
-		DevURL  string
-		ToURL   string
-		Schemas []string
-		Format  string
-		Force   bool
-		Verbose bool
+		DirURL         string
+		DevURL         string
+		ToURL          string
+		Schemas        []string
+		Format         string
+		LogFormat      string
+		RevisionSchema string
+		Force          bool
+		Verbose        bool
 	}
 	// MigrateCmd represents the migrate command. It wraps several other sub-commands.
 	MigrateCmd = &cobra.Command{
@@ -75,6 +83,21 @@ to re-hash the contents and resolve the error
 			return nil
 		},
 	}
+	// MigrateApplyCmd represents the 'atlas migrate apply' subcommand.
+	MigrateApplyCmd = &cobra.Command{
+		Use:   "apply",
+		Short: "Applies pending migration files on the connected database.",
+		Long: `'atlas migrate apply' reads the migration state of the connected database and computes what migrations are pending.
+It then attempts to apply the pending migration files in the correct order onto the database. 
+The first argument denotes the maximum number of migration files to apply.
+As a safety measure 'atlas migrate apply' will abort with an error, if:
+  - the migration directory is not in sync with the 'atlas.sum' file
+  - the migration and database history do not match each other`,
+		Example: `  atlas migrate apply --to mysql://user:pass@localhost:3306/dbname
+  atlas migrate apply 1 --dir file:///path/to/migration/directory --to mysql://user:pass@localhost:3306/dbname`,
+		Args: cobra.MaximumNArgs(1),
+		RunE: CmdMigrateApplyRun,
+	}
 	// MigrateDiffCmd represents the 'atlas migrate diff' subcommand.
 	MigrateDiffCmd = &cobra.Command{
 		Use:   "diff",
@@ -116,8 +139,8 @@ This command should be used whenever a manual change in the migration directory
 the atlas.sum file. If there is a mismatch it will be reported. If the --dev-url flag is given, the migration files are 
 executed on the connected database in order to validate SQL semantics.`,
 		Example: `  atlas migrate validate
-  atlas migrate validate --dir /path/to/migration/directory
-  atlas migrate validate --dir /path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev`,
+  atlas migrate validate --dir file:///path/to/migration/directory
+  atlas migrate validate --dir file:///path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev`,
 		PreRunE: migrateFlagsFromEnv,
 		RunE:    CmdMigrateValidateRun,
 	}
@@ -126,6 +149,7 @@ executed on the connected database in order to validate SQL semantics.`,
 func init() {
 	// Add sub-commands.
 	Root.AddCommand(MigrateCmd)
+	MigrateCmd.AddCommand(MigrateApplyCmd)
 	MigrateCmd.AddCommand(MigrateDiffCmd)
 	MigrateCmd.AddCommand(MigrateHashCmd)
 	MigrateCmd.AddCommand(MigrateNewCmd)
@@ -134,24 +158,98 @@ func init() {
 	devURL := func(set *pflag.FlagSet) {
 		set.StringVarP(&MigrateFlags.DevURL, migrateFlagDevURL, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
 	}
+	toURL := func(set *pflag.FlagSet) {
+		set.StringVarP(&MigrateFlags.ToURL, migrateFlagTo, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
+	}
 	// Global flags.
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using URL format")
 	MigrateCmd.PersistentFlags().StringSliceVarP(&MigrateFlags.Schemas, migrateFlagSchema, "", nil, "set schema names")
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.Format, migrateFlagFormat, "", formatAtlas, "set migration file format")
 	MigrateCmd.PersistentFlags().BoolVarP(&MigrateFlags.Force, migrateFlagForce, "", false, "force a command to run on a broken migration directory state")
 	MigrateCmd.PersistentFlags().SortFlags = false
+	// Apply flags.
+	MigrateApplyCmd.Flags().StringVarP(&MigrateFlags.LogFormat, migrateFlagLog, "", logFormatTTY, "log format to use")
+	MigrateApplyCmd.Flags().StringVarP(&MigrateFlags.RevisionSchema, migrateFlagRevisionsSchema, "", "", "schema name where the revisions table is to be created")
+	toURL(MigrateApplyCmd.Flags())
 	// Diff flags.
 	devURL(MigrateDiffCmd.Flags())
-	MigrateDiffCmd.Flags().StringVarP(&MigrateFlags.ToURL, migrateDiffFlagTo, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
+	toURL(MigrateDiffCmd.Flags())
 	MigrateDiffCmd.Flags().BoolVarP(&MigrateFlags.Verbose, migrateDiffFlagVerbose, "", false, "enable verbose logging")
 	MigrateDiffCmd.Flags().SortFlags = false
 	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagDevURL))
-	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateDiffFlagTo))
+	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagTo))
 	// Validate flags.
 	devURL(MigrateValidateCmd.Flags())
 	receivesEnv(MigrateCmd)
 }
 
+// CmdMigrateApplyRun is the command executed when running the CLI with 'migrate apply' args.
+func CmdMigrateApplyRun(cmd *cobra.Command, args []string) error {
+	var (
+		n   int
+		err error
+	)
+	if len(args) > 1 {
+		n, err = strconv.Atoi(args[1])
+		if err != nil {
+			return err
+		}
+	}
+	// Open the migration directory.
+	dir, err := dir()
+	if err != nil {
+		return err
+	}
+	// Open a client to the database.
+	target, err := sqlclient.Open(cmd.Context(), MigrateFlags.ToURL)
+	if err != nil {
+		return err
+	}
+	// Get the correct log format and destination. Currently, only os.Stdout is supported.
+	l, err := logFormat(cmd.OutOrStdout())
+	if err != nil {
+		return err
+	}
+	// Currently, only in DB revisions are supported.
+	opts := []entmigrate.Option{entmigrate.WithSchema(MigrateFlags.RevisionSchema)}
+	rrw, err := entmigrate.NewEntRevisions(target, opts...)
+	if err != nil {
+		return err
+	}
+	if err := rrw.Init(cmd.Context()); err != nil {
+		return err
+	}
+	// Wrap the whole execution in one transaction. This behaviour will change once
+	// there are insights about migration files available.
+	tx, err := target.Tx(cmd.Context(), nil)
+	if err != nil {
+		return err
+	}
+	// Get the executor.
+	ex, err := migrate.NewExecutor(tx.Driver, dir, rrw, migrate.WithLogger(l))
+	if err != nil {
+		return err
+	}
+	defer func(rrw *entmigrate.EntRevisions, ctx context.Context) {
+		if err2 := rrw.Flush(ctx); err2 != nil {
+			if err != nil {
+				err = fmt.Errorf("%v: %w", err2, err)
+			} else {
+				err = err2
+			}
+		}
+	}(rrw, cmd.Context())
+	err = ex.ExecuteN(cmd.Context(), n)
+	if err != nil {
+		if err2 := tx.Rollback(); err2 != nil {
+			err = fmt.Errorf("%v: %w", err2, err)
+		}
+		return err
+	}
+	err = tx.Commit()
+	return err
+}
+
 // CmdMigrateDiffRun is the command executed when running the CLI with 'migrate diff' args.
 func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	// Open a dev driver.
@@ -248,7 +346,7 @@ func CmdMigrateValidateRun(cmd *cobra.Command, _ []string) error {
 	if err != nil {
 		return err
 	}
-	ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NoopRevisionReadWriter{})
+	ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NopRevisionReadWriter{})
 	if err != nil {
 		return err
 	}
@@ -351,6 +449,73 @@ func formatter() (migrate.Formatter, error) {
 	}
 }
 
+const (
+	logFormatTTY = "tty"
+)
+
+// LogTTY is a migrate.Logger that pretty prints execution progress.
+// If the connected out is not a tty, it will fall back to a non-colorful output.
+type LogTTY struct {
+	out         io.Writer
+	start       time.Time
+	fileStart   time.Time
+	fileCounter int
+	stmtCounter int
+}
+
+var (
+	cyan    = color.CyanString
+	yellow  = color.YellowString
+	dash    = yellow("--")
+	arr     = cyan("->")
+	indent2 = strings.Repeat(" ", 2)
+	indent4 = strings.Repeat(indent2, 2)
+)
+
+// Log implements the migrate.Logger interface.
+func (l *LogTTY) Log(e migrate.LogEntry) {
+	switch e := e.(type) {
+	case migrate.LogExecution:
+		l.start = time.Now()
+		fmt.Fprintf(l.out, "Migrating to version %v", cyan(e.To))
+		if e.From != "" {
+			fmt.Fprintf(l.out, " from %v", cyan(e.From))
+		}
+		fmt.Fprintf(l.out, " (%d migrations in total):\n", len(e.Files))
+	case migrate.LogFile:
+		l.fileCounter++
+		if !l.fileStart.IsZero() {
+			l.reportFileEnd()
+		}
+		l.fileStart = time.Now()
+		fmt.Fprintf(l.out, "\n%s%v migrating version %v\n", indent2, dash, cyan(e.Version))
+	case migrate.LogStmt:
+		l.stmtCounter++
+		fmt.Fprintf(l.out, "%s%v %s\n", indent4, arr, e.SQL)
+	case migrate.LogDone:
+		l.reportFileEnd()
+		fmt.Fprintf(l.out, "\n%s%v\n", indent2, cyan(strings.Repeat("-", 25)))
+		fmt.Fprintf(l.out, "%s%v %v\n", indent2, dash, time.Since(l.start))
+		fmt.Fprintf(l.out, "%s%v %v migrations\n", indent2, dash, l.fileCounter)
+		fmt.Fprintf(l.out, "%s%v %v sql statements\n", indent2, dash, l.stmtCounter)
+	default:
+		fmt.Fprintf(l.out, "%v", e)
+	}
+}
+
+func (l *LogTTY) reportFileEnd() {
+	fmt.Fprintf(l.out, "%s%v ok (%v)\n", indent2, dash, yellow("%s", time.Since(l.fileStart)))
+}
+
+func logFormat(out io.Writer) (migrate.Logger, error) {
+	switch MigrateFlags.LogFormat {
+	case logFormatTTY:
+		return &LogTTY{out: out}, nil
+	default:
+		return nil, fmt.Errorf("unknown log-format %q", MigrateFlags.LogFormat)
+	}
+}
+
 func migrateFlagsFromEnv(cmd *cobra.Command, _ []string) error {
 	activeEnv, err := selectEnv(GlobalFlags.SelectedEnv)
 	if err != nil {
@@ -373,7 +538,7 @@ func migrateFlagsFromEnv(cmd *cobra.Command, _ []string) error {
 		}
 		toURL = "file://" + toURL
 	}
-	if err := maySetFlag(cmd, migrateDiffFlagTo, toURL); err != nil {
+	if err := maySetFlag(cmd, migrateFlagTo, toURL); err != nil {
 		return err
 	}
 	if s := "[" + strings.Join(activeEnv.Schemas, "") + "]"; len(activeEnv.Schemas) > 0 {
diff --git a/cmd/atlascmd/migrate/migrate.go b/cmd/atlascmd/migrate/migrate.go
index b7e32673..a0e408f9 100644
--- a/cmd/atlascmd/migrate/migrate.go
+++ b/cmd/atlascmd/migrate/migrate.go
@@ -13,6 +13,7 @@ import (
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
+	"entgo.io/ent/dialect"
 	"entgo.io/ent/dialect/sql"
 	entschema "entgo.io/ent/dialect/sql/schema"
 )
@@ -20,10 +21,11 @@ import (
 type (
 	// A EntRevisions provides implementation for the migrate.RevisionReadWriter interface.
 	EntRevisions struct {
-		ac     *sqlclient.Client // underlying Atlas client
-		sc     *sqlclient.Client // underlying Atlas client connected to the named schema
-		ec     *ent.Client       // underlying Ent client
-		schema string            // name of the schema the revision table resides in
+		ac     *sqlclient.Client  // underlying Atlas client
+		sc     *sqlclient.Client  // underlying Atlas client connected to the named schema
+		ec     *ent.Client        // underlying Ent client
+		schema string             // name of the schema the revision table resides in
+		cache  []migrate.Revision // cache stores writes to Ent for blocked connections (like in SQLite).
 	}
 
 	// Option allows to configure EntRevisions by using functional arguments.
@@ -88,6 +90,8 @@ func (r *EntRevisions) Init(ctx context.Context) error {
 }
 
 // ReadRevisions reads the revisions from the revisions table.
+//
+// ReadRevisions will not return results only saved to cache.
 func (r *EntRevisions) ReadRevisions(ctx context.Context) (migrate.Revisions, error) {
 	revs, err := r.ec.Revision.Query().Order(ent.Asc(revision.FieldID)).All(ctx)
 	if err != nil {
@@ -111,6 +115,32 @@ func (r *EntRevisions) ReadRevisions(ctx context.Context) (migrate.Revisions, er
 
 // WriteRevision writes a revision to the revisions table.
 func (r *EntRevisions) WriteRevision(ctx context.Context, rev *migrate.Revision) error {
+	if r.useCache() {
+		// Do not store the pointer since we want to maintain the order for writes to the database.
+		r.cache = append(r.cache, *rev)
+		return nil
+	}
+	return r.write(ctx, rev)
+}
+
+// Flush writes the changes saved in memory to the database.
+//
+// This method exists to support both execution of migration in a transaction and saving revision for SQLite flavors,
+// since attempting to write to the database while in a transaction will fail there.
+func (r *EntRevisions) Flush(ctx context.Context) error {
+	if !r.useCache() {
+		return nil
+	}
+	for i := range r.cache {
+		if err := r.write(ctx, &r.cache[i]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// write attempts to write the given revision to the database.
+func (r *EntRevisions) write(ctx context.Context, rev *migrate.Revision) error {
 	return r.ec.Revision.Create().
 		SetID(rev.Version).
 		SetDescription(rev.Description).
@@ -125,4 +155,10 @@ func (r *EntRevisions) WriteRevision(ctx context.Context, rev *migrate.Revision)
 		Exec(ctx)
 }
 
+func (r *EntRevisions) useCache() bool {
+	// For SQLite dialect and flavors we have to enable the revision write cache to postpone writing to
+	// the database until the transaction wrapping the migration execution has been committed.
+	return r.ac.Name == dialect.SQLite
+}
+
 var _ migrate.RevisionReadWriter = (*EntRevisions)(nil)
diff --git a/doc/md/cli/reference.md b/doc/md/cli/reference.md
index 1b6952b6..cdf98e8b 100644
--- a/doc/md/cli/reference.md
+++ b/doc/md/cli/reference.md
@@ -68,6 +68,38 @@ atlas migrate
 ```
 
 
+### atlas migrate apply
+
+Applies pending migration files on the connected database.
+
+#### Usage
+```
+atlas migrate apply [flags]
+```
+
+#### Details
+'atlas migrate apply' reads the migration state of the connected database and computes what migrations are pending.
+It then attempts to apply the pending migration files in the correct order onto the database. 
+The first argument denotes the maximum number of migration files to apply.
+As a safety measure 'atlas migrate apply' will abort with an error, if:
+  - the migration directory is not in sync with the 'atlas.sum' file
+  - the migration and database history do not match each other
+
+#### Example
+
+```
+  atlas migrate apply --to mysql://user:pass@localhost:3306/dbname
+  atlas migrate apply 1 --dir file:///path/to/migration/directory --to mysql://user:pass@localhost:3306/dbname
+```
+#### Flags
+```
+      --log string                log format to use (default "tty")
+      --revisions-schema string   schema name where the revisions table is to be created
+      --to string                 [driver://username:password@address/dbname?param=value] select a data source using the URL format
+
+```
+
+
 ### atlas migrate diff
 
 Compute the diff between the migration directory and a connected database and create a new migration file.
@@ -152,8 +184,8 @@ executed on the connected database in order to validate SQL semantics.
 
 ```
   atlas migrate validate
-  atlas migrate validate --dir /path/to/migration/directory
-  atlas migrate validate --dir /path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev
+  atlas migrate validate --dir file:///path/to/migration/directory
+  atlas migrate validate --dir file:///path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev
 ```
 #### Flags
 ```
diff --git a/go.mod b/go.mod
index 341477a9..c65bc537 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.17
 require (
 	entgo.io/ent v0.10.2-0.20220502113020-4ac82f5bb3f0
 	github.com/DATA-DOG/go-sqlmock v1.5.0
+	github.com/fatih/color v1.13.0
 	github.com/go-openapi/inflect v0.19.0
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/hashicorp/hcl/v2 v2.10.0
@@ -28,6 +29,8 @@ require (
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
+	github.com/mattn/go-colorable v0.1.9 // indirect
+	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
diff --git a/go.sum b/go.sum
index d6da9f5b..68043dcc 100644
--- a/go.sum
+++ b/go.sum
@@ -14,11 +14,14 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
 github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -49,6 +52,11 @@ github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
 github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
 github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
+github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
 github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -57,6 +65,7 @@ github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzC
 github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -89,6 +98,9 @@ golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5h
 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=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -97,6 +109,7 @@ 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.1.9-0.20211216111533-8d383106f7e7 h1:M1gcVrIb2lSn2FIL19DG0+/b8nNVKJ7W7b4WcAGZAYM=
 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=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@@ -104,6 +117,7 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
diff --git a/internal/integration/go.sum b/internal/integration/go.sum
index f004c973..b4cc7ef4 100644
--- a/internal/integration/go.sum
+++ b/internal/integration/go.sum
@@ -28,6 +28,7 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -81,6 +82,8 @@ github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
 github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
 github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
+github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
 github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -162,6 +165,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 1b32ed07..10b58dd6 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -262,7 +262,7 @@ func DisableChecksum() PlannerOption {
 // 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) {
-	ex, err := NewExecutor(p.drv, p.dir, NoopRevisionReadWriter{})
+	ex, err := NewExecutor(p.drv, p.dir, NopRevisionReadWriter{})
 	if err != nil {
 		return nil, err
 	}
@@ -286,10 +286,12 @@ func (p *Planner) Plan(ctx context.Context, name string, to StateReader) (*Plan,
 
 // WritePlan writes the given Plan to the Dir based on the configured Formatter.
 func (p *Planner) WritePlan(plan *Plan) error {
+	// Format the plan into files.
 	files, err := p.fmt.Format(plan)
 	if err != nil {
 		return err
 	}
+	// Store the files in the migration directory.
 	for _, f := range files {
 		d, err := io.ReadAll(f)
 		if err != nil {
@@ -299,6 +301,7 @@ func (p *Planner) WritePlan(plan *Plan) error {
 			return err
 		}
 	}
+	// If enabled, update the sum file.
 	if p.sum {
 		sum, err := HashSum(p.dir)
 		if err != nil {
@@ -334,7 +337,7 @@ func NewExecutor(drv Driver, dir Dir, rrw RevisionReadWriter, opts ...ExecutorOp
 		return nil, errors.New("sql/migrate: execute: dir cannot be nil")
 	}
 	if rrw == nil {
-		return nil, errors.New("sql/migrate: execute: mockRevisionReadWriter cannot be nil")
+		return nil, errors.New("sql/migrate: execute: rrw cannot be nil")
 	}
 	// If the driver does not support acquiring a lock, don't execute migrations as this can potentially be fatal.
 	if _, ok := drv.(schema.Locker); !ok {
@@ -449,9 +452,9 @@ func (e *Executor) Execute(ctx context.Context, m File) (err error) {
 	if e.log != nil {
 		e.log.Log(LogFile{v, d})
 	}
-	hf, err := readHashFile(e.dir)
+	hf, err := HashSum(e.dir)
 	if err != nil {
-		return fmt.Errorf("sql/migrate: execute: read atlas.sum file: %w", err)
+		return fmt.Errorf("sql/migrate: execute: create hash file: %w", err)
 	}
 	h, err := hf.sumByName(m.Name())
 	if err != nil {
@@ -499,14 +502,30 @@ func (e *Executor) ExecuteN(ctx context.Context, n int) error {
 		for i := range pending {
 			names[i] = pending[i].Name()
 		}
-		e.log.Log(LogExecution{names})
+		revs, err := e.rrw.ReadRevisions(ctx)
+		if err != nil {
+			return fmt.Errorf("sql/migrate: execute: read revisions: %w", err)
+		}
+		last := pending[len(pending)-1]
+		v, err := e.dir.(Scanner).Version(last)
+		if err != nil {
+			return fmt.Errorf("sql/migrate: execute: scan version from %q: %w", last.Name(), err)
+		}
+		l := LogExecution{To: v, Files: names}
+		if len(revs) > 0 {
+			l.From = revs[len(revs)-1].Version
+		}
+		e.log.Log(l)
 	}
 	for _, m := range pending {
 		if err := e.Execute(ctx, m); err != nil {
 			return err
 		}
 	}
-	return nil
+	if e.log != nil {
+		e.log.Log(LogDone{})
+	}
+	return err
 }
 
 // ErrNotClean is returned when the connected dev-db is not in a clean state (aka it has schemas and tables).
@@ -526,7 +545,7 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	if c, ok := e.drv.(interface {
 		// The IsClean method can be added to a Driver to override how to
 		// determine if a connected database is in a clean state.
-		// This interface exists only to support SQLite favors.
+		// This interface exists solely to support SQLite flavors.
 		IsClean(context.Context) (bool, error)
 	}); ok {
 		e, err := c.IsClean(ctx)
@@ -549,7 +568,7 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	defer func() {
 		if e, ok := e.drv.(interface {
 			// The Clean method can be added to a Driver to change how to clean a database.
-			// This interface exists only to support SQLite favors.
+			// This interface exists solely to support SQLite flavors.
 			Clean(context.Context) error
 		}); ok {
 			if derr := e.Clean(ctx); derr != nil {
@@ -578,21 +597,21 @@ func (e *Executor) ReadState(ctx context.Context) (realm *schema.Realm, err erro
 	return
 }
 
-// NoopRevisionReadWriter is a RevisionsReadWriter that does nothing.
+// NopRevisionReadWriter is a RevisionsReadWriter that does nothing.
 // It is useful for one-time replay of the migration directory.
-type NoopRevisionReadWriter struct{}
+type NopRevisionReadWriter struct{}
 
 // ReadRevisions implements RevisionsReadWriter.ReadRevisions,
-func (NoopRevisionReadWriter) ReadRevisions(context.Context) (Revisions, error) {
+func (NopRevisionReadWriter) ReadRevisions(context.Context) (Revisions, error) {
 	return nil, nil
 }
 
 // WriteRevision implements RevisionsReadWriter.WriteRevision,
-func (NoopRevisionReadWriter) WriteRevision(context.Context, *Revision) error {
+func (NopRevisionReadWriter) WriteRevision(context.Context, *Revision) error {
 	return nil
 }
 
-var _ RevisionReadWriter = (*NoopRevisionReadWriter)(nil)
+var _ RevisionReadWriter = (*NopRevisionReadWriter)(nil)
 
 // done computes and sets the ExecutionTime.
 func (r *Revision) done(ok bool) {
@@ -630,6 +649,11 @@ type (
 	// LogExecution is sent once when execution of multiple migration files has been started.
 	// It holds the filenames of the pending migration files.
 	LogExecution struct {
+		// From what version.
+		From string
+		// To what version.
+		To string
+		// Migration Files to be executed.
 		Files []string
 	}
 
@@ -645,11 +669,15 @@ type (
 	LogStmt struct {
 		SQL string
 	}
+
+	// LogDone is sent if the execution is done.
+	LogDone struct{}
 )
 
 func (LogExecution) logEntry() {}
 func (LogFile) logEntry()      {}
 func (LogStmt) logEntry()      {}
+func (LogDone) logEntry()      {}
 
 // LocalDir implements Dir for a local path. It implements the Scanner interface compatible with
 // migration files generated by the DefaultFormatter.
diff --git a/sql/schema/schema.go b/sql/schema/schema.go
index 287e5089..5656388a 100644
--- a/sql/schema/schema.go
+++ b/sql/schema/schema.go
@@ -282,7 +282,7 @@ type (
 		Text string
 	}
 
-	// Charset describes a column or a table character set setting.
+	// Charset describes a column or a table character-set setting.
 	Charset struct {
 		V string
 	}
diff --git a/sql/sqlcheck/sqlcheck.go b/sql/sqlcheck/sqlcheck.go
new file mode 100644
index 00000000..c6a1d573
--- /dev/null
+++ b/sql/sqlcheck/sqlcheck.go
@@ -0,0 +1,83 @@
+// 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 sqlcheck
+
+import (
+	"context"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlclient"
+)
+
+type (
+	// An Analyzer describes a migration file analyzer.
+	Analyzer interface {
+		// Analyze executes the analysis function.
+		Analyze(context.Context, *Pass) error
+	}
+
+	// A Pass provides information to the Run function that
+	// applies a specific analyzer to an SQL file.
+	Pass struct {
+		// A migration file and the changes it describes.
+		File *File
+
+		// Dev is a driver-specific environment used to execute analysis work.
+		Dev *sqlclient.Client
+
+		// Report reports a diagnostic
+		Report Reporter
+	}
+
+	// File represents a parsed version of a migration file.
+	File struct {
+		migrate.File
+		// Changes is the list of changes this file represents.
+		Changes []*Change
+	}
+
+	// A Change in a migration file.
+	Change struct {
+		schema.Changes        // The actual changes.
+		Stmt           string // The SQL statement generated this change.
+		Pos            int    // The position of the statement in the file.
+	}
+
+	// A Diagnostic is a message associated with a source location or range.
+	Diagnostic struct {
+		Pos     int    // Diagnostic position.
+		Message string // Diagnostic message.
+	}
+
+	// Reporter represents a diagnostic reporter.
+	Reporter interface {
+		Report(Diagnostic)
+	}
+)
+
+// Analyzers implements Analyzer.
+type Analyzers []Analyzer
+
+// Analyze implements Analyzer.
+func (a Analyzers) Analyze(ctx context.Context, p *Pass) error {
+	for _, a := range a {
+		if err := a.Analyze(ctx, p); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ReporterFunc is a function that implements Reporter.
+type ReporterFunc func(Diagnostic)
+
+// Report calls f(d).
+func (f ReporterFunc) Report(d Diagnostic) {
+	f(d)
+}
+
+// NopReporter is a Reporter that does nothing.
+var NopReporter Reporter = ReporterFunc(func(Diagnostic) {})
diff --git a/sql/sqlclient/client.go b/sql/sqlclient/client.go
index 4e050ded..729a5022 100644
--- a/sql/sqlclient/client.go
+++ b/sql/sqlclient/client.go
@@ -43,6 +43,9 @@ type (
 		// and encoding the schema documents.
 		schemaspec.Marshaler
 		schemahcl.Evaluator
+
+		// A func to open a migrate.Driver with a given schema.ExecQuerier. Used when creating a TxClient.
+		openDriver func(schema.ExecQuerier) (migrate.Driver, error)
 	}
 
 	// TxClient is returned by calling Client.Tx. It behaves the same as Client,
@@ -69,17 +72,16 @@ type (
 
 // Tx returns a transactional client.
 func (c *Client) Tx(ctx context.Context, opts *sql.TxOptions) (*TxClient, error) {
+	if c.openDriver == nil {
+		return nil, errors.New("sql/sqlclient: unexpected driver opener: <nil>")
+	}
 	tx, err := c.DB.BeginTx(ctx, opts)
 	if err != nil {
 		return nil, fmt.Errorf("sql/sqlclient: starting transaction: %w", err)
 	}
-	v, ok := drivers.Load(c.Name)
-	if !ok {
-		return nil, fmt.Errorf("sql/sqlclient: unexpected missing opener %q", c.Name)
-	}
-	drv, err := v.(*driver).openDriver(tx)
+	drv, err := c.openDriver(tx)
 	if err != nil {
-		return nil, fmt.Errorf("sql/sqlclient: opengin atlas driver: %w", err)
+		return nil, fmt.Errorf("sql/sqlclient: opening atlas driver: %w", err)
 	}
 	ic := *c
 	ic.Driver = drv
@@ -139,9 +141,8 @@ type (
 
 	driver struct {
 		Opener
-		name       string
-		parser     URLParser
-		openDriver func(schema.ExecQuerier) (migrate.Driver, error)
+		name   string
+		parser URLParser
 	}
 )
 
@@ -265,6 +266,7 @@ func RegisterCodec(m schemaspec.Marshaler, e schemahcl.Evaluator) RegisterOption
 
 // RegisterDriverOpener registers a func to create a migrate.Driver from a schema.ExecQuerier.
 // Registering this function is implicitly done when using DriverOpener.
+// The passed opener is used when creating a TxClient.
 func RegisterDriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) RegisterOption {
 	return func(opts *registerOptions) {
 		opts.openDriver = open
@@ -278,9 +280,6 @@ func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) Opener
 		if !ok {
 			return nil, fmt.Errorf("sql/sqlclient: unexpected missing opener %q", u.Scheme)
 		}
-		if v.(*driver).openDriver == nil {
-			v.(*driver).openDriver = open
-		}
 		ur := v.(*driver).parser.ParseURL(u)
 		db, err := sql.Open(v.(*driver).name, ur.DSN)
 		if err != nil {
@@ -294,10 +293,11 @@ func DriverOpener(open func(schema.ExecQuerier) (migrate.Driver, error)) Opener
 			return nil, err
 		}
 		return &Client{
-			Name:   v.(*driver).name,
-			DB:     db,
-			URL:    ur,
-			Driver: drv,
+			Name:       v.(*driver).name,
+			DB:         db,
+			URL:        ur,
+			Driver:     drv,
+			openDriver: open,
 		}, nil
 	})
 }
@@ -325,7 +325,19 @@ func Register(name string, opener Opener, opts ...RegisterOption) {
 			return c, nil
 		})
 	}
-	drv := &driver{opener, name, opt.parser, opt.openDriver}
+	// If there was a driver opener registered by a call to RegisterDriverOpener, it has precedence.
+	if opt.openDriver != nil {
+		f := opener
+		opener = OpenerFunc(func(ctx context.Context, u *url.URL) (*Client, error) {
+			c, err := f.Open(ctx, u)
+			if err != nil {
+				return nil, err
+			}
+			c.openDriver = opt.openDriver
+			return c, err
+		})
+	}
+	drv := &driver{opener, name, opt.parser}
 	for _, f := range append(opt.flavours, name) {
 		if _, ok := drivers.Load(f); ok {
 			panic("sql/sqlclient: Register called twice for " + f)


Test patch:
diff --git a/cmd/atlasci/internal/ci/run_test.go b/cmd/atlasci/internal/ci/run_test.go
new file mode 100644
index 00000000..158dad3f
--- /dev/null
+++ b/cmd/atlasci/internal/ci/run_test.go
@@ -0,0 +1,62 @@
+// 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 ci_test
+
+import (
+	"context"
+	"testing"
+
+	"ariga.io/atlas/cmd/atlasci/internal/ci"
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlclient"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRunner_Run(t *testing.T) {
+	ctx := context.Background()
+	c, err := sqlclient.Open(ctx, "sqlite://run?mode=memory&cache=shared&_fk=1")
+	require.NoError(t, err)
+	r := &ci.Runner{
+		Scan: testDir{},
+		Dev:  c,
+		ChangeDetector: testDetector{
+			base: []migrate.File{
+				testFile{name: "1.sql", content: "CREATE TABLE users (id INT)"},
+			},
+			feat: []migrate.File{
+				testFile{name: "2.sql", content: "CREATE TABLE pets (id INT)\nDROP TABLE users"},
+			},
+		},
+		Analyzer: &testAnalyzer{},
+		Reporter: sqlcheck.NopReporter,
+	}
+	require.NoError(t, r.Run(ctx))
+
+	passes := r.Analyzer.(*testAnalyzer).passes
+	require.Len(t, passes, 1)
+	changes := passes[0].File.Changes
+	require.Len(t, changes, 2)
+	require.Equal(t, "CREATE TABLE pets (id INT)", changes[0].Stmt)
+	require.Equal(t, "DROP TABLE users", changes[1].Stmt)
+}
+
+type testAnalyzer struct {
+	passes []*sqlcheck.Pass
+}
+
+func (t *testAnalyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
+	t.passes = append(t.passes, p)
+	return nil
+}
+
+type testDetector struct {
+	base, feat []migrate.File
+}
+
+func (t testDetector) DetectChanges(context.Context) ([]migrate.File, []migrate.File, error) {
+	return t.base, t.feat, nil
+}
diff --git a/cmd/atlascmd/migrate/migrate_test.go b/cmd/atlascmd/migrate/migrate_test.go
index cc7e6060..1064367d 100644
--- a/cmd/atlascmd/migrate/migrate_test.go
+++ b/cmd/atlascmd/migrate/migrate_test.go
@@ -9,8 +9,10 @@ import (
 	"fmt"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"ariga.io/atlas/cmd/atlascmd/migrate/ent/revision"
+	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/sqlclient"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/stretchr/testify/require"
@@ -33,3 +35,33 @@ func TestEntRevisions_Init(t *testing.T) {
 	_, ok := s.Table(revision.Table)
 	require.True(t, ok)
 }
+
+func TestEntRevisions_Flush(t *testing.T) {
+	ctx := context.Background()
+	c, err := sqlclient.Open(
+		context.Background(),
+		fmt.Sprintf("sqlite://%s?cache=shared&mode=memory&_fk=true", filepath.Join(t.TempDir(), "revision")),
+	)
+	require.NoError(t, err)
+	r, err := NewEntRevisions(c)
+	require.NoError(t, err)
+	require.True(t, r.useCache())
+	require.NoError(t, r.Init(ctx))
+
+	// Writing will only fill the cache.
+	require.NoError(t, r.WriteRevision(ctx, &migrate.Revision{
+		Version:        "version",
+		ExecutedAt:     time.Now(),
+		ExecutionState: "ongoing",
+	}))
+	require.Len(t, r.cache, 1)
+	revs, err := r.ReadRevisions(ctx)
+	require.NoError(t, err)
+	require.Len(t, revs, 0)
+
+	// Flushing will save the cached revision.
+	require.NoError(t, r.Flush(ctx))
+	revs, err = r.ReadRevisions(ctx)
+	require.NoError(t, err)
+	require.Len(t, revs, 1)
+}
diff --git a/cmd/atlascmd/migrate_test.go b/cmd/atlascmd/migrate_test.go
index 93fcdf79..45c4092c 100644
--- a/cmd/atlascmd/migrate_test.go
+++ b/cmd/atlascmd/migrate_test.go
@@ -20,6 +20,7 @@ import (
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
+	"ariga.io/atlas/sql/sqlite"
 	_ "ariga.io/atlas/sql/sqlite"
 
 	_ "github.com/mattn/go-sqlite3"
@@ -31,6 +32,81 @@ func TestMigrate(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestMigrate_Apply(t *testing.T) {
+	p := t.TempDir()
+
+	// Fails on empty directory.
+	_, err := runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://"+p,
+		"--to", openSQLite(t, ""),
+	)
+	require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+
+	// Fails on directory without sum file.
+	require.NoError(t, os.Rename(
+		filepath.FromSlash("testdata/sqlite/atlas.sum"),
+		filepath.FromSlash("testdata/sqlite/atlas.sum.bak"),
+	))
+	t.Cleanup(func() {
+		os.Rename(filepath.FromSlash("testdata/sqlite/atlas.sum.bak"), filepath.FromSlash("testdata/sqlite/atlas.sum"))
+	})
+
+	_, err = runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://testdata/sqlite",
+		"--to", openSQLite(t, ""),
+	)
+	require.ErrorIs(t, err, migrate.ErrChecksumNotFound)
+	require.NoError(t, os.Rename(
+		filepath.FromSlash("testdata/sqlite/atlas.sum.bak"),
+		filepath.FromSlash("testdata/sqlite/atlas.sum"),
+	))
+
+	// A lock will prevent execution.
+	sqlclient.Register(
+		"sqlitelockapply",
+		sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
+			client, err := sqlclient.Open(ctx, strings.Replace(u.String(), u.Scheme, "sqlite", 1))
+			if err != nil {
+				return nil, err
+			}
+			client.Driver = &sqliteLockerDriver{client.Driver}
+			return client, nil
+		}),
+		sqlclient.RegisterDriverOpener(func(db schema.ExecQuerier) (migrate.Driver, error) {
+			drv, err := sqlite.Open(db)
+			if err != nil {
+				return nil, err
+			}
+			return &sqliteLockerDriver{drv}, nil
+		}),
+	)
+	f, err := os.Create(filepath.Join(p, "test.db"))
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	s, err := runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://testdata/sqlite",
+		"--to", fmt.Sprintf("sqlitelockapply://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
+	)
+	require.ErrorIs(t, err, errLock)
+	require.True(t, strings.HasPrefix(s, "Error: sql/migrate: acquiring database lock: "+errLock.Error()))
+
+	// Will work and print stuff to the console.
+	s, err = runCmd(
+		Root, "migrate", "apply",
+		"--dir", "file://testdata/sqlite",
+		"--to", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
+	)
+	require.NoError(t, err)
+	require.Contains(t, s, "20220318104614")                         // log to version
+	require.Contains(t, s, "CREATE TABLE tbl (`col` int NOT NULL);") // logs statement
+	require.Contains(t, s, "1 migrations")                           // logs amount of migrations
+	require.Contains(t, s, "1 sql statements")                       // logs amount of statement
+}
+
 func TestMigrate_Diff(t *testing.T) {
 	p := t.TempDir()
 
@@ -59,7 +135,7 @@ func TestMigrate_Diff(t *testing.T) {
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
 
 	// A lock will prevent diffing.
-	sqlclient.Register("sqlitelock", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
+	sqlclient.Register("sqlitelockdiff", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) {
 		client, err := sqlclient.Open(ctx, strings.Replace(u.String(), u.Scheme, "sqlite", 1))
 		if err != nil {
 			return nil, err
@@ -74,11 +150,11 @@ func TestMigrate_Diff(t *testing.T) {
 		Root, "migrate", "diff",
 		"name",
 		"--dir", "file://"+t.TempDir(),
-		"--dev-url", fmt.Sprintf("sqlitelock://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
+		"--dev-url", fmt.Sprintf("sqlitelockdiff://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
 		"--to", hclURL(t),
 	)
-	require.True(t, strings.HasPrefix(s, "Error: "+lockErr))
-	require.EqualError(t, err, lockErr)
+	require.True(t, strings.HasPrefix(s, "Error: "+errLock.Error()))
+	require.ErrorIs(t, err, errLock)
 }
 
 func TestMigrate_New(t *testing.T) {
@@ -127,10 +203,10 @@ func TestMigrate_New(t *testing.T) {
 	require.FileExists(t, filepath.Join(p, v+"_liquibase.sql"))
 	require.Equal(t, 9, countFiles(t, p))
 
-	f := filepath.Join("testdata", "new.sql")
+	f := filepath.Join("testdata", "mysql", "new.sql")
 	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
 	t.Cleanup(func() { os.Remove(f) })
-	s, err = runCmd(Root, "migrate", "new", "--dir", "file://testdata")
+	s, err = runCmd(Root, "migrate", "new", "--dir", "file://testdata/mysql")
 	require.NotZero(t, s)
 	require.Error(t, err)
 }
@@ -138,14 +214,14 @@ func TestMigrate_New(t *testing.T) {
 func TestMigrate_Validate(t *testing.T) {
 	// Without re-playing.
 	MigrateFlags.DevURL = "" // global flags are set from other tests ...
-	s, err := runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
+	s, err := runCmd(Root, "migrate", "validate", "--dir", "file://testdata/mysql")
 	require.Zero(t, s)
 	require.NoError(t, err)
 
-	f := filepath.Join("testdata", "new.sql")
+	f := filepath.Join("testdata", "mysql", "new.sql")
 	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
 	t.Cleanup(func() { os.Remove(f) })
-	s, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
+	s, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata/mysql")
 	require.NotZero(t, s)
 	require.Error(t, err)
 	require.NoError(t, os.Remove(f))
@@ -165,17 +241,17 @@ func TestMigrate_Validate(t *testing.T) {
 	require.NoError(t, err)
 
 	// Should fail since the files are not compatible with SQLite.
-	_, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata", "--dev-url", openSQLite(t, ""))
+	_, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata/mysql", "--dev-url", openSQLite(t, ""))
 	require.Error(t, err)
 }
 
 func TestMigrate_Hash(t *testing.T) {
-	s, err := runCmd(Root, "migrate", "hash", "--dir", "file://testdata")
+	s, err := runCmd(Root, "migrate", "hash", "--dir", "file://testdata/mysql")
 	require.Zero(t, s)
 	require.NoError(t, err)
 
 	p := t.TempDir()
-	err = copyFile(filepath.Join("testdata", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"))
+	err = copyFile(filepath.Join("testdata", "mysql", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"))
 	require.NoError(t, err)
 
 	s, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+p, "--force")
@@ -194,7 +270,7 @@ func TestMigrate_Hash(t *testing.T) {
 
 	p = t.TempDir()
 	require.NoError(t, copyFile(
-		filepath.Join("testdata", "20220318104614_initial.sql"),
+		filepath.Join("testdata", "mysql", "20220318104614_initial.sql"),
 		filepath.Join(p, "20220318104614_initial.sql"),
 	))
 	s, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+os.Getenv("MIGRATION_DIR"))
@@ -305,10 +381,10 @@ func copyFile(src, dst string) error {
 
 type sqliteLockerDriver struct{ migrate.Driver }
 
-const lockErr = "lockErr"
+var errLock = errors.New("lockErr")
 
 func (d *sqliteLockerDriver) Lock(context.Context, string, time.Duration) (schema.UnlockFunc, error) {
-	return func() error { return nil }, errors.New(lockErr)
+	return func() error { return nil }, errLock
 }
 
 func countFiles(t *testing.T, p string) int {
diff --git a/cmd/atlascmd/testdata/atlas.sum b/cmd/atlascmd/testdata/atlas.sum
deleted file mode 100644
index 822b27e5..00000000
--- a/cmd/atlascmd/testdata/atlas.sum
+++ /dev/null
@@ -1,2 +0,0 @@
-h1:CuXS3DZEf7JpEt4+3+YB0nDZtOK6mSZDnajdwfY1COM=
-20220318104614_initial.sql h1:EoDHPlX7fTGn5qiCdR5xhwFh+DrOi3cQ7Y49BsIy97k=
diff --git a/cmd/atlascmd/testdata/20220318104614_initial.sql b/cmd/atlascmd/testdata/mysql/20220318104614_initial.sql
similarity index 100%
rename from cmd/atlascmd/testdata/20220318104614_initial.sql
rename to cmd/atlascmd/testdata/mysql/20220318104614_initial.sql
diff --git a/cmd/atlascmd/testdata/mysql/20220420213403_second.sql b/cmd/atlascmd/testdata/mysql/20220420213403_second.sql
new file mode 100644
index 00000000..b829d7b4
--- /dev/null
+++ b/cmd/atlascmd/testdata/mysql/20220420213403_second.sql
@@ -0,0 +1,1 @@
+ALTER TABLE `atlantis`.`tbl` ADD `col_2` TEXT;
diff --git a/cmd/atlascmd/testdata/mysql/atlas.sum b/cmd/atlascmd/testdata/mysql/atlas.sum
new file mode 100644
index 00000000..0d6a1fba
--- /dev/null
+++ b/cmd/atlascmd/testdata/mysql/atlas.sum
@@ -0,0 +1,3 @@
+h1:EGX5/CEEerpLWqYQNHB1veTXon8t05wEGJiX2fOtFXg=
+20220318104614_initial.sql h1:EoDHPlX7fTGn5qiCdR5xhwFh+DrOi3cQ7Y49BsIy97k=
+20220420213403_second.sql h1:cAioQjDgJkOIiMAyEwqaurPs0EHpTeu0CnWlJzNj5SE=
diff --git a/cmd/atlascmd/testdata/sqlite/20220318104614_initial.sql b/cmd/atlascmd/testdata/sqlite/20220318104614_initial.sql
new file mode 100644
index 00000000..eaf12fab
--- /dev/null
+++ b/cmd/atlascmd/testdata/sqlite/20220318104614_initial.sql
@@ -0,0 +1,2 @@
+-- create "tbl" table
+CREATE TABLE tbl (`col` int NOT NULL);
diff --git a/cmd/atlascmd/testdata/sqlite/atlas.sum b/cmd/atlascmd/testdata/sqlite/atlas.sum
new file mode 100644
index 00000000..50c6c028
--- /dev/null
+++ b/cmd/atlascmd/testdata/sqlite/atlas.sum
@@ -0,0 +1,2 @@
+h1:lk7K9/v0zF26ssxQzMbXGJn1lIIO1mO77MHp9oQ763s=
+20220318104614_initial.sql h1:FifWjY2X0g2YVnb18Qm+QBPvoldDOOob7bS0LrFuCXc=
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 498cded3..887cef99 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -190,11 +190,11 @@ func TestExecutor_ReadState(t *testing.T) {
 	require.NoError(t, err)
 
 	// Locking not supported.
-	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NoopRevisionReadWriter{})
+	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NopRevisionReadWriter{})
 	require.ErrorIs(t, err, migrate.ErrLockUnsupported)
 
 	drv := &lockMockDriver{&mockDriver{}}
-	ex, err := migrate.NewExecutor(drv, d, migrate.NoopRevisionReadWriter{})
+	ex, err := migrate.NewExecutor(drv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 
 	_, err = ex.ReadState(ctx)
@@ -223,7 +223,7 @@ func TestExecutor_ReadState(t *testing.T) {
 
 	// Works.
 	edrv := &emptyMockDriver{drv}
-	ex, err = migrate.NewExecutor(edrv, d, migrate.NoopRevisionReadWriter{})
+	ex, err = migrate.NewExecutor(edrv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 	_, err = ex.ReadState(ctx)
 	require.NoError(t, err)
@@ -308,7 +308,7 @@ func TestExecutor(t *testing.T) {
 	dir, err := migrate.NewLocalDir(t.TempDir())
 	require.NoError(t, err)
 	ex, err = migrate.NewExecutor(&mockDriver{}, dir, nil)
-	require.EqualError(t, err, "sql/migrate: execute: mockRevisionReadWriter cannot be nil")
+	require.EqualError(t, err, "sql/migrate: execute: rrw cannot be nil")
 	require.Nil(t, ex)
 
 	// Does not work if no locking mechanism is provided.
@@ -355,12 +355,13 @@ func TestExecutor(t *testing.T) {
 	})
 	requireEqualRevisions(t, migrate.Revisions{rev1, rev2}, migrate.Revisions(*rrw))
 	require.Equal(t, []migrate.LogEntry{
-		migrate.LogExecution{Files: []string{"1.a_sub.up.sql", "2.10.x-20_description.sql"}},
+		migrate.LogExecution{To: "2.10.x-20", Files: []string{"1.a_sub.up.sql", "2.10.x-20_description.sql"}},
 		migrate.LogFile{Version: "1.a", Desc: "sub.up"},
 		migrate.LogStmt{SQL: "CREATE TABLE t_sub(c int);"},
 		migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c1 int;"},
 		migrate.LogFile{Version: "2.10.x-20", Desc: "description"},
 		migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c2 int;"},
+		migrate.LogDone{},
 	}, []migrate.LogEntry(*log))
 	require.Equal(t, drv.lockCounter, 1)
 	require.Equal(t, drv.unlockCounter, 1)