Repository: ariga/atlas
Pull request #2334 (2023-12-04 11:54:07)

schemahcl: children (context) can refuse inheritance


Patch:
diff --git a/cmd/atlas/internal/docker/docker.go b/cmd/atlas/internal/docker/docker.go
index a017b5d6..32096ac0 100644
--- a/cmd/atlas/internal/docker/docker.go
+++ b/cmd/atlas/internal/docker/docker.go
@@ -25,7 +25,10 @@ import (
 	"github.com/go-sql-driver/mysql"
 )
 
-const pass = "pass"
+const (
+	pass          = "pass"
+	passSQLServer = "P@ssw0rd0995"
+)
 
 type (
 	// Config is used to configure container creation.
@@ -70,9 +73,10 @@ func NewConfig(opts ...ConfigOption) (*Config, error) {
 }
 
 const (
-	DriverMySQL    = "mysql"
-	DriverMariaDB  = "mariadb"
-	DriverPostgres = "postgres"
+	DriverMySQL     = "mysql"
+	DriverMariaDB   = "mariadb"
+	DriverPostgres  = "postgres"
+	DriverSQLServer = "sqlserver"
 )
 
 // FromURL parses a URL in the format of
@@ -95,14 +99,14 @@ func FromURL(u *url.URL) (*Config, error) {
 	case n == 2:
 		parts[0] = fmt.Sprintf("%s/%s", parts[0], parts[1])
 	}
-	switch u.Scheme {
-	case "docker+postgres", "docker+mysql", "docker+maria":
+	// Support docker+driver://image/tag
+	if drv, ok := strings.CutPrefix(u.Scheme, "docker+"); ok {
 		img := Image(parts[0])
 		if u.Host != "" && u.Host != "_" {
 			img = Image(u.Host, parts[0])
 		}
 		opts = append(opts, img)
-		u.Host = u.Scheme[len("docker+"):]
+		u.Host = drv
 	}
 	var (
 		err error
@@ -131,6 +135,16 @@ func FromURL(u *url.URL) (*Config, error) {
 			opts = append(opts, Env("POSTGRES_DB="+parts[1]))
 		}
 		cfg, err = PostgreSQL(tag, opts...)
+	case DriverSQLServer:
+		if len(parts) > 1 {
+			if db := parts[1]; db != "master" {
+				opts = append(opts, Setup(fmt.Sprintf("CREATE DATABASE [%s]", db)))
+			}
+		}
+		cfg, err = SQLServer(tag, opts...)
+		if err != nil {
+			return nil, err
+		}
 	default:
 		return nil, fmt.Errorf("unsupported docker image %q", u.Host)
 	}
@@ -197,6 +211,25 @@ func PostgreSQL(version string, opts ...ConfigOption) (*Config, error) {
 	)
 }
 
+// SQLServer returns a new Config for a SQLServer image.
+func SQLServer(version string, opts ...ConfigOption) (*Config, error) {
+	return NewConfig(
+		append(
+			[]ConfigOption{
+				Image("mcr.microsoft.com/mssql/server:" + version),
+				Port("1433"),
+				Database("master"),
+				Env(
+					"ACCEPT_EULA=Y",
+					"MSSQL_PID=Developer",
+					"MSSQL_SA_PASSWORD="+passSQLServer,
+				),
+			},
+			opts...,
+		)...,
+	)
+}
+
 // Image sets the docker image to use. For example:
 //
 //	Image("mysql")
@@ -319,12 +352,12 @@ func (c *Container) Wait(ctx context.Context, timeout time.Duration) error {
 	if err != nil {
 		return err
 	}
+	pingURL := c.PingURL(*u)
 	for {
 		select {
 		case <-time.After(100 * time.Millisecond):
 			// Ping against the root connection.
-			u.Path = "/"
-			client, err := sqlclient.Open(ctx, u.String())
+			client, err := sqlclient.Open(ctx, pingURL)
 			if err != nil {
 				continue
 			}
@@ -352,6 +385,8 @@ func (c *Container) Wait(ctx context.Context, timeout time.Duration) error {
 // URL returns a URL to connect to the Container.
 func (c *Container) URL() (*url.URL, error) {
 	switch c.cfg.driver {
+	case DriverSQLServer:
+		return url.Parse(fmt.Sprintf("sqlserver://sa:%s@localhost:%s?database=%s", passSQLServer, c.Port, c.cfg.Database))
 	case DriverPostgres:
 		return url.Parse(fmt.Sprintf("postgres://postgres:%s@localhost:%s/%s?sslmode=disable", c.Passphrase, c.Port, c.cfg.Database))
 	case DriverMySQL, DriverMariaDB:
@@ -361,6 +396,20 @@ func (c *Container) URL() (*url.URL, error) {
 	}
 }
 
+// PingURL returns a URL to ping the Container.
+func (c *Container) PingURL(u url.URL) string {
+	switch c.cfg.driver {
+	case DriverSQLServer:
+		q := u.Query()
+		q.Del("database")
+		u.RawQuery = q.Encode()
+		return u.String()
+	default:
+		u.Path = "/"
+		return u.String()
+	}
+}
+
 // validate that no empty values are given.
 func (c *Config) validate() error {
 	if c == nil || c.Image == "" || c.Port == "" || c.Out == nil {
@@ -388,7 +437,7 @@ func init() {
 	sqlclient.Register(
 		"docker",
 		sqlclient.OpenerFunc(Open),
-		sqlclient.RegisterFlavours("docker+postgres", "docker+mysql", "docker+maria"),
+		sqlclient.RegisterFlavours("docker+postgres", "docker+mysql", "docker+maria", "docker+sqlserver"),
 	)
 }
 
diff --git a/doc/md/components/migrate-push-command.mdx b/doc/md/components/migrate-push-command.mdx
index 044878a4..566e4913 100644
--- a/doc/md/components/migrate-push-command.mdx
+++ b/doc/md/components/migrate-push-command.mdx
@@ -36,14 +36,9 @@ atlas migrate push app \
 </TabItem>
 <TabItem value="sqlserver" label="SQL Server">
 
-Run first the following command to start a local MSSQL server using Docker:
-
-```bash
-docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=P@ssw0rd0995' -p 1433:1433 -d mcr.microsoft.com/mssql/server:2019-latest
-```
 ```bash
 atlas migrate push app \
-    --dev-url "sqlserver://sa:P@ssw0rd0995@localhost:1433?database=master"
+  --dev-url "docker://sqlserver/2022-latest"
 ```
 
 </TabItem>
diff --git a/doc/md/concepts/dev.mdx b/doc/md/concepts/dev.mdx
index 05457e59..445b1a90 100644
--- a/doc/md/concepts/dev.mdx
+++ b/doc/md/concepts/dev.mdx
@@ -110,6 +110,28 @@ docker+postgres://org/image:tag
 --dev-url "sqlite://dev?mode=memory"
 ```
 
+</TabItem>
+<TabItem value="sqlserver" label="SQLServer">
+
+```shell
+# When working on a single database schema.
+--dev-url "docker://sqlserver/2022-latest/dev?mode=schema"
+
+# When working on multiple database schemas.
+--dev-url "docker://sqlserver/2022-latest/dev?mode=database"
+```
+
+To work with an Azure SQL Edge docker image or SQLServer version, use one of the following formats:
+
+```shell
+# Run SQLServer 2017-latest in schema mode.
+docker://sqlserver/2017-latest?mode=schema
+docker://sqlserver/2019-latest?mode=schema
+docker://sqlserver/2022-latest?mode=schema
+
+# Run Azure SQL Edge 1.0.7 in schema mode.
+docker+sqlserver://mcr.microsoft.com/azure-sql-edge:1.0.7?mode=schema
+```
 </TabItem>
 </Tabs>
 
diff --git a/doc/website/blog/2023-10-17-automatic-migrations-for-sql-server.mdx b/doc/website/blog/2023-10-17-automatic-migrations-for-sql-server.mdx
index 29fe5c41..6988424f 100644
--- a/doc/website/blog/2023-10-17-automatic-migrations-for-sql-server.mdx
+++ b/doc/website/blog/2023-10-17-automatic-migrations-for-sql-server.mdx
@@ -95,17 +95,6 @@ CREATE TABLE posts (
 );
 ```
 
-### Spinning up a Dev-Database
-
-Atlas relies on a [_Dev-Database_](/concepts/dev-database), a temporary empty database to normalize schemas and make various
-calculations. Because we are providing the desired state of our database in plain SQL, Atlas must somehow transform
-our schema to a normalized form which requires such a database. Let's spin up an additional SQL Server instance
-on port 1434:
-
-```
-docker run --rm -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=P@ssw0rd0995' -p 1434:1433 -d mcr.microsoft.com/mssql/server:latest
-```
-
 ### Applying our schema
 
 Next, let's apply this schema to our database. To do so, we will use the `atlas schema apply` command.
@@ -113,7 +102,7 @@ Next, let's apply this schema to our database. To do so, we will use the `atlas
 ```shell
 atlas schema apply -u "sqlserver://sa:P@ssw0rd0995@localhost:1433?database=master" \
   --to file://schema.sql \
-  --dev-url "sqlserver://sa:P@ssw0rd0995@localhost:1434?database=master"
+  --dev-url "docker://sqlserver"
 ```
 Atlas will connect to our target database to inspect it's current state. Next, it will use the dev-database to
 normalize our schema and finally, it will generate the SQL commands that will bring our database to the desired state:
diff --git a/schemahcl/schemahcl.go b/schemahcl/schemahcl.go
index 41db66e0..fbd28af0 100644
--- a/schemahcl/schemahcl.go
+++ b/schemahcl/schemahcl.go
@@ -483,6 +483,14 @@ func (s *State) mayScopeContext(ctx *hcl.EvalContext, scope []string) *hcl.EvalC
 		Variables: make(map[string]cty.Value),
 		Functions: make(map[string]function.Function),
 	}
+	for p := ctx; p != nil; p = p.Parent() {
+		for k, v := range p.Variables {
+			if isRef(v) {
+				nctx.Variables[k] = v
+			}
+		}
+	}
+	// Override the parent context with the scoped variables and functions.
 	for n, v := range vars {
 		nctx.Variables[n] = v
 	}
@@ -492,13 +500,6 @@ func (s *State) mayScopeContext(ctx *hcl.EvalContext, scope []string) *hcl.EvalC
 	// A patch from the past. Should be moved
 	// to specific scopes in the future.
 	nctx.Functions["sql"] = rawExprFunc
-	for p := ctx; p != nil; p = p.Parent() {
-		for k, v := range p.Variables {
-			if isRef(v) {
-				nctx.Variables[k] = v
-			}
-		}
-	}
 	return nctx
 }
 
diff --git a/sql/internal/sqlx/diff.go b/sql/internal/sqlx/diff.go
index 22efcade..5afb647e 100644
--- a/sql/internal/sqlx/diff.go
+++ b/sql/internal/sqlx/diff.go
@@ -122,6 +122,15 @@ func (d *Diff) RealmDiff(from, to *schema.Realm, options ...schema.DiffOption) (
 			continue
 		}
 		changes = opts.AddOrSkip(changes, &schema.AddSchema{S: s1})
+		for _, o := range s1.Objects {
+			changes = opts.AddOrSkip(changes, &schema.AddObject{O: o})
+		}
+		for _, f := range s1.Funcs {
+			changes = opts.AddOrSkip(changes, &schema.AddFunc{F: f})
+		}
+		for _, p := range s1.Procs {
+			changes = opts.AddOrSkip(changes, &schema.AddProc{P: p})
+		}
 		for _, t := range s1.Tables {
 			changes = opts.AddOrSkip(changes, &schema.AddTable{T: t})
 		}
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 7ff17d9d..5223ba57 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -660,11 +660,11 @@ func (e *Executor) Pending(ctx context.Context) ([]File, error) {
 	if err != nil {
 		return nil, fmt.Errorf("sql/migrate: execute: read revisions: %w", err)
 	}
-	migrations, err := e.dir.Files()
+	all, err := e.dir.Files()
 	if err != nil {
 		return nil, fmt.Errorf("sql/migrate: execute: select migration files: %w", err)
 	}
-	migrations = SkipCheckpointFiles(migrations)
+	migrations := SkipCheckpointFiles(all)
 	var pending []File
 	switch {
 	// If it is the first time we run.
@@ -690,14 +690,27 @@ func (e *Executor) Pending(ctx context.Context) ([]File, error) {
 				return nil, err
 			}
 			pending = migrations[baseline+1:]
-
 			// In case the "allow-dirty" option was set, or the database is clean,
 			// the starting-point is the first migration file or the last checkpoint.
 		} else if pending, err = FilesFromLastCheckpoint(e.dir); err != nil {
 			return nil, err
 		}
-	// In case we applied/marked revisions in
-	// the past, and there is work to do.
+	// In case we applied a checkpoint, but it was only partially applied.
+	case revs[len(revs)-1].Applied != revs[len(revs)-1].Total && len(all) > 0:
+		if idx, found := slices.BinarySearchFunc(all, revs[len(revs)-1], func(f File, r *Revision) int {
+			return strings.Compare(f.Version(), r.Version)
+		}); found {
+			if f, ok := all[idx].(CheckpointFile); ok && f.IsCheckpoint() {
+				// There can only be one checkpoint file and it must be the first one applied.
+				// Thus, we can consider all migrations following the checkpoint to be pending.
+				return append([]File{f}, SkipCheckpointFiles(all[idx:])...), nil
+			}
+		}
+		if len(migrations) == 0 {
+			break // don't fall through the next case if there are no migrations
+		}
+		fallthrough // proceed normally
+	// In case we applied/marked revisions in the past, and there is work to do.
 	case len(migrations) > 0:
 		var (
 			last      = revs[len(revs)-1]
@@ -726,7 +739,6 @@ 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


Test patch:
diff --git a/cmd/atlas/internal/docker/docker_test.go b/cmd/atlas/internal/docker/docker_test.go
index d309f665..4f09ff59 100644
--- a/cmd/atlas/internal/docker/docker_test.go
+++ b/cmd/atlas/internal/docker/docker_test.go
@@ -50,6 +50,21 @@ func TestDockerConfig(t *testing.T) {
 		Port:     "5432",
 		Out:      io.Discard,
 	}, cfg)
+
+	// SQL Server
+	cfg, err = SQLServer("2022-latest", Out(io.Discard))
+	require.NoError(t, err)
+	require.Equal(t, &Config{
+		Image:    "mcr.microsoft.com/mssql/server:2022-latest",
+		Port:     "1433",
+		Database: "master",
+		Out:      io.Discard,
+		Env: []string{
+			"ACCEPT_EULA=Y",
+			"MSSQL_PID=Developer",
+			"MSSQL_SA_PASSWORD=" + passSQLServer,
+		},
+	}, cfg)
 }
 
 func TestFromURL(t *testing.T) {
@@ -116,6 +131,78 @@ func TestFromURL(t *testing.T) {
 		Port:     "5432",
 		Out:      io.Discard,
 	}, cfg)
+
+	// SQL Server
+	u, err = url.Parse("docker://sqlserver")
+	require.NoError(t, err)
+	cfg, err = FromURL(u)
+	require.NoError(t, err)
+	require.Equal(t, &Config{
+		driver:   "sqlserver",
+		Image:    "mcr.microsoft.com/mssql/server",
+		Database: "master",
+		Port:     "1433",
+		Out:      io.Discard,
+		Env: []string{
+			"ACCEPT_EULA=Y",
+			"MSSQL_PID=Developer",
+			"MSSQL_SA_PASSWORD=" + passSQLServer,
+		},
+	}, cfg)
+
+	u, err = url.Parse("docker://sqlserver/2022-latest")
+	require.NoError(t, err)
+	cfg, err = FromURL(u)
+	require.NoError(t, err)
+	require.Equal(t, &Config{
+		driver:   "sqlserver",
+		Image:    "mcr.microsoft.com/mssql/server:2022-latest",
+		Database: "master",
+		Port:     "1433",
+		Out:      io.Discard,
+		Env: []string{
+			"ACCEPT_EULA=Y",
+			"MSSQL_PID=Developer",
+			"MSSQL_SA_PASSWORD=" + passSQLServer,
+		},
+	}, cfg)
+
+	u, err = url.Parse("docker://sqlserver/2019-latest/foo")
+	require.NoError(t, err)
+	cfg, err = FromURL(u)
+	require.NoError(t, err)
+	require.Equal(t, &Config{
+		driver:   "sqlserver",
+		setup:    []string{"CREATE DATABASE [foo]"},
+		Image:    "mcr.microsoft.com/mssql/server:2019-latest",
+		Database: "foo",
+		Port:     "1433",
+		Out:      io.Discard,
+		Env: []string{
+			"ACCEPT_EULA=Y",
+			"MSSQL_PID=Developer",
+			"MSSQL_SA_PASSWORD=" + passSQLServer,
+		},
+	}, cfg)
+
+	// Azure SQL Edge
+	u, err = url.Parse("docker+sqlserver://mcr.microsoft.com/azure-sql-edge:1.0.7/foo")
+	require.NoError(t, err)
+	cfg, err = FromURL(u)
+	require.NoError(t, err)
+	require.Equal(t, &Config{
+		driver:   "sqlserver",
+		setup:    []string{"CREATE DATABASE [foo]"},
+		Image:    "mcr.microsoft.com/azure-sql-edge:1.0.7",
+		Database: "foo",
+		Port:     "1433",
+		Out:      io.Discard,
+		Env: []string{
+			"ACCEPT_EULA=Y",
+			"MSSQL_PID=Developer",
+			"MSSQL_SA_PASSWORD=" + passSQLServer,
+		},
+	}, cfg)
 }
 
 func TestFromURL_CustomImage(t *testing.T) {
@@ -235,4 +322,11 @@ func TestImageURL(t *testing.T) {
 		require.NoError(t, err)
 		require.Equal(t, u, got.String())
 	}
+	for img, u := range map[string]string{
+		"mcr.microsoft.com/azure-sql-edge:1.0.7": "docker+sqlserver://mcr.microsoft.com/azure-sql-edge:1.0.7",
+	} {
+		got, err := ImageURL(DriverSQLServer, img)
+		require.NoError(t, err)
+		require.Equal(t, u, got.String())
+	}
 }
diff --git a/internal/integration/testdata/mysql/cli-migrate-apply-checkpoint-fail.txt b/internal/integration/testdata/mysql/cli-migrate-apply-checkpoint-fail.txt
new file mode 100644
index 00000000..4bb64e3a
--- /dev/null
+++ b/internal/integration/testdata/mysql/cli-migrate-apply-checkpoint-fail.txt
@@ -0,0 +1,50 @@
+only mysql
+
+atlas migrate hash
+
+# There will be an implicit commit in MySQL, leaving this file partially applied.
+! atlas migrate apply --url URL
+stderr 'executing statement "THIS IS A FAILING STATEMENT;" from version "3"'
+
+# Status will tell us about partial appliance.
+atlas migrate status --url URL
+stdout 'Migration Status: PENDING'
+stdout '  -- Current Version: 3 \(1 statements applied\)'
+stdout '  -- Next Version:    3 \(1 statements left\)'
+stdout '  -- Executed Files:  1 \(last one partially\)'
+stdout '  -- Pending Files:   1'
+stdout ''
+stdout 'Last migration attempt had errors:'
+stdout '  -- SQL:   THIS IS A FAILING STATEMENT;'
+
+# Running apply again with fixed file will solve it, only running the missing statement.
+cp 3_checkpoint.sql migrations/3_checkpoint.sql
+atlas migrate hash
+atlas migrate apply --url URL
+stdout 'Migrating to version 3 from 3 \(1 migrations in total\):'
+stdout ''
+stdout '  -- migrating version 3'
+stdout '    -> CREATE TABLE `tbl_2` \(`col` bigint\);'
+stdout '  -- ok'
+
+atlas migrate apply --url URL
+stdout 'No migration files to execute'
+
+-- migrations/1_first.sql --
+CREATE TABLE `tbl_1` (`col` bigint);
+
+-- migrations/2_second.sql --
+CREATE TABLE `tbl_2` (`col` bigint);
+
+-- migrations/3_checkpoint.sql --
+-- atlas:checkpoint
+
+CREATE TABLE `tbl_1` (`col` bigint);
+THIS IS A FAILING STATEMENT;
+
+-- 3_checkpoint.sql --
+-- atlas:checkpoint
+
+CREATE TABLE `tbl_1` (`col` bigint);
+CREATE TABLE `tbl_2` (`col` bigint);
+
diff --git a/schemahcl/schemahcl_test.go b/schemahcl/schemahcl_test.go
index ece7a010..1928318d 100644
--- a/schemahcl/schemahcl_test.go
+++ b/schemahcl/schemahcl_test.go
@@ -949,3 +949,39 @@ foo "f1" {
 		},
 	}, &e)
 }
+
+func Test_ScopeContextOverride(t *testing.T) {
+	type (
+		Foo struct {
+			Name string `spec:",name"`
+		}
+		Bar struct {
+			Name string `spec:",name"`
+			Attr string `spec:"attr"`
+			Ref  *Ref   `spec:"ref"`
+		}
+	)
+	var (
+		doc struct {
+			Foo []*Foo `spec:"foo"`
+			Bar []*Bar `spec:"bar"`
+		}
+		b = []byte(`
+foo "f1" {}
+bar "b1" {
+	attr = foo
+	ref  = foo.f1
+}
+`)
+	)
+	require.NoError(t, New(
+		WithScopedEnums("bar.attr", "foo"), // foo is a valid value for bar.attr.
+	).EvalBytes(b, &doc, nil))
+	require.Len(t, doc.Foo, 1)
+	require.Len(t, doc.Bar, 1)
+	require.Equal(t, &Bar{
+		Name: "b1",
+		Attr: "foo",
+		Ref:  &Ref{V: "$foo.f1"},
+	}, doc.Bar[0])
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 5d098051..8f44e98b 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -263,6 +263,7 @@ func TestExecutor_Replay(t *testing.T) {
 
 func TestExecutor_Pending(t *testing.T) {
 	var (
+		ctx  = context.Background()
 		drv  = &mockDriver{}
 		rrw  = &mockRevisionReadWriter{}
 		log  = &mockLogger{}
@@ -289,80 +290,94 @@ func TestExecutor_Pending(t *testing.T) {
 			Hash:        "+O40cAXHgvMClnynHd5wggPAeZAk7zSEaNgzXCZOfmY=",
 		}
 	)
-	dir, err := migrate.NewLocalDir(filepath.Join("testdata/migrate", "sub"))
+	dir, err := migrate.NewLocalDir(filepath.Join("testdata", "migrate", "sub"))
 	require.NoError(t, err)
 	ex, err := migrate.NewExecutor(drv, dir, rrw, migrate.WithLogger(log))
 	require.NoError(t, err)
 
 	// All are pending
-	p, err := ex.Pending(context.Background())
+	p, err := ex.Pending(ctx)
 	require.NoError(t, err)
 	require.Len(t, p, 3)
 
 	// 2 are pending.
 	*rrw = []*migrate.Revision{rev1}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.NoError(t, err)
 	require.Len(t, p, 2)
 
 	// Only the last one is pending (in full).
 	*rrw = []*migrate.Revision{rev1, rev2}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.NoError(t, err)
 	require.Len(t, p, 1)
 
 	// First statement of last one is marked as applied, second isn't. Third file is still pending.
 	*rrw = []*migrate.Revision{rev1, rev2, rev3}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.NoError(t, err)
 	require.Len(t, p, 1)
 
 	// Nothing to do if all migrations are applied.
 	rev3.Applied = rev3.Total
 	*rrw = []*migrate.Revision{rev1, rev2, rev3}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
 	require.Len(t, p, 0)
 
 	// If there is a revision in the past with no existing migration, we don't care.
 	rev3.Applied = rev3.Total
 	*rrw = []*migrate.Revision{{Version: "2.11"}, rev3}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
 	require.Len(t, p, 0)
 
 	// If only the last migration file is applied, we expect there are no pending files.
 	*rrw = []*migrate.Revision{rev3}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
 	require.Len(t, p, 0)
 
 	// If the last applied revision has no matching migration file, and the last
 	// migration version precedes that revision, there is nothing to do.
 	*rrw = []*migrate.Revision{{Version: "5"}}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.ErrorIs(t, err, migrate.ErrNoPendingFiles)
 	require.Len(t, p, 0)
 
 	// The applied revision precedes every migration file. Expect all files pending.
 	*rrw = []*migrate.Revision{{Version: "1.1"}}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.NoError(t, err)
 	require.Len(t, p, 3)
 
 	// All except one file are applied. The latest revision does not exist and its version is smaller than the last
 	// migration file. Expect the last file pending.
 	*rrw = []*migrate.Revision{rev1, rev2, {Version: "2.11"}}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.NoError(t, err)
 	require.Len(t, p, 1)
 	require.Equal(t, rev3.Version, p[0].Version())
 
 	// If the last revision is partially applied and the matching migration file does not exist, we have a problem.
 	*rrw = []*migrate.Revision{{Version: "deleted", Description: "desc", Total: 1}}
-	p, err = ex.Pending(context.Background())
+	p, err = ex.Pending(ctx)
 	require.EqualError(t, err, migrate.MissingMigrationError{Version: "deleted", Description: "desc"}.Error())
 	require.Len(t, p, 0)
+
+	// If the last revision is a partially applied checkpoint, expect it to be part of
+	// the pending files. Checkpoint files following the failed one are not included.
+	dir, err = migrate.NewLocalDir(filepath.Join("testdata", "partial-checkpoint"))
+	require.NoError(t, err)
+	ex, err = migrate.NewExecutor(drv, dir, rrw, migrate.WithLogger(log))
+	require.NoError(t, err)
+	*rrw = []*migrate.Revision{{Version: "3", Description: "checkpoint", Total: 2, Applied: 1}}
+	p, err = ex.Pending(ctx)
+	require.NoError(t, err)
+	require.Len(t, p, 3)
+	require.Equal(t, "3", p[0].Version())
+	require.Equal(t, "4", p[1].Version())
+	require.Equal(t, "6", p[2].Version())
 }
 
 func TestExecutor_ExecOrderLinear(t *testing.T) {
diff --git a/sql/migrate/testdata/partial-checkpoint/1_first.sql b/sql/migrate/testdata/partial-checkpoint/1_first.sql
new file mode 100644
index 00000000..b884394b
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/1_first.sql
@@ -0,0 +1,1 @@
+create table `tbl_1` (`col` int);
diff --git a/sql/migrate/testdata/partial-checkpoint/2_second.sql b/sql/migrate/testdata/partial-checkpoint/2_second.sql
new file mode 100644
index 00000000..b1d0c7ff
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/2_second.sql
@@ -0,0 +1,1 @@
+create table `tbl_2` (`col` int);
diff --git a/sql/migrate/testdata/partial-checkpoint/3_checkpoint.sql b/sql/migrate/testdata/partial-checkpoint/3_checkpoint.sql
new file mode 100644
index 00000000..a8eeec80
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/3_checkpoint.sql
@@ -0,0 +1,4 @@
+-- atlas:checkpoint
+
+CREATE TABLE `tbl_1` (`col` int);
+CREATE TABLE `tbl_2` (`col` int);
diff --git a/sql/migrate/testdata/partial-checkpoint/4_fourth.sql b/sql/migrate/testdata/partial-checkpoint/4_fourth.sql
new file mode 100644
index 00000000..c227813f
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/4_fourth.sql
@@ -0,0 +1,1 @@
+create table `tbl_3` (`col` int);
diff --git a/sql/migrate/testdata/partial-checkpoint/5_checkpoint.sql b/sql/migrate/testdata/partial-checkpoint/5_checkpoint.sql
new file mode 100644
index 00000000..d5fda506
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/5_checkpoint.sql
@@ -0,0 +1,5 @@
+-- atlas:checkpoint
+
+CREATE TABLE `tbl_1` (`col` int);
+CREATE TABLE `tbl_2` (`col` int);
+create table `tbl_3` (`col` int);
diff --git a/sql/migrate/testdata/partial-checkpoint/6_sixth.sql b/sql/migrate/testdata/partial-checkpoint/6_sixth.sql
new file mode 100644
index 00000000..78bac932
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/6_sixth.sql
@@ -0,0 +1,1 @@
+CREATE TABLE `tbl_4` (`col` int);
diff --git a/sql/migrate/testdata/partial-checkpoint/atlas.sum b/sql/migrate/testdata/partial-checkpoint/atlas.sum
new file mode 100644
index 00000000..380705c7
--- /dev/null
+++ b/sql/migrate/testdata/partial-checkpoint/atlas.sum
@@ -0,0 +1,7 @@
+h1:POfbsH2G2GIXAZQ2CdPa5ufTxcLpcnperz/gX0Vw6Uk=
+1_first.sql h1:uJ2MyyeTqXx4rCqtRMQAvRrfsvdwmvsZ3LcadVvCdOc=
+2_second.sql h1:TLO00GtsqSi8o0ySoaofBqxemrElL8fHOs3IRnqYvB8=
+3_checkpoint.sql h1:LR4UjDYVtrRuHLN5GQwLAYnXrSf/iO70psY8hTmLmjw=
+4_fourth.sql h1:QvaneqKxg+3R+zNzCP/wW43XkECdHkUwEpxJKPICEP8=
+5_checkpoint.sql h1:syyuaJb/hoMRO7MJlv4AMhYqMCoSb8ZgcHENO8RqN3I=
+6_sixth.sql h1:JNhpyBtH7XKqGBNRHQ+AvM9IDpO08C3G/swWPHfLkfw=
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index 02515d6b..e8763f84 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -484,6 +484,27 @@ func TestDiff_TableDiff(t *testing.T) {
 	}
 }
 
+func TestDiff_RealmDiff(t *testing.T) {
+	db, m, err := sqlmock.New()
+	require.NoError(t, err)
+	mock{m}.version("130000")
+	drv, err := Open(db)
+	to := schema.New("public").
+		AddTables(
+			schema.NewTable("users").AddColumns(schema.NewIntColumn("t2_id", "int")),
+		).
+		AddObjects(
+			&schema.EnumType{T: "e", Values: []string{"b"}},
+		)
+	changes, err := drv.RealmDiff(schema.NewRealm(), schema.NewRealm(to))
+	require.NoError(t, err)
+	require.EqualValues(t, []schema.Change{
+		&schema.AddSchema{S: to},
+		&schema.AddObject{O: to.Objects[0]},
+		&schema.AddTable{T: to.Tables[0]},
+	}, changes)
+}
+
 func TestDiff_SchemaDiff(t *testing.T) {
 	db, m, err := sqlmock.New()
 	require.NoError(t, err)