Repository: ariga/atlas (go)

Issue:
Versioned migration output appears to be incorrect
We are exploring atlas for database schema management and migrations.

Setting up a toy model with an `atlas.hcl` and a `schema.hcl` as follows:

```hcl
env "local" {
    src = "schema.hcl"

    url = "postgres://user:pass@localhost:5432/db_name?sslmode=disable"
    dev = "postgres://user:pass@localhost:5432/db_name_shadow?sslmode=disable"

    schemas = ["public"]

    migration {
        dir = "file://migrations"

        format = "atlas"
    }
}
```

```hcl
schema "public" {}

table "users" {
    schema = schema.public

    column "id" {
        type = bigint

        null = false

        identity {
            generated = ALWAYS
        }
    }

    primary_key {
        columns = [column.id]
    }
}
```

Starting with a fresh database, after running only `CREATE DATABASE db_name;` and `CREATE DATABASE db_name_shadow;`.

I then ran the migration command to generate a diff (`atlas migrate diff --env local --schema public`). This completes successfully and generates the following file (xxx5318.sql):

```sql
-- create "users" table
CREATE TABLE "public"."users" ("id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, PRIMARY KEY ("id"));
```

However, the status command (`atlas migrate status --env local`), shows the following:

```
Migration Status: OK
  -- Current Version:	No version applied yet
  -- Next Version:	Already at latest version
  -- Available:		1
  -- Executed:		0
  -- Pending:		0
```

**First issue:** Should "Next Version" here be the file just generated, named "xxx5318.sql"? If not, what does "Already at latest version" mean in this context, when there is an unapplied migration? Should status be "PENDING" instead of "OK"? Should the counts be Available 1, Executed 0, Pending 1?

I now apply the migration with `atlas migrate apply --env local`, resulting in:

```sql
Migrating to version 20220807075318.sql (1 migrations in total):

  -- migrating version 20220807075318.sql
    -> CREATE TABLE "public"."users" ("id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, PRIMARY KEY ("id"));
  -- ok (5.929761ms)

  -------------------------
  -- 5.973805ms
  -- 1 migrations
  -- 1 sql statements
```

```
Migration Status: OK
  -- Current Version:	20220807075318.sql
  -- Next Version:	Already at latest version
  -- Available:		1
  -- Executed:		1
  -- Pending:		0
```

Both of these look good. I make a change to the `schema.hcl` file to add a new column:

```diff
schema "public" {}

table "users" {
    schema = schema.public

    column "id" {
        type = bigint

        null = false

        identity {
            generated = ALWAYS
        }
    }
+
+    column "happy" {
+        type = boolean
+
+        null = false
+
+        default = true
+    }

    primary_key {
        columns = [column.id]
    }
}
```

Create a new migration (xxx5934.sql) with `atlas migrate diff --env local --schema public`:

```sql
-- modify "users" table
ALTER TABLE "public"."users" ADD COLUMN "happy" boolean NOT NULL DEFAULT true;
```

And check the status (`atlas migrate status --env local`):

```
Migration Status: PENDING
  -- Current Version:	20220807075934.sql
  -- Next Version:	20220807075934.sql
  -- Available:		2
  -- Executed:		2
  -- Pending:		1
```

**Second issue:** This doesn't look right. What I expected here is that the current version is `xxx5318.sql` and the next version is `xxx5934.sql`. I would also expect this to say Available 2, Executed 1, Pending 1. If the above is correct as-is, could you help me better understand what it's trying to convey?

Nonetheless, we attempt to apply the migration (`atlas migrate apply --env local`):

```sql
Migrating to version 20220807075934.sql from 20220807075318.sql (1 migrations in total):

  -- migrating version 20220807075934.sql
    -> ALTER TABLE "public"."users" ADD COLUMN "happy" boolean NOT NULL DEFAULT true;
  -- ok (3.916609ms)

  -------------------------

  -- 3.961342ms
  -- 1 migrations
  -- 1 sql statements
```

```
Migration Status: OK
  -- Current Version:	20220807075318.sql
  -- Next Version:	Already at latest version
  -- Available:		2
  -- Executed:		1
  -- Pending:		0
```

**Third issue:** I believe the current version here should be `xxx5934.sql` rather than `xxx5318.sql`.

With all of that said, the migration itself appears to have completed just fine, and the end state of the table is as expected:

```sql
# \d+ users

                                                    Table "public.users"

 Column |  Type   | Collation | Nullable |           Default            | Storage | Compression | Stats target | Description 

--------+---------+-----------+----------+------------------------------+---------+-------------+--------------+-------------

 id     | bigint  |           | not null | generated always as identity | plain   |             |              | 

 happy  | boolean |           | not null | true                         | plain   |             |              | 

Indexes:

    "users_pkey" PRIMARY KEY, btree (id)

Access method: heap
```

-----
Tool versions:

```
postgres (PostgreSQL) 14.4 (Ubuntu 14.4-1.pgdg22.04+1)
psql (PostgreSQL) 14.4 (Ubuntu 14.4-1.pgdg22.04+1)

atlas version v0.5.0
https://github.com/ariga/atlas/releases/tag/v0.5.0
```

Pull request:
internal/cmdapi: fix #1018

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 568dbc8a..5779ba7a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -137,7 +137,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for mysql56
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="MySQL" -dialect="mysql56" ./...
+        run: go test -race -count=2 -v -run="MySQL" -version="mysql56" ./...
   
   integration-mysql57:
     runs-on: ubuntu-latest
@@ -168,7 +168,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for mysql57
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="MySQL" -dialect="mysql57" ./...
+        run: go test -race -count=2 -v -run="MySQL" -version="mysql57" ./...
   
   integration-mysql8:
     runs-on: ubuntu-latest
@@ -199,7 +199,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for mysql8
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="MySQL" -dialect="mysql8" ./...
+        run: go test -race -count=2 -v -run="MySQL" -version="mysql8" ./...
   
   integration-maria107:
     runs-on: ubuntu-latest
@@ -230,7 +230,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for maria107
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="MySQL" -dialect="maria107" ./...
+        run: go test -race -count=2 -v -run="MySQL" -version="maria107" ./...
   
   integration-maria102:
     runs-on: ubuntu-latest
@@ -261,7 +261,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for maria102
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="MySQL" -dialect="maria102" ./...
+        run: go test -race -count=2 -v -run="MySQL" -version="maria102" ./...
   
   integration-maria103:
     runs-on: ubuntu-latest
@@ -292,7 +292,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for maria103
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="MySQL" -dialect="maria103" ./...
+        run: go test -race -count=2 -v -run="MySQL" -version="maria103" ./...
   
   integration-postgres10:
     runs-on: ubuntu-latest
@@ -322,7 +322,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for postgres10
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="Postgres" -dialect="postgres10" ./...
+        run: go test -race -count=2 -v -run="Postgres" -version="postgres10" ./...
   
   integration-postgres11:
     runs-on: ubuntu-latest
@@ -352,7 +352,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for postgres11
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="Postgres" -dialect="postgres11" ./...
+        run: go test -race -count=2 -v -run="Postgres" -version="postgres11" ./...
   
   integration-postgres12:
     runs-on: ubuntu-latest
@@ -382,7 +382,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for postgres12
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="Postgres" -dialect="postgres12" ./...
+        run: go test -race -count=2 -v -run="Postgres" -version="postgres12" ./...
   
   integration-postgres13:
     runs-on: ubuntu-latest
@@ -412,7 +412,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for postgres13
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="Postgres" -dialect="postgres13" ./...
+        run: go test -race -count=2 -v -run="Postgres" -version="postgres13" ./...
   
   integration-postgres14:
     runs-on: ubuntu-latest
@@ -442,7 +442,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for postgres14
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="Postgres" -dialect="postgres14" ./...
+        run: go test -race -count=2 -v -run="Postgres" -version="postgres14" ./...
   
   integration-tidb5:
     runs-on: ubuntu-latest
@@ -466,7 +466,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for tidb5
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="TiDB" -dialect="tidb5" ./...
+        run: go test -race -count=2 -v -run="TiDB" -version="tidb5" ./...
   
   integration-tidb6:
     runs-on: ubuntu-latest
@@ -490,7 +490,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for tidb6
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="TiDB" -dialect="tidb6" ./...
+        run: go test -race -count=2 -v -run="TiDB" -version="tidb6" ./...
   
   integration-sqlite:
     runs-on: ubuntu-latest
@@ -508,7 +508,7 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for sqlite
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="SQLite.*" -dialect="sqlite" ./...
+        run: go test -race -count=2 -v -run="SQLite.*" -version="sqlite" ./...
   
   integration-cockroach:
     runs-on: ubuntu-latest
@@ -532,5 +532,5 @@ jobs:
             ${{ runner.os }}-go-
       - name: Run integration tests for cockroach
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="Cockroach" -dialect="cockroach" ./...
+        run: go test -race -count=2 -v -run="Cockroach" -version="cockroach" ./...
   
\ No newline at end of file
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index c742691b..9df1303e 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -45,10 +45,10 @@ const (
 	migrateFlagDryRun           = "dry-run"
 	migrateFlagTo               = "to"
 	migrateFlagSchema           = "schema"
-	migrateDiffFlagVerbose      = "verbose"
 	migrateLintLatest           = "latest"
 	migrateLintGitDir           = "git-dir"
 	migrateLintGitBase          = "git-base"
+	migrateDiffQualifier        = "qualifier"
 	migrateApplyAllowDirty      = "allow-dirty"
 	migrateApplyFromVersion     = "from"
 	migrateApplyBaselineVersion = "baseline"
@@ -65,7 +65,6 @@ var (
 		DirFormat      string
 		RevisionSchema string
 		Force          bool
-		Verbose        bool
 		Apply          struct {
 			DryRun          bool
 			LogFormat       string
@@ -73,6 +72,9 @@ var (
 			FromVersion     string
 			BaselineVersion string
 		}
+		Diff struct {
+			Qualifier string // optional table qualifier
+		}
 		Lint struct {
 			Format  string // log formatting
 			Latest  uint   // latest N migration files
@@ -247,7 +249,7 @@ func init() {
 	// Diff flags.
 	urlFlag(&MigrateFlags.DevURL, migrateFlagDevURL, "", MigrateDiffCmd.Flags())
 	MigrateDiffCmd.Flags().StringSliceVarP(&MigrateFlags.ToURLs, migrateFlagTo, "", nil, "[driver://username:password@address/dbname?param=value ...] select a desired state using the URL format")
-	MigrateDiffCmd.Flags().BoolVarP(&MigrateFlags.Verbose, migrateDiffFlagVerbose, "", false, "enable verbose logging")
+	MigrateDiffCmd.Flags().StringVarP(&MigrateFlags.Diff.Qualifier, migrateDiffQualifier, "", "", "qualify tables with custom qualifier when working on a single schema")
 	MigrateDiffCmd.Flags().SortFlags = false
 	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagDevURL))
 	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagTo))
@@ -289,6 +291,15 @@ func CmdMigrateApplyRun(cmd *cobra.Command, args []string) error {
 		return err
 	}
 	defer c.Close()
+	// Acquire a lock.
+	if l, ok := c.Driver.(schema.Locker); ok {
+		unlock, err := l.Lock(cmd.Context(), "atlas_migrate_execute", 0)
+		if err != nil {
+			return fmt.Errorf("acquiring database lock: %w", err)
+		}
+		// If unlocking fails notify the user about it.
+		defer cobra.CheckErr(unlock())
+	}
 	// Get the correct log format and destination. Currently, only os.Stdout is supported.
 	l, err := logFormat(cmd.OutOrStdout())
 	if err != nil {
@@ -315,11 +326,6 @@ func CmdMigrateApplyRun(cmd *cobra.Command, args []string) error {
 	if err != nil {
 		return err
 	}
-	unlock, err := ex.Lock(cmd.Context())
-	if err != nil {
-		return err
-	}
-	defer unlock()
 	pending, err := ex.Pending(cmd.Context())
 	if err != nil && !errors.Is(err, migrate.ErrNoPendingFiles) {
 		return err
@@ -407,7 +413,7 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	if l, ok := dev.Driver.(schema.Locker); ok {
 		unlock, err := l.Lock(cmd.Context(), "atlas_migrate_diff", 0)
 		if err != nil {
-			return err
+			return fmt.Errorf("acquiring database lock: %w", err)
 		}
 		// If unlocking fails notify the user about it.
 		defer cobra.CheckErr(unlock())
@@ -430,7 +436,7 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	opts := []migrate.PlannerOption{migrate.PlanFormat(f)}
 	if dev.URL.Schema != "" {
 		// Disable tables qualifier in schema-mode.
-		opts = append(opts, migrate.PlanWithSchemaQualifier(""))
+		opts = append(opts, migrate.PlanWithSchemaQualifier(MigrateFlags.Diff.Qualifier))
 	}
 	// Plan the changes and create a new migration file.
 	pl := migrate.NewPlanner(dev.Driver, dir, opts...)
@@ -546,10 +552,8 @@ func statusPrint(out io.Writer, avail, pending []migrate.File, revs migrate.Revi
 	switch len(pending) {
 	case len(avail):
 		cur = "No version applied yet"
-	case 0:
-		cur = cyan(avail[len(avail)-1].Version())
 	default:
-		cur = cyan(avail[len(avail)-len(pending)].Version())
+		cur = cyan(applied[len(applied)-1].Version())
 		// If the last pending version is partially applied, tell so.
 		if partial {
 			cur += fmt.Sprintf(" (%d statements applied)", revs[len(revs)-1].Applied)
@@ -569,16 +573,15 @@ func statusPrint(out io.Writer, avail, pending []migrate.File, revs migrate.Revi
 	if partial {
 		exec += " + 1 partially"
 	}
-	fmt.Fprintf(out, "Migration Status: %s\n", state)
-	fmt.Fprintf(out, "%s%s Current Version:\t%s\n", indent2, dash, cur)
-	fmt.Fprintf(out, "%s%s Next Version:\t%s\n", indent2, dash, next)
-	// fmt.Fprintf(out, "%s%s Available Files:\t%s\n", indent2, dash, cyan(strconv.Itoa(len(avail))))
-	fmt.Fprintf(out, "%s%s Executed Files:\t%s\n", indent2, dash, exec)
 	c := cyan
 	if len(pending) == 0 {
 		c = green
 	}
-	fmt.Fprintf(out, "%s%s Pending Files:\t%s", indent2, dash, c(strconv.Itoa(len(pending))))
+	fmt.Fprintf(out, "Migration Status: %s\n", state)
+	fmt.Fprintf(out, "%s%s Current Version:  %s\n", indent2, dash, cur)
+	fmt.Fprintf(out, "%s%s Next Version:     %s\n", indent2, dash, next)
+	fmt.Fprintf(out, "%s%s Executed Files:   %s\n", indent2, dash, exec)
+	fmt.Fprintf(out, "%s%s Pending Files:    %s", indent2, dash, c(strconv.Itoa(len(pending))))
 	if partial {
 		fmt.Fprintf(out, " (partially)")
 	}
diff --git a/cmd/atlas/internal/cmdapi/schema.go b/cmd/atlas/internal/cmdapi/schema.go
index 49da6d84..517a9a05 100644
--- a/cmd/atlas/internal/cmdapi/schema.go
+++ b/cmd/atlas/internal/cmdapi/schema.go
@@ -55,7 +55,6 @@ var (
 		Paths       []string
 		DryRun      bool
 		AutoApprove bool
-		Verbose     bool
 	}
 	// SchemaApply represents the 'atlas schema apply' subcommand command.
 	SchemaApply = &cobra.Command{
@@ -143,7 +142,6 @@ func init() {
 	SchemaApply.Flags().StringVarP(&ApplyFlags.DevURL, devURLFlag, "", "", "URL for the dev database. Used to validate schemas and calculate diffs\nbefore running migration.")
 	SchemaApply.Flags().BoolVarP(&ApplyFlags.DryRun, "dry-run", "", false, "Dry-run. Print SQL plan without prompting for execution.")
 	SchemaApply.Flags().BoolVarP(&ApplyFlags.AutoApprove, "auto-approve", "", false, "Auto approve. Apply the schema changes without prompting for approval.")
-	SchemaApply.Flags().BoolVarP(&ApplyFlags.Verbose, migrateDiffFlagVerbose, "", false, "enable verbose logging")
 	SchemaApply.Flags().StringVarP(&SchemaFlags.DSN, dsnFlag, "d", "", "")
 	cobra.CheckErr(SchemaApply.Flags().MarkHidden(dsnFlag))
 	cobra.CheckErr(SchemaApply.MarkFlagRequired(urlFlag))
diff --git a/doc/md/reference.md b/doc/md/reference.md
index c0f6aff7..7d38bb32 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -142,9 +142,9 @@ directory state to the desired schema. The desired state can be another connecte
 ```
 #### Flags
 ```
-      --dev-url string   [driver://username:password@address/dbname?param=value] select a database using the URL format
-      --to strings       [driver://username:password@address/dbname?param=value ...] select a desired state using the URL format
-      --verbose          enable verbose logging
+      --dev-url string     [driver://username:password@address/dbname?param=value] select a database using the URL format
+      --to strings         [driver://username:password@address/dbname?param=value ...] select a desired state using the URL format
+      --qualifier string   qualify tables with custom qualifier when working on a single schema
 
 ```
 
@@ -333,7 +333,6 @@ migration.
                          before running migration.
       --dry-run          Dry-run. Print SQL plan without prompting for execution.
       --auto-approve     Auto approve. Apply the schema changes without prompting for approval.
-      --verbose          enable verbose logging
 
 ```
 
diff --git a/internal/ci/ci.tmpl b/internal/ci/ci.tmpl
index ed7d6a84..b91a123b 100644
--- a/internal/ci/ci.tmpl
+++ b/internal/ci/ci.tmpl
@@ -135,5 +135,5 @@ jobs:
             {{ "${{ runner.os }}-go-" }}
       - name: Run integration tests for {{ .Version }}
         working-directory: internal/integration
-        run: go test -race -count=2 -v -run="{{ .Regex }}" -dialect="{{ .Version }}" ./...
+        run: go test -race -count=2 -v -run="{{ .Regex }}" -version="{{ .Version }}" ./...
   {{ end }}
\ No newline at end of file
diff --git a/sql/internal/sqlx/plan.go b/sql/internal/sqlx/plan.go
index 6be2ec43..a6a96b4b 100644
--- a/sql/internal/sqlx/plan.go
+++ b/sql/internal/sqlx/plan.go
@@ -248,3 +248,37 @@ func isDropped(changes []schema.Change, t *schema.Table) bool {
 	}
 	return false
 }
+
+// CheckChangesScope checks that changes can be applied
+// on a schema scope (connection).
+func CheckChangesScope(changes []schema.Change) error {
+	names := make(map[string]struct{})
+	for _, c := range changes {
+		var t *schema.Table
+		switch c := c.(type) {
+		case *schema.AddSchema, *schema.ModifySchema, *schema.DropSchema:
+			return fmt.Errorf("%T is not allowed when migration plan is scoped to one schema", c)
+		case *schema.AddTable:
+			t = c.T
+		case *schema.ModifyTable:
+			t = c.T
+		case *schema.DropTable:
+			t = c.T
+		default:
+			continue
+		}
+		if t.Schema != nil && t.Schema.Name != "" {
+			names[t.Schema.Name] = struct{}{}
+		}
+		for _, c := range t.Columns {
+			e, ok := c.Type.Type.(*schema.EnumType)
+			if ok && e.Schema != nil && e.Schema.Name != "" {
+				names[t.Schema.Name] = struct{}{}
+			}
+		}
+	}
+	if len(names) > 1 {
+		return fmt.Errorf("found %d schemas when migration plan is scoped to one", len(names))
+	}
+	return nil
+}
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 48e03f8a..241e7318 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -387,8 +387,6 @@ func (p *Planner) WritePlan(plan *Plan) error {
 var (
 	// ErrNoPendingFiles is returned if there are no pending migration files to execute on the managed database.
 	ErrNoPendingFiles = errors.New("sql/migrate: execute: nothing to do")
-	// ErrLockUnsupported is returned if there is no schema.Locker given.
-	ErrLockUnsupported = errors.New("sql/migrate: driver does not support locking")
 	// ErrSnapshotUnsupported is returned if there is no Snapshoter given.
 	ErrSnapshotUnsupported = errors.New("sql/migrate: driver does not support taking a database snapshot")
 	// ErrCleanCheckerUnsupported is returned if there is no CleanChecker given.
@@ -417,9 +415,6 @@ func NewExecutor(drv Driver, dir Dir, rrw RevisionReadWriter, opts ...ExecutorOp
 	if ex.log == nil {
 		ex.log = NopLogger{}
 	}
-	if _, ok := drv.(schema.Locker); !ok {
-		return nil, ErrLockUnsupported
-	}
 	if _, ok := drv.(Snapshoter); !ok {
 		return nil, ErrSnapshotUnsupported
 	}
@@ -467,16 +462,6 @@ func WithAllowDirty(b bool) ExecutorOption {
 	}
 }
 
-// Lock acquires a lock for the executor.
-// It is considered a user error to not call Lock before the Pending and Execute methods.
-func (e *Executor) Lock(ctx context.Context) (schema.UnlockFunc, error) {
-	unlock, err := e.drv.(schema.Locker).Lock(ctx, "atlas_migration_execute", 0)
-	if err != nil {
-		return nil, fmt.Errorf("sql/migrate: acquiring database lock: %w", err)
-	}
-	return unlock, nil
-}
-
 // Pending returns all pending (not fully applied) migration files in the migration directory.
 func (e *Executor) Pending(ctx context.Context) ([]File, error) {
 	// Don't operate with a broken migration directory.
@@ -662,15 +647,6 @@ func (e HistoryChangedError) Error() string {
 // ExecuteN executes n pending migration files. If n<=0 all pending migration files are executed. It will not attempt
 // an execution if the database is not "clean" (has only successfully applied migrations).
 func (e *Executor) ExecuteN(ctx context.Context, n int) (err error) {
-	unlock, err := e.Lock(ctx)
-	if err != nil {
-		return err
-	}
-	defer func() {
-		if err2 := unlock(); err2 != nil {
-			err = wrap(err2, err)
-		}
-	}()
 	pending, err := e.Pending(ctx)
 	if err != nil {
 		return err
@@ -699,15 +675,6 @@ func (e *Executor) ExecuteN(ctx context.Context, n int) (err error) {
 
 // Replay the migration directory and invoke the state to get back the inspection result.
 func (e *Executor) Replay(ctx context.Context, r StateReader) (_ *schema.Realm, err error) {
-	unlock, err := e.drv.(schema.Locker).Lock(ctx, "atlas_migration_directory_state", 0)
-	if err != nil {
-		return nil, fmt.Errorf("sql/migrate: acquiring database lock: %w", err)
-	}
-	defer func() {
-		if err2 := unlock(); err2 != nil {
-			err = wrap(err2, err)
-		}
-	}()
 	// Clean up after ourselves.
 	restore, err := e.drv.(Snapshoter).Snapshot(ctx)
 	if err != nil {
diff --git a/sql/mysql/migrate.go b/sql/mysql/migrate.go
index c2d4bae1..80ad48ca 100644
--- a/sql/mysql/migrate.go
+++ b/sql/mysql/migrate.go
@@ -67,6 +67,11 @@ type state struct {
 // plan builds the migration plan for applying the
 // given changes on the attached connection.
 func (s *state) plan(changes []schema.Change) error {
+	if s.SchemaQualifier != nil {
+		if err := sqlx.CheckChangesScope(changes); err != nil {
+			return err
+		}
+	}
 	planned, err := s.topLevel(changes)
 	if err != nil {
 		return err
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 561ba016..4056fe1c 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -68,6 +68,11 @@ type state struct {
 // Exec executes the changes on the database. An error is returned
 // if one of the operations fail, or a change is not supported.
 func (s *state) plan(ctx context.Context, changes []schema.Change) error {
+	if s.SchemaQualifier != nil {
+		if err := sqlx.CheckChangesScope(changes); err != nil {
+			return err
+		}
+	}
 	planned := s.topLevel(changes)
 	planned, err := sqlx.DetachCycles(planned)
 	if err != nil {


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index ff5198ed..34a9af8d 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -101,7 +101,7 @@ func TestMigrate_Apply(t *testing.T) {
 		"--url", 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()))
+	require.True(t, strings.HasPrefix(s, "Error: acquiring database lock: "+errLock.Error()))
 
 	// Will work and print stuff to the console.
 	s, err = runCmd(
@@ -328,7 +328,7 @@ func TestMigrate_Diff(t *testing.T) {
 		"--dev-url", fmt.Sprintf("sqlitelockdiff://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")),
 		"--to", to,
 	)
-	require.True(t, strings.HasPrefix(s, "Error: "+errLock.Error()))
+	require.True(t, strings.HasPrefix(s, "Error: acquiring database lock: "+errLock.Error()))
 	require.ErrorIs(t, err, errLock)
 }
 
diff --git a/internal/integration/cockroach_test.go b/internal/integration/cockroach_test.go
index b0b6b5f4..c6140089 100644
--- a/internal/integration/cockroach_test.go
+++ b/internal/integration/cockroach_test.go
@@ -40,7 +40,7 @@ var crdbTests = map[string]*crdbTest{
 
 func crdbRun(t *testing.T, fn func(*crdbTest)) {
 	for version, tt := range crdbTests {
-		if flagDialect == "" || flagDialect == version {
+		if flagVersion == "" || flagVersion == version {
 			t.Run(version, func(t *testing.T) {
 				tt.once.Do(func() {
 					var err error
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index dca524a7..6e32d73a 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -33,11 +33,11 @@ import (
 
 var (
 	dbs         []io.Closer
-	flagDialect string
+	flagVersion string
 )
 
 func TestMain(m *testing.M) {
-	flag.StringVar(&flagDialect, "dialect", "", "[mysql56, postgres10, tidb5, ...] what dialect (version) to test")
+	flag.StringVar(&flagVersion, "version", "", "[mysql56, postgres10, tidb5, ...] what version to test")
 	flag.Parse()
 	code := m.Run()
 	for _, db := range dbs {
diff --git a/internal/integration/mysql_test.go b/internal/integration/mysql_test.go
index 5a0ad37a..0a432706 100644
--- a/internal/integration/mysql_test.go
+++ b/internal/integration/mysql_test.go
@@ -42,7 +42,7 @@ var myTests = map[string]*myTest{
 
 func myRun(t *testing.T, fn func(*myTest)) {
 	for version, tt := range myTests {
-		if flagDialect == "" || flagDialect == version {
+		if flagVersion == "" || flagVersion == version {
 			t.Run(version, func(t *testing.T) {
 				tt.once.Do(func() {
 					var err error
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index f4b858ff..17d2265d 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -46,7 +46,7 @@ var pgTests = map[string]*pgTest{
 
 func pgRun(t *testing.T, fn func(*pgTest)) {
 	for version, tt := range pgTests {
-		if flagDialect == "" || flagDialect == version {
+		if flagVersion == "" || flagVersion == version {
 			t.Run(version, func(t *testing.T) {
 				tt.once.Do(func() {
 					var err error
@@ -709,9 +709,9 @@ table "posts" {
 }
 `
 		err = os.WriteFile(filepath.Join(dir, "schema.hcl"), []byte(hcl), 0600)
-		diff := func() string {
+		diff := func(name string) string {
 			out, err := exec.Command(
-				bin, "migrate", "diff",
+				bin, "migrate", "diff", name,
 				"--dir", fmt.Sprintf("file://%s", filepath.Join(dir, "migrations")),
 				"--to", fmt.Sprintf("file://%s", filepath.Join(dir, "schema.hcl")),
 				"--dev-url", fmt.Sprintf("postgres://postgres:pass@localhost:%d/migrate_diff?sslmode=disable", t.port),
@@ -719,7 +719,7 @@ table "posts" {
 			require.NoError(t, err, string(out))
 			return strings.TrimSpace(string(out))
 		}
-		require.Empty(t, diff())
+		require.Empty(t, diff("initial"))
 
 		// Expect one file and read its contents.
 		files, err := os.ReadDir(filepath.Join(dir, "migrations"))
@@ -736,7 +736,26 @@ CREATE TABLE "public"."users" ("id" integer NOT NULL);
 -- create "posts" table
 CREATE TABLE "other"."posts" ("id" integer NOT NULL);
 `, string(b))
-		require.Equal(t, "The migration directory is synced with the desired state, no changes to be made", diff())
+		require.Equal(t, "The migration directory is synced with the desired state, no changes to be made", diff("no_change"))
+
+		// Append a change to the schema and expect a migration to be created.
+		hcl += `
+table "other" "users" {
+	schema = schema.other
+	column "id" { type = integer }
+}`
+		err = os.WriteFile(filepath.Join(dir, "schema.hcl"), []byte(hcl), 0600)
+		require.Empty(t, diff("second"))
+		require.Equal(t, "The migration directory is synced with the desired state, no changes to be made", diff("no_change"))
+		files, err = os.ReadDir(filepath.Join(dir, "migrations"))
+		require.NoError(t, err)
+		require.Equal(t, 3, len(files), dir)
+		b, err = os.ReadFile(filepath.Join(dir, "migrations", files[1].Name()))
+		require.NoError(t, err)
+		require.Equal(t,
+			`-- create "users" table
+CREATE TABLE "other"."users" ("id" integer NOT NULL);
+`, string(b))
 	})
 }
 
diff --git a/internal/integration/testdata/mysql/cli-migrate-diff.txt b/internal/integration/testdata/mysql/cli-migrate-diff.txt
index d59a9b77..b63ffa2c 100644
--- a/internal/integration/testdata/mysql/cli-migrate-diff.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-diff.txt
@@ -14,6 +14,11 @@ cmpmig 0 1.sql
 atlas migrate diff --dev-url URL --to file://./2.hcl second
 cmpmig 1 2.sql
 
+# Clean migration directory and run diff with a custom qualifier.
+exec rm -rf migrations
+atlas migrate diff --dev-url URL --to file://./1.hcl --qualifier test first
+cmpmig 0 1-qualifier.sql
+
 -- 1.hcl --
 schema "script_cli_migrate_diff" {}
 
@@ -35,6 +40,10 @@ table "users" {
 -- create "users" table
 CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_general_ci;
 
+-- 1-qualifier.sql --
+-- create "users" table
+CREATE TABLE `test`.`users` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_general_ci;
+
 -- 2.hcl --
 schema "script_cli_migrate_diff" {}
 
diff --git a/internal/integration/testdata/postgres/cli-migrate-apply.txt b/internal/integration/testdata/postgres/cli-migrate-apply.txt
index d699d1a9..66452cd6 100644
--- a/internal/integration/testdata/postgres/cli-migrate-apply.txt
+++ b/internal/integration/testdata/postgres/cli-migrate-apply.txt
@@ -1,4 +1,4 @@
-only postgres12 postgres14
+only postgres14
 
 ! atlas migrate apply
 stderr 'Error: checksum file not found'
diff --git a/internal/integration/testdata/postgres/cli-migrate-status.txt b/internal/integration/testdata/postgres/cli-migrate-status.txt
new file mode 100644
index 00000000..9a94d434
--- /dev/null
+++ b/internal/integration/testdata/postgres/cli-migrate-status.txt
@@ -0,0 +1,51 @@
+only postgres12
+
+# make sure sum file is correct
+atlas migrate hash --force
+
+# for clean database
+atlas migrate status --url URL --revisions-schema $db
+cmp stdout status_clean.txt
+
+# apply one
+atlas migrate apply --url URL --revisions-schema $db 1
+atlas migrate status --url URL --revisions-schema $db
+cmp stdout status_one_applied.txt
+
+# apply next (and last)
+atlas migrate apply --url URL --revisions-schema $db 1
+atlas migrate status --url URL --revisions-schema $db
+cmp stdout status_ok.txt
+
+-- migrations/1.sql --
+CREATE TABLE "users" ("id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, PRIMARY KEY ("id"));
+
+-- migrations/2.sql --
+ALTER TABLE "users" ADD COLUMN "happy" boolean NOT NULL DEFAULT true;
+
+-- broken_migration.sql --
+CREATE TABLE "users" ("id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, PRIMARY KEY ("id"));
+THIS LINE ADDS A SYNTAX ERROR;
+
+-- fixed_migration.sql --
+CREATE TABLE "users" ("id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, PRIMARY KEY ("id"));
+ALTER TABLE "users" ADD COLUMN "happy" boolean NOT NULL DEFAULT true;
+
+-- status_clean.txt --
+Migration Status: PENDING
+  -- Current Version:  No version applied yet
+  -- Next Version:     1
+  -- Executed Files:   0
+  -- Pending Files:    2
+-- status_one_applied.txt --
+Migration Status: PENDING
+  -- Current Version:  1
+  -- Next Version:     2
+  -- Executed Files:   1
+  -- Pending Files:    1
+-- status_ok.txt --
+Migration Status: OK
+  -- Current Version:  2
+  -- Next Version:     Already at latest version
+  -- Executed Files:   2
+  -- Pending Files:    0
\ No newline at end of file
diff --git a/internal/integration/tidb_test.go b/internal/integration/tidb_test.go
index 2312860f..c0b79666 100644
--- a/internal/integration/tidb_test.go
+++ b/internal/integration/tidb_test.go
@@ -31,7 +31,7 @@ var tidbTests = map[string]*myTest{
 
 func tidbRun(t *testing.T, fn func(*myTest)) {
 	for version, tt := range tidbTests {
-		if flagDialect == "" || flagDialect == version {
+		if flagVersion == "" || flagVersion == version {
 			t.Run(version, func(t *testing.T) {
 				tt.once.Do(func() {
 					var err error
diff --git a/sql/internal/sqlx/plan_test.go b/sql/internal/sqlx/plan_test.go
index 64fb5085..7976b97f 100644
--- a/sql/internal/sqlx/plan_test.go
+++ b/sql/internal/sqlx/plan_test.go
@@ -92,3 +92,23 @@ func TestDetachCycles(t *testing.T) {
 	workplaces.ForeignKeys = nil
 	require.Equal(t, deletion, planned[2:])
 }
+
+func TestCheckChangesScope(t *testing.T) {
+	err := CheckChangesScope([]schema.Change{
+		&schema.AddSchema{},
+	})
+	require.EqualError(t, err, "*schema.AddSchema is not allowed when migration plan is scoped to one schema")
+	err = CheckChangesScope([]schema.Change{
+		&schema.ModifySchema{},
+	})
+	require.EqualError(t, err, "*schema.ModifySchema is not allowed when migration plan is scoped to one schema")
+	err = CheckChangesScope([]schema.Change{
+		&schema.DropSchema{},
+	})
+	require.EqualError(t, err, "*schema.DropSchema is not allowed when migration plan is scoped to one schema")
+	err = CheckChangesScope([]schema.Change{
+		&schema.AddTable{T: schema.NewTable("users").SetSchema(schema.New("s1"))},
+		&schema.AddTable{T: schema.NewTable("users").SetSchema(schema.New("s2"))},
+	})
+	require.EqualError(t, err, "found 2 schemas when migration plan is scoped to one")
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index c15837d7..06495d74 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -59,7 +59,7 @@ func TestPlanner_WritePlan(t *testing.T) {
 
 func TestPlanner_Plan(t *testing.T) {
 	var (
-		drv = &lockMockDriver{&mockDriver{}}
+		drv = &mockDriver{}
 		ctx = context.Background()
 	)
 	d, err := migrate.NewLocalDir(t.TempDir())
@@ -89,7 +89,7 @@ func TestPlanner_Plan(t *testing.T) {
 
 func TestPlanner_PlanSchema(t *testing.T) {
 	var (
-		drv = &lockMockDriver{&mockDriver{}}
+		drv = &mockDriver{}
 		ctx = context.Background()
 	)
 	d, err := migrate.NewLocalDir(t.TempDir())
@@ -125,43 +125,24 @@ func TestExecutor_Replay(t *testing.T) {
 	d, err := migrate.NewLocalDir("testdata/migrate")
 	require.NoError(t, err)
 
-	// Locking not supported.
-	_, err = migrate.NewExecutor(&mockDriver{}, d, migrate.NopRevisionReadWriter{})
-	require.ErrorIs(t, err, migrate.ErrLockUnsupported)
-
-	drv := &lockMockDriver{&mockDriver{}}
+	drv := &mockDriver{}
 	ex, err := migrate.NewExecutor(drv, d, migrate.NopRevisionReadWriter{})
 	require.NoError(t, err)
 
 	_, err = ex.Replay(ctx, migrate.RealmConn(drv, nil))
 	require.NoError(t, err)
 	require.Equal(t, []string{"DROP TABLE IF EXISTS t;", "CREATE TABLE t(c int);"}, drv.executed)
-	require.Equal(t, 2, drv.lockCounter)
-	require.Equal(t, 2, drv.unlockCounter)
-	require.True(t, drv.released())
-
-	// Does not work if locked.
-	drv.locks = map[string]struct{}{"atlas_migration_directory_state": {}}
-	_, err = ex.Replay(ctx, migrate.RealmConn(drv, nil))
-	require.EqualError(t, err, "sql/migrate: acquiring database lock: lockErr")
-	require.Equal(t, 2, drv.lockCounter)
-	require.Equal(t, 2, drv.unlockCounter)
-	require.False(t, drv.released())
-	drv.locks = make(map[string]struct{})
 
 	// Does not work if database is not clean.
 	drv.dirty = true
 	drv.realm = schema.Realm{Schemas: []*schema.Schema{{Name: "schema"}}}
 	_, err = ex.Replay(ctx, migrate.RealmConn(drv, nil))
 	require.ErrorAs(t, err, &migrate.NotCleanError{})
-	require.Equal(t, 3, drv.lockCounter)
-	require.Equal(t, 3, drv.unlockCounter)
-	require.True(t, drv.released())
 }
 
 func TestExecutor_Pending(t *testing.T) {
 	var (
-		drv  = &lockMockDriver{&mockDriver{}}
+		drv  = &mockDriver{}
 		rrw  = &mockRevisionReadWriter{}
 		log  = &mockLogger{}
 		rev1 = &migrate.Revision{
@@ -232,23 +213,18 @@ func TestExecutor(t *testing.T) {
 	require.EqualError(t, err, "sql/migrate: execute: no revision storage given")
 	require.Nil(t, ex)
 
-	// Does not work if no locking mechanism is provided.
-	ex, err = migrate.NewExecutor(&mockDriver{}, dir, &mockRevisionReadWriter{})
-	require.ErrorIs(t, err, migrate.ErrLockUnsupported)
-	require.Nil(t, ex)
-
 	// Does not operate on invalid migration dir.
 	dir, err = migrate.NewLocalDir(t.TempDir())
 	require.NoError(t, err)
 	require.NoError(t, dir.WriteFile("atlas.sum", hash))
-	ex, err = migrate.NewExecutor(&lockMockDriver{&mockDriver{}}, dir, &mockRevisionReadWriter{})
+	ex, err = migrate.NewExecutor(&mockDriver{}, dir, &mockRevisionReadWriter{})
 	require.NoError(t, err)
 	require.NotNil(t, ex)
 	require.ErrorIs(t, ex.ExecuteN(context.Background(), 0), migrate.ErrChecksumMismatch)
 
 	// Prerequisites.
 	var (
-		drv  = &lockMockDriver{&mockDriver{}}
+		drv  = &mockDriver{}
 		rrw  = &mockRevisionReadWriter{}
 		log  = &mockLogger{}
 		rev1 = &migrate.Revision{
@@ -288,9 +264,6 @@ func TestExecutor(t *testing.T) {
 		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)
-	require.True(t, drv.released())
 
 	// Partly is pending.
 	p, err := ex.Pending(context.Background())
@@ -300,20 +273,17 @@ func TestExecutor(t *testing.T) {
 
 	// Apply one by one.
 	*rrw = mockRevisionReadWriter{}
-	*drv = lockMockDriver{&mockDriver{}}
+	*drv = mockDriver{}
 
 	require.NoError(t, ex.ExecuteN(context.Background(), 1))
-	require.Equal(t, drv.executed, []string{"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;"})
+	require.Equal(t, []string{"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;"}, drv.executed)
 	requireEqualRevisions(t, migrate.Revisions{rev1}, migrate.Revisions(*rrw))
 
 	require.NoError(t, ex.ExecuteN(context.Background(), 1))
-	require.Equal(t, drv.executed, []string{
+	require.Equal(t, []string{
 		"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;", "ALTER TABLE t_sub ADD c2 int;",
-	})
+	}, drv.executed)
 	requireEqualRevisions(t, migrate.Revisions{rev1, rev2}, migrate.Revisions(*rrw))
-	require.Equal(t, 2, drv.lockCounter)
-	require.Equal(t, 2, drv.unlockCounter)
-	require.True(t, drv.released())
 
 	// Partly is pending.
 	p, err = ex.Pending(context.Background())
@@ -323,7 +293,7 @@ func TestExecutor(t *testing.T) {
 
 	// Suppose first revision is already executed, only execute second migration file.
 	*rrw = mockRevisionReadWriter(migrate.Revisions{rev1})
-	*drv = lockMockDriver{&mockDriver{}}
+	*drv = mockDriver{}
 
 	require.NoError(t, ex.ExecuteN(context.Background(), 1))
 	require.Equal(t, []string{"ALTER TABLE t_sub ADD c2 int;"}, drv.executed)
@@ -335,13 +305,9 @@ func TestExecutor(t *testing.T) {
 	require.Len(t, p, 1)
 	require.Equal(t, "3_partly.sql", p[0].Name())
 
-	require.Equal(t, 1, drv.lockCounter)
-	require.Equal(t, 1, drv.unlockCounter)
-	require.True(t, drv.released())
-
 	// Failing, counter will be correct.
 	*rrw = mockRevisionReadWriter(migrate.Revisions{rev1, rev2})
-	*drv = lockMockDriver{&mockDriver{}}
+	*drv = mockDriver{}
 	drv.failOn(2, errors.New("this is an error"))
 	require.ErrorContains(t, ex.ExecuteN(context.Background(), 1), "this is an error")
 	revs, err := rrw.ReadRevisions(context.Background())
@@ -362,7 +328,7 @@ func TestExecutor(t *testing.T) {
 
 	// Re-attempting to migrate will pick up where the execution was left off.
 	revs[len(revs)-1].PartialHashes[0] = h
-	*drv = lockMockDriver{&mockDriver{}}
+	*drv = mockDriver{}
 	require.NoError(t, ex.ExecuteN(context.Background(), 1))
 	require.Equal(t, []string{"ALTER TABLE t_sub ADD c4 int;"}, drv.executed)
 
@@ -373,7 +339,7 @@ func TestExecutor(t *testing.T) {
 func TestExecutor_Baseline(t *testing.T) {
 	var (
 		rrw mockRevisionReadWriter
-		drv = &lockMockDriver{&mockDriver{dirty: true}}
+		drv = &mockDriver{dirty: true}
 		log = &mockLogger{}
 	)
 	dir, err := migrate.NewLocalDir(filepath.Join("testdata/migrate", "sub"))
@@ -417,7 +383,7 @@ func TestExecutor_Baseline(t *testing.T) {
 
 func TestExecutor_FromVersion(t *testing.T) {
 	var (
-		drv = &lockMockDriver{&mockDriver{}}
+		drv = &mockDriver{}
 		log = &mockLogger{}
 		rrw = &mockRevisionReadWriter{
 			{
@@ -455,19 +421,15 @@ func TestExecutor_FromVersion(t *testing.T) {
 type (
 	mockDriver struct {
 		migrate.Driver
-		plan          *migrate.Plan
-		changes       []schema.Change
-		applied       []schema.Change
-		realm         schema.Realm
-		executed      []string
-		locks         map[string]struct{}
-		lockCounter   int
-		unlockCounter int
-		failCounter   int
-		failWith      error
-		dirty         bool
+		plan        *migrate.Plan
+		changes     []schema.Change
+		applied     []schema.Change
+		realm       schema.Realm
+		executed    []string
+		failCounter int
+		failWith    error
+		dirty       bool
 	}
-	lockMockDriver struct{ *mockDriver }
 )
 
 // the nth call to ExecContext will fail with the given error.
@@ -533,26 +495,6 @@ func (m *mockDriver) CheckClean(context.Context, *migrate.TableIdent) error {
 	return nil
 }
 
-func (m *lockMockDriver) Lock(_ context.Context, name string, _ time.Duration) (schema.UnlockFunc, error) {
-	if _, ok := m.locks[name]; ok {
-		return nil, errors.New("lockErr")
-	}
-	m.lockCounter++
-	if m.locks == nil {
-		m.locks = make(map[string]struct{})
-	}
-	m.locks[name] = struct{}{}
-	return func() error {
-		m.unlockCounter++
-		delete(m.locks, name)
-		return nil
-	}, nil
-}
-
-func (m *lockMockDriver) released() bool {
-	return len(m.locks) == 0
-}
-
 type mockRevisionReadWriter migrate.Revisions
 
 func (mockRevisionReadWriter) Ident() *migrate.TableIdent {
diff --git a/sql/mysql/migrate_test.go b/sql/mysql/migrate_test.go
index 73dba6b0..55b834c3 100644
--- a/sql/mysql/migrate_test.go
+++ b/sql/mysql/migrate_test.go
@@ -922,6 +922,17 @@ func TestPlanChanges(t *testing.T) {
 				},
 			},
 		},
+		// Empty qualifier in multi-schema mode should fail.
+		{
+			changes: []schema.Change{
+				&schema.AddTable{T: schema.NewTable("t1").SetSchema(schema.New("s1")).AddColumns(schema.NewIntColumn("a", "int"))},
+				&schema.AddTable{T: schema.NewTable("t2").SetSchema(schema.New("s2")).AddColumns(schema.NewIntColumn("a", "int"))},
+			},
+			options: []migrate.PlanOption{
+				func(o *migrate.PlanOptions) { o.SchemaQualifier = new(string) },
+			},
+			wantErr: true,
+		},
 	}
 	for i, tt := range tests {
 		t.Run(strconv.Itoa(i), func(t *testing.T) {
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index f85bb68e..5c385f76 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -1097,6 +1097,17 @@ func TestPlanChanges(t *testing.T) {
 				},
 			},
 		},
+		// Empty qualifier in multi-schema mode should fail.
+		{
+			changes: []schema.Change{
+				&schema.AddTable{T: schema.NewTable("t1").SetSchema(schema.New("s1")).AddColumns(schema.NewIntColumn("a", "int"))},
+				&schema.AddTable{T: schema.NewTable("t2").SetSchema(schema.New("s2")).AddColumns(schema.NewIntColumn("a", "int"))},
+			},
+			options: []migrate.PlanOption{
+				func(o *migrate.PlanOptions) { o.SchemaQualifier = new(string) },
+			},
+			wantErr: true,
+		},
 	}
 	for i, tt := range tests {
 		t.Run(strconv.Itoa(i), func(t *testing.T) {