Repository: ariga/atlas
Pull request #2336 (2023-12-05 11:03:27)

cmd/atlas: allow run sqlserver docker


Patch:
diff --git a/cmd/atlas/internal/docker/docker.go b/cmd/atlas/internal/docker/docker.go
index a017b5d6..ee5d6350 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")
@@ -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:
@@ -388,7 +423,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/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/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=