Repository: ariga/atlas
Pull request #2287 (2023-11-15 18:16:54)

sql/migrate: support controlling the execution order
Also, changing the default behavior to error (instead of skip) in case non-linear history

(cmd tests will be pushed soon)



Patch:
diff --git a/.github/workflows/ci-go_oss.yaml b/.github/workflows/ci-go_oss.yaml
index 85410cc2..e8ab6cb9 100644
--- a/.github/workflows/ci-go_oss.yaml
+++ b/.github/workflows/ci-go_oss.yaml
@@ -51,7 +51,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: [ "1.20", "1.21" ]
+        go: [ "1.21" ]
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-go@v4
diff --git a/cmd/atlas/internal/cmdapi/cmdapi.go b/cmd/atlas/internal/cmdapi/cmdapi.go
index 8804884e..49ca705a 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi.go
@@ -304,6 +304,7 @@ const (
 	flagSchemaShort    = "s"
 	flagTo             = "to"
 	flagTxMode         = "tx-mode"
+	flagExecOrder      = "exec-order"
 	flagURL            = "url"
 	flagURLShort       = "u"
 	flagVar            = "var"
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index fc5b5751..80936ea4 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -57,17 +57,29 @@ type migrateApplyFlags struct {
 	allowDirty      bool   // allow working on a database that already has resources
 	baselineVersion string // apply with this version as baseline
 	txMode          string // (none, file, all)
+	execOrder       string // (linear, linear-skip, non-linear)
 	context         string // Run context. See cloudapi.DeployContextInput.
 }
 
-func (f *migrateApplyFlags) migrateOptions() (opts []migrate.ExecutorOption) {
+func (f *migrateApplyFlags) migrateOptions() ([]migrate.ExecutorOption, error) {
+	var opts []migrate.ExecutorOption
 	if f.allowDirty {
 		opts = append(opts, migrate.WithAllowDirty(true))
 	}
 	if v := f.baselineVersion; v != "" {
 		opts = append(opts, migrate.WithBaselineVersion(v))
 	}
-	return
+	if v := f.execOrder; v != "" && v != execOrderLinear {
+		switch v {
+		case execOrderLinearSkip:
+			opts = append(opts, migrate.WithExecOrder(migrate.ExecOrderLinearSkip))
+		case execOrderNonLinear:
+			opts = append(opts, migrate.WithExecOrder(migrate.ExecOrderNonLinear))
+		default:
+			return nil, fmt.Errorf("unknown execution order: %q", v)
+		}
+	}
+	return opts, nil
 }
 
 func migrateApplyCmd() *cobra.Command {
@@ -132,6 +144,7 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.`,
 	addFlagLockTimeout(cmd.Flags(), &flags.lockTimeout)
 	cmd.Flags().StringVarP(&flags.baselineVersion, flagBaseline, "", "", "start the first migration after the given baseline version")
 	cmd.Flags().StringVarP(&flags.txMode, flagTxMode, "", txModeFile, "set transaction mode [none, file, all]")
+	cmd.Flags().StringVarP(&flags.execOrder, flagExecOrder, "", execOrderLinear, "set file execution order [linear, linear-skip, non-linear]")
 	cmd.Flags().StringVar(&flags.context, flagContext, "", "describes what triggered this command (e.g., GitHub Action)")
 	cobra.CheckErr(cmd.Flags().MarkHidden(flagContext))
 	cmd.Flags().BoolVarP(&flags.allowDirty, flagAllowDirty, "", false, "allow start working on a non-clean database")
@@ -205,7 +218,11 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags,
 		return err
 	}
 	// Determine pending files.
-	opts := append(flags.migrateOptions(), migrate.WithOperatorVersion(operatorVersion()), migrate.WithLogger(report))
+	opts, err := flags.migrateOptions()
+	if err != nil {
+		return err
+	}
+	opts = append(opts, migrate.WithOperatorVersion(operatorVersion()), migrate.WithLogger(report))
 	ex, err := migrate.NewExecutor(client.Driver, dir, rrw, opts...)
 	if err != nil {
 		return err
@@ -1425,6 +1442,10 @@ const (
 	txModeNone = "none"
 	txModeAll  = "all"
 	txModeFile = "file"
+
+	execOrderLinear     = "linear"
+	execOrderLinearSkip = "linear-skip"
+	execOrderNonLinear  = "non-linear"
 )
 
 // tx handles wrapping migration execution in transactions.
@@ -1649,6 +1670,9 @@ func setMigrateEnvFlags(cmd *cobra.Command, env *Env) error {
 		if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil {
 			return err
 		}
+		if err := maySetFlag(cmd, flagExecOrder, strings.ReplaceAll(strings.ToLower(env.Migration.ExecOrder), "_", "-")); err != nil {
+			return err
+		}
 	case "diff", "checkpoint":
 		if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil {
 			return err
diff --git a/cmd/atlas/internal/cmdapi/project.go b/cmd/atlas/internal/cmdapi/project.go
index 72648364..1fcf5eac 100644
--- a/cmd/atlas/internal/cmdapi/project.go
+++ b/cmd/atlas/internal/cmdapi/project.go
@@ -76,6 +76,7 @@ type (
 		Dir             string `spec:"dir"`
 		Format          string `spec:"format"`
 		Baseline        string `spec:"baseline"`
+		ExecOrder       string `spec:"exec_order"`
 		LockTimeout     string `spec:"lock_timeout"`
 		RevisionsSchema string `spec:"revisions_schema"`
 	}
@@ -472,6 +473,7 @@ func parseConfig(path, env string, vars map[string]cty.Value) (*Project, error)
 			cmdext.DataSources,
 			cfg.InitBlock(),
 			schemahcl.WithScopedEnums("env.migration.format", cmdmigrate.Formats...),
+			schemahcl.WithScopedEnums("env.migration.exec_order", "LINEAR", "LINEAR_SKIP", "NON_LINEAR"),
 			schemahcl.WithScopedEnums("env.lint.review", ReviewModes...),
 			schemahcl.WithScopedEnums("lint.review", ReviewModes...),
 			schemahcl.WithVariables(map[string]cty.Value{
diff --git a/doc/md/atlas-schema/projects.mdx b/doc/md/atlas-schema/projects.mdx
index d8683988..d1094323 100644
--- a/doc/md/atlas-schema/projects.mdx
+++ b/doc/md/atlas-schema/projects.mdx
@@ -861,6 +861,7 @@ set or map item. See the example [below](#multi-environment-example).
 - `migration` - A block defines the migration configuration of the env.
   - `dir` - The [URL](../concepts/url.mdx) to the migration directory.
   - `baseline` - An optional version to start the migration history from. Read more [here](../versioned/apply.mdx#existing-databases).
+  - `exec_order` - Set the file execution order [`LINEAR` (default), `LINEAR_SKIP`, `NON_LINEAR`]. Read more [here](../versioned/apply.mdx#execution-order).
   - `lock_timeout` - An optional timeout to wait for a database lock to be released. Defaults to `10s`. 
   - `revisions_schema` - An optional name to control the schema that the revisions table resides in.
 
diff --git a/doc/md/reference.md b/doc/md/reference.md
index bcfadd30..2927e2ab 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -94,6 +94,7 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.
       --lock-timeout duration     set how long to wait for the database lock (default 10s)
       --baseline string           start the first migration after the given baseline version
       --tx-mode string            set transaction mode [none, file, all] (default "file")
+      --exec-order string         set file execution order [linear, linear-skip, non-linear] (default "linear")
       --allow-dirty               allow start working on a non-clean database
 
 ```
diff --git a/doc/md/versioned/apply.mdx b/doc/md/versioned/apply.mdx
index f6aa1edd..b902ea7e 100644
--- a/doc/md/versioned/apply.mdx
+++ b/doc/md/versioned/apply.mdx
@@ -192,6 +192,36 @@ The `--atlas:txmode` directive can be used to override the transaction mode for
 CREATE INDEX CONCURRENTLY name_idx ON users (name);
 ```
 
+### Execution Order
+
+The `--exec-order` flag controls how Atlas computes and executes pending migration files to the database.
+Atlas supports three different order execution modes:
+
+- `linear` (default) - Atlas expects a linear history and fails if it encounters files that were added out of order.
+  This option ensures files are executed in a consistent order, guaranteeing deterministic behavior. It can be enforced
+  in CI using the `atlas migrate lint` command. Learn more about [non-linear changes](/lint/analyzers#non-linear-changes)
+  in the documentation.
+- `linear-skip` - This option is a softer version of `linear`, meaning that if Atlas encounters a new file that was not
+   added in sequential order (its version is lower than the database version), it will be skipped.
+- `non-linear` - This option directs Atlas to execute migration files that were added out of order. Note, although
+  this option can be useful in local development, it is strongly discouraged in real production environments.
+  Executing files out of order cannot guarantee deterministic behavior and may lead to failures (e.g., conflicted migrations).
+  Rolling back to a specific version of the migration directory might be challenging, as the state of the database could differ
+  from the state of the directory.
+
+#### Handling Out-of-Order Errors {#non-linear-error}
+
+If you encounter this issue during deployment, it means that Atlas was not set up in your CI, which is why the issue was
+not caught beforehand. Let's go through the steps to set up Atlas for your CI and fix this error:
+
+- Configure Atlas to run for every PR or a change in the master branch that affects the migration directory. Learn more on
+  how to integrate it into your [GitHub Actions](/cloud/setup-ci) or [GitLab](/guides/ci-platforms/gitlab) CI pipelines.
+- Run `atlas migrate rebase <versions>` to rebase the problematic files reported by Atlas. Then, create a PR.
+- At this stage, if Atlas CI is set up, it will verify that the migration directory is _replay-able_ and that the rebased
+  migration file(s) can be executed without any issues.
+- Running `atlas migrate apply` (deployment) again should pass without this error.
+
+
 ### Existing Databases
 
 #### Baseline migration
diff --git a/doc/website/docusaurus.config.js b/doc/website/docusaurus.config.js
index 2db53155..c2bfad67 100644
--- a/doc/website/docusaurus.config.js
+++ b/doc/website/docusaurus.config.js
@@ -243,7 +243,7 @@ module.exports = {
           {
             from: '/ui/intro',
             to: '/cloud/getting-started',
-          }
+          },
         ],
       },
     ],
diff --git a/go.mod b/go.mod
index b3f2fe5a..38872ff1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module ariga.io/atlas
 
-go 1.20
+go 1.21
 
 require (
 	github.com/DATA-DOG/go-sqlmock v1.5.0
diff --git a/internal/ci/jobs_oss.go b/internal/ci/jobs_oss.go
index 6ac727eb..b9e68c99 100644
--- a/internal/ci/jobs_oss.go
+++ b/internal/ci/jobs_oss.go
@@ -9,7 +9,7 @@ package main
 //go:generate go run . -flavor Community -suffix oss
 
 func init() {
-	data.GoVersions = goVersions{"1.20", "1.21"}
+	data.GoVersions = goVersions{"1.21"}
 	data.Jobs = append(jobs,
 		Job{
 			Version: "tidb5",
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 65ae493c..87df4eb7 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -10,6 +10,7 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"slices"
 	"strings"
 	"time"
 
@@ -239,6 +240,7 @@ type (
 		dir         Dir                // The Dir with migration files to use.
 		rrw         RevisionReadWriter // The RevisionReadWriter to read and write database revisions to.
 		log         Logger             // The Logger to use.
+		order       ExecOrder          // The order to execute the migration files.
 		baselineVer string             // Start the first migration after the given baseline version.
 		allowDirty  bool               // Allow start working on a non-clean database.
 		operator    string             // Revision.OperatorVersion
@@ -611,6 +613,33 @@ func WithLogger(log Logger) ExecutorOption {
 	}
 }
 
+// ExecOrder defines the execution order to use.
+type ExecOrder uint
+
+const (
+	// ExecOrderLinear is the default execution order mode.
+	// It expects a linear history and fails if it encounters files that were
+	// added out of order. For example, a new file was added with version lower
+	// than the last applied revision.
+	ExecOrderLinear ExecOrder = iota
+
+	// ExecOrderLinearSkip is a softer version of ExecOrderLinear.
+	// This means that if a new file is added with a version lower than the last
+	// applied revision, it will be skipped.
+	ExecOrderLinearSkip
+
+	// ExecOrderNonLinear executes migration files that were added out of order.
+	ExecOrderNonLinear
+)
+
+// WithExecOrder sets the execution order to use.
+func WithExecOrder(o ExecOrder) ExecutorOption {
+	return func(ex *Executor) error {
+		ex.order = o
+		return nil
+	}
+}
+
 // WithOperatorVersion sets the operator version to save on the revisions
 // when executing migration files.
 func WithOperatorVersion(v string) ExecutorOption {
@@ -697,6 +726,30 @@ func (e *Executor) Pending(ctx context.Context) ([]File, error) {
 			idx++
 		}
 		pending = migrations[idx:]
+
+		// Capture all files (versions) between first and last revisions and ensure they
+		// were actually applied. Then, error or execute according to the execution order.
+		// Note, "first" is computed as it can be set to the first checkpoint, which may
+		// not be the first migration file.
+		if first := slices.IndexFunc(migrations[:idx], func(f File) bool {
+			return f.Version() >= revs[0].Version
+		}); first != -1 && first < idx && e.order != ExecOrderLinearSkip {
+			var skipped []File
+			for _, f := range migrations[first:idx] {
+				if _, found := slices.BinarySearchFunc(revs, f, func(r *Revision, f File) int {
+					return strings.Compare(r.Version, f.Version())
+				}); !found {
+					skipped = append(skipped, f)
+				}
+			}
+			switch {
+			case len(skipped) == 0:
+			case e.order == ExecOrderNonLinear:
+				pending = append(skipped, pending...)
+			case e.order == ExecOrderLinear:
+				return nil, &HistoryNonLinearError{Files: skipped}
+			}
+		}
 	}
 	if len(pending) == 0 {
 		return nil, ErrNoPendingFiles
@@ -807,6 +860,20 @@ func (e HistoryChangedError) Error() string {
 	return fmt.Sprintf("sql/migrate: execute: history changed: statement %d from file %q changed", e.Stmt, e.File)
 }
 
+// HistoryNonLinearError is returned if the migration history is not linear. Means, a file was added out of order.
+// The executor can be configured to ignore this error and continue execution. See WithExecOrder for details.
+type HistoryNonLinearError struct {
+	Files []File
+}
+
+func (e HistoryNonLinearError) Error() string {
+	names := make([]string, len(e.Files))
+	for i := range e.Files {
+		names[i] = e.Files[i].Name()
+	}
+	return fmt.Sprintf("migration files %s added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error", strings.Join(names, ", "))
+}
+
 // ExecuteN executes n pending migration files. If n<=0 all pending migration files are executed.
 func (e *Executor) ExecuteN(ctx context.Context, n int) (err error) {
 	pending, err := e.Pending(ctx)
diff --git a/sql/sqltool/tool.go b/sql/sqltool/tool.go
index 22443ce5..19fe7069 100644
--- a/sql/sqltool/tool.go
+++ b/sql/sqltool/tool.go
@@ -12,6 +12,7 @@ import (
 	"io/fs"
 	"path/filepath"
 	"regexp"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -575,7 +576,7 @@ func flywayVersionCompare(v1, v2 string) int {
 		}
 		return ret
 	}
-	return compareSliceInt(parse(v1), parse(v2))
+	return slices.Compare(parse(v1), parse(v2))
 }
 
 func unexpectedPragmaErr(f migrate.File, line int, pragma string) error {
@@ -639,24 +640,3 @@ var (
 	_ dirPath = (*GooseDir)(nil)
 	_ dirPath = (*LiquibaseDir)(nil)
 )
-
-// Copied from golang.org/x/exp/slices.Compare
-func compareSliceInt(s1, s2 []int) int {
-	s2len := len(s2)
-	for i, v1 := range s1 {
-		if i >= s2len {
-			return +1
-		}
-		v2 := s2[i]
-		switch {
-		case v1 < v2:
-			return -1
-		case v1 > v2:
-			return +1
-		}
-	}
-	if len(s1) < s2len {
-		return -1
-	}
-	return 0
-}


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index d811cb32..a788c97b 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -713,6 +713,95 @@ func TestMigrate_ApplyTxModeDirective(t *testing.T) {
 	require.Equal(t, s, `unknown txmode "unknown" found in file directive "20220925094021_second.sql"`)
 }
 
+func TestMigrate_ApplyExecOrder(t *testing.T) {
+	p := t.TempDir()
+	db := fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db"))
+	require.NoError(t, os.Mkdir(filepath.Join(p, "migrations"), 0700))
+	dir, err := migrate.NewLocalDir(filepath.Join(p, "migrations"))
+	require.NoError(t, err)
+	write := func(n, b string) {
+		require.NoError(t, dir.WriteFile(n, []byte(b)))
+		hash, err := dir.Checksum()
+		require.NoError(t, err)
+		require.NoError(t, migrate.WriteSumFile(dir, hash))
+	}
+	write("1.sql", "create table t1(c int);")
+	write("3.sql", "create table t3(c int);")
+
+	// First run.
+	s, err := runCmd(
+		migrateApplyCmd(),
+		"--dir", "file://"+dir.Path(),
+		"--url", db,
+		"--format", "{{ len .Applied }}",
+	)
+	require.NoError(t, err)
+	require.Equal(t, "2", s)
+
+	// File was added out of order.
+	write("2.sql", "create table t2(c int);")
+	_, err = runCmd(
+		migrateApplyCmd(),
+		"--dir", "file://"+dir.Path(),
+		"--url", db,
+	)
+	require.EqualError(t, err, "migration files 2.sql added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error")
+
+	// The "linear" option is the default execution order.
+	_, err = runCmd(
+		migrateApplyCmd(),
+		"--dir", "file://"+dir.Path(),
+		"--url", db,
+		"--exec-order", "linear",
+	)
+	require.EqualError(t, err, "migration files 2.sql added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error")
+
+	// Keep linear order and skip files that were added out of order.
+	s, err = runCmd(
+		migrateApplyCmd(),
+		"--dir", "file://"+dir.Path(),
+		"--url", db,
+		"--exec-order", "linear-skip",
+	)
+	require.NoError(t, err)
+	require.Equal(t, "No migration files to execute\n", s)
+
+	// Allow non-linear order.
+	s, err = runCmd(
+		migrateApplyCmd(),
+		"--dir", "file://"+dir.Path(),
+		"--url", db,
+		"--exec-order", "non-linear",
+		"--format", "{{ range .Applied }}{{ .Version }}{{ end }}",
+	)
+	require.NoError(t, err)
+	require.Equal(t, "2", s)
+
+	write("2.5.sql", "create table t25(c int);")
+	write("4.sql", "create table t4(c int);")
+	s, err = runCmd(
+		migrateApplyCmd(),
+		"--dir", "file://"+dir.Path(),
+		"--url", db,
+		"--exec-order", "non-linear",
+		"--format", "{{ range .Applied }}{{ println .Version }}{{ end }}",
+	)
+	require.NoError(t, err)
+	require.Equal(t, "2.5\n4\n", s)
+
+	// There are no pending migrations, in all execution orders.
+	for _, o := range []string{"linear", "linear-skip", "non-linear"} {
+		s, err = runCmd(
+			migrateApplyCmd(),
+			"--dir", "file://"+dir.Path(),
+			"--url", db,
+			"--exec-order", o,
+		)
+		require.NoError(t, err)
+		require.Equal(t, "No migration files to execute\n", s)
+	}
+}
+
 func TestMigrate_ApplyBaseline(t *testing.T) {
 	t.Run("FromFlags", func(t *testing.T) {
 		p := t.TempDir()
diff --git a/cmd/atlas/internal/cmdapi/project_test.go b/cmd/atlas/internal/cmdapi/project_test.go
index 72bcc20f..23979d86 100644
--- a/cmd/atlas/internal/cmdapi/project_test.go
+++ b/cmd/atlas/internal/cmdapi/project_test.go
@@ -70,6 +70,7 @@ env "local" {
     format = atlas
     lock_timeout = "1s"
     revisions_schema = "revisions"
+    exec_order = LINEAR_SKIP
   }
   lint {
     latest = 1
@@ -127,6 +128,7 @@ env "multi" {
 				Format:          cmdmigrate.FormatAtlas,
 				LockTimeout:     "1s",
 				RevisionsSchema: "revisions",
+				ExecOrder:       "LINEAR_SKIP",
 			},
 			Diff: &Diff{
 				SkipChanges: &SkipChanges{
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 744ee46a..7074d8e2 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -365,6 +365,125 @@ func TestExecutor_Pending(t *testing.T) {
 	require.Len(t, p, 0)
 }
 
+func TestExecutor_ExecOrderLinear(t *testing.T) {
+	var (
+		drv = &mockDriver{}
+		ctx = context.Background()
+		rrw = &mockRevisionReadWriter{{Version: "1"}, {Version: "2"}, {Version: "3"}}
+		dir = func(names ...string) migrate.Dir {
+			m := &migrate.MemDir{}
+			for _, n := range names {
+				require.NoError(t, m.WriteFile(n, nil))
+			}
+			h, err := m.Checksum()
+			require.NoError(t, err)
+			require.NoError(t, migrate.WriteSumFile(m, h))
+			return m
+		}
+	)
+	t.Run("Linear", func(t *testing.T) {
+		ex, err := migrate.NewExecutor(drv, dir(), rrw)
+		require.NoError(t, err)
+		files, err := ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "3.sql"), rrw)
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "2.5.sql", "3.sql"), rrw)
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorAs(t, err, new(*migrate.HistoryNonLinearError))
+		require.EqualError(t, err, "migration files 2.5.sql added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error")
+
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "2.5.sql", "2.6.sql", "3.sql"), rrw)
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorAs(t, err, new(*migrate.HistoryNonLinearError))
+		require.EqualError(t, err, "migration files 2.5.sql, 2.6.sql added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error")
+
+		// The first file executed as checkpoint, therefore, 1.sql is not pending nor skipped.
+		rrw = &mockRevisionReadWriter{{Version: "2"}, {Version: "3"}}
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2_checkpoint.sql", "3.sql"), rrw)
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		// The first file executed as checkpoint, therefore, 1.sql is not pending nor skipped.
+		rrw = &mockRevisionReadWriter{{Version: "2"}, {Version: "3"}}
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2_checkpoint.sql", "2.5.sql", "3.sql"), rrw)
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorAs(t, err, new(*migrate.HistoryNonLinearError))
+		require.EqualError(t, err, "migration files 2.5.sql added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error")
+	})
+
+	t.Run("LinearSkipped", func(t *testing.T) {
+		ex, err := migrate.NewExecutor(drv, dir(), rrw, migrate.WithExecOrder(migrate.ExecOrderLinearSkip))
+		require.NoError(t, err)
+		files, err := ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "3.sql"), rrw, migrate.WithExecOrder(migrate.ExecOrderLinearSkip))
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		// File 2.5.sql is skipped.
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "2.5.sql", "3.sql"), rrw, migrate.WithExecOrder(migrate.ExecOrderLinearSkip))
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		// Files 2.5.sql and 2.6.sql are skipped.
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "2.5.sql", "2.6.sql", "3.sql"), rrw, migrate.WithExecOrder(migrate.ExecOrderLinearSkip))
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+	})
+
+	t.Run("NonLinear", func(t *testing.T) {
+		ex, err := migrate.NewExecutor(drv, dir(), rrw, migrate.WithExecOrder(migrate.ExecOrderNonLinear))
+		require.NoError(t, err)
+		files, err := ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "3.sql"), rrw, migrate.WithExecOrder(migrate.ExecOrderNonLinear))
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
+		require.Empty(t, files)
+
+		// File 2.5.sql is pending.
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "2.5.sql", "3.sql"), rrw, migrate.WithExecOrder(migrate.ExecOrderNonLinear))
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.NoError(t, err)
+		require.Len(t, files, 1)
+		require.Equal(t, "2.5.sql", files[0].Name())
+
+		// Files 2.5.sql, 2.6.sql and 4.sql are pending.
+		ex, err = migrate.NewExecutor(drv, dir("1.sql", "2.sql", "2.5.sql", "2.6.sql", "3.sql", "4.sql"), rrw, migrate.WithExecOrder(migrate.ExecOrderNonLinear))
+		require.NoError(t, err)
+		files, err = ex.Pending(ctx)
+		require.NoError(t, err)
+		require.Len(t, files, 3)
+		require.Equal(t, "2.5.sql", files[0].Name())
+		require.Equal(t, "2.6.sql", files[1].Name())
+		require.Equal(t, "4.sql", files[2].Name())
+	})
+}
+
 func TestExecutor(t *testing.T) {
 	// Passing nil raises error.
 	ex, err := migrate.NewExecutor(nil, nil, nil)