Repository: ariga/atlas
Pull request #1120 (2022-09-02 08:47:15)

cmd/atlas/internal: change default behavior of revision table location
This PR changes the default location of the revision information table. The old behavior was to store the table in a separate schema called `atlas_schema_revisions`. Now, if the connection is bound to a schema (e.g. when working with Ent), the table will be stored in that schema, unless the `--revision-schema` flag is given.

To not destroy pre-existing projects, we require the flag to be present, if the connection is schema bound, no table exists in that schema but we can find one in the old default schema.

- [x] add test for the above


Patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index a8113714..ba654b35 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -231,7 +231,7 @@ func init() {
 		set.StringVarP(f, name, short, "", "[driver://username:password@address/dbname?param=value] select a database using the URL format")
 	}
 	revisionsFlag := func(set *pflag.FlagSet) {
-		set.StringVarP(&MigrateFlags.RevisionSchema, migrateFlagRevisionsSchema, "", revision.Table, "schema name where the revisions table resides")
+		set.StringVarP(&MigrateFlags.RevisionSchema, migrateFlagRevisionsSchema, "", "", "schema name where the revisions table resides")
 	}
 	// Global flags.
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using URL format")
@@ -311,6 +311,9 @@ func CmdMigrateApplyRun(cmd *cobra.Command, args []string) error {
 		// If unlocking fails notify the user about it.
 		defer cobra.CheckErr(unlock())
 	}
+	if err := checkRevisionSchemaClarity(cmd, c); err != nil {
+		return err
+	}
 	// Get the correct log format and destination. Currently, only os.Stdout is supported.
 	l, err := logFormat(cmd.OutOrStdout())
 	if err != nil {
@@ -378,8 +381,72 @@ func CmdMigrateApplyRun(cmd *cobra.Command, args []string) error {
 	return mux.commit()
 }
 
+func checkRevisionSchemaClarity(cmd *cobra.Command, c *sqlclient.Client) error {
+	// The "old" default  behavior for the revision schema location was to store the revision table in its own schema.
+	// Now, the table is saved in the connected schema, if any. To keep the backwards compatability, we now require
+	// for schema bound connections to have the schema-revision flag present if there is no revision table in the schema
+	// but the old default schema does have one.
+	if c.URL.Schema != "" && MigrateFlags.RevisionSchema == "" {
+		// If the schema does not contain a revision table, but we can find a table in the previous default schema,
+		// abort and tell the user to specify the intention.
+		opts := &schema.InspectOptions{Tables: []string{revision.Table}}
+		s, err := c.InspectSchema(cmd.Context(), "", opts)
+		var ok bool
+		switch {
+		case schema.IsNotExistError(err):
+			// If the schema does not exist, the table does not as well.
+		case err != nil:
+			return err
+		default:
+			// Connected schema does exist, check if the table does.
+			_, ok = s.Table(revision.Table)
+		}
+		if !ok { // Either schema or table does not exist.
+			// Check for the old default schema. If it does not exist, we have no problem.
+			s, err := c.InspectSchema(cmd.Context(), defaultRevisionSchema, opts)
+			switch {
+			case schema.IsNotExistError(err):
+				// Schema does not exist, we can proceed.
+			case err != nil:
+				return err
+			default:
+				if _, ok := s.Table(revision.Table); ok {
+					fmt.Fprintf(cmd.OutOrStderr(),
+						`We couldn't find a revision table in the connected schema but found one in 
+the schema 'atlas_schema_revisions' and cannot determine the desired behavior.
+
+As a safety guard, we require you to specify whether to use the existing
+table in 'atlas_schema_revisions' or create a new one in the connected schema
+by providing the '--revisions-schema' flag or deleting the 'atlas_schema_revisions'
+schema if it is unused.
+
+`)
+					cmd.SilenceUsage = true
+					cmd.SilenceErrors = true
+					return errors.New("ambiguous revision table")
+				}
+			}
+		}
+	}
+	return nil
+}
+
 func entRevisions(ctx context.Context, c *sqlclient.Client) (*entmigrate.EntRevisions, error) {
-	return entmigrate.NewEntRevisions(ctx, c, entmigrate.WithSchema(MigrateFlags.RevisionSchema))
+	return entmigrate.NewEntRevisions(ctx, c, entmigrate.WithSchema(revisionSchemaName(c)))
+}
+
+// defaultRevisionSchema is the default schema for storing revisions table.
+const defaultRevisionSchema = "atlas_schema_revisions"
+
+func revisionSchemaName(c *sqlclient.Client) string {
+	switch {
+	case MigrateFlags.RevisionSchema != "":
+		return MigrateFlags.RevisionSchema
+	case c.URL.Schema != "":
+		return c.URL.Schema
+	default:
+		return defaultRevisionSchema
+	}
 }
 
 // tx handles wrapping migration execution in transactions.
@@ -590,14 +657,27 @@ func CmdMigrateStatusRun(cmd *cobra.Command, _ []string) error {
 		return err
 	}
 	defer client.Close()
-	if ok, err := revisionsTableExists(cmd.Context(), client); !ok || err != nil {
-		if err != nil {
-			return err
-		}
+	if err := checkRevisionSchemaClarity(cmd, client); err != nil {
+		return err
+	}
+	// Inspect schema and check if the table does already exist.
+	s, err := client.InspectSchema(
+		cmd.Context(),
+		revisionSchemaName(client),
+		&schema.InspectOptions{Tables: []string{revision.Table}},
+	)
+	switch {
+	case err != nil && !schema.IsNotExistError(err):
+		return err
+	case schema.IsNotExistError(err):
+		return statusPrint(cmd.OutOrStdout(), avail, avail, nil)
+	}
+	if _, ok := s.Table(revision.Table); !ok {
+		// Table does not exist.
 		return statusPrint(cmd.OutOrStdout(), avail, avail, nil)
 	}
 	// Currently, only in DB revisions are supported.
-	rrw, err := entmigrate.NewEntRevisions(cmd.Context(), client, entmigrate.WithSchema(MigrateFlags.RevisionSchema))
+	rrw, err := entRevisions(cmd.Context(), client)
 	if err != nil {
 		return err
 	}
@@ -745,7 +825,7 @@ func CmdMigrateLintRun(cmd *cobra.Command, _ []string) error {
 			T: format,
 			W: cmd.OutOrStdout(),
 		},
-		Analyzer: az,
+		Analyzers: az,
 	}
 	err = r.Run(cmd.Context())
 	// Print the error in case it was not printed before.
@@ -771,36 +851,6 @@ to re-hash the contents and resolve the error
 `)
 }
 
-func revisionsTableExists(ctx context.Context, c *sqlclient.Client) (bool, error) {
-	// Connect to the given schema name.
-	sc, err := sqlclient.Open(ctx, MigrateFlags.URL, sqlclient.OpenSchema(MigrateFlags.RevisionSchema))
-	switch {
-	case err != nil && !errors.Is(err, sqlclient.ErrUnsupported):
-		return false, err
-	case errors.Is(err, sqlclient.ErrUnsupported):
-		// If the driver does not support changing the schema use the existing connection.
-		sc = c
-	case err == nil:
-		// Connecting attempt to the schema was successful, make sure to close it.
-		defer sc.Close()
-	}
-	// Inspect schema and check if the table does already exist.
-	s, err := sc.InspectSchema(ctx, "", &schema.InspectOptions{Tables: []string{revision.Table}})
-	switch {
-	case err != nil && !schema.IsNotExistError(err):
-		return false, err
-	case schema.IsNotExistError(err):
-		// Schema does not exist.
-		return false, nil
-	}
-	if _, ok := s.Table(revision.Table); !ok {
-		// Table does not exist.
-		return false, nil
-	}
-	// Schema and Table are present.
-	return true, nil
-}
-
 // dir returns a migrate.Dir to use as migration directory. For now only local directories are supported.
 func dir(create bool) (migrate.Dir, error) {
 	parts := strings.SplitN(MigrateFlags.DirURL, "://", 2)
diff --git a/cmd/atlas/internal/lint/run.go b/cmd/atlas/internal/lint/run.go
index d07c89d5..49e1a6d4 100644
--- a/cmd/atlas/internal/lint/run.go
+++ b/cmd/atlas/internal/lint/run.go
@@ -16,6 +16,8 @@ import (
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/sqlcheck"
 	"ariga.io/atlas/sql/sqlclient"
+
+	"golang.org/x/exp/slices"
 )
 
 // Runner is used to execute CI jobs.
@@ -31,8 +33,8 @@ type Runner struct {
 	// Dir is used for scanning and validating the migration directory.
 	Dir migrate.Dir
 
-	// Analyzer defines the analysis to be run in the CI job.
-	Analyzer sqlcheck.Analyzer
+	// Analyzers defines the analysis to be run in the CI job.
+	Analyzers []sqlcheck.Analyzer
 
 	// ReportWriter writes the summary report.
 	ReportWriter ReportWriter
@@ -108,14 +110,21 @@ func (r *Runner) summary(ctx context.Context) error {
 
 	// Analyze files.
 	for _, f := range diff.Files {
-		fr := NewFileReport(f)
-		if err := r.Analyzer.Analyze(ctx, &sqlcheck.Pass{
-			File:     f,
-			Dev:      r.Dev,
-			Reporter: fr,
-		}); err != nil {
-			fr.Error = err.Error()
+		var (
+			es []string
+			nl = nolintRules(f)
+			fr = NewFileReport(f)
+		)
+		for _, az := range r.Analyzers {
+			if err := az.Analyze(ctx, &sqlcheck.Pass{
+				File:     f,
+				Dev:      r.Dev,
+				Reporter: nl.reporterFor(fr, az),
+			}); err != nil && !nl.skipped {
+				es = append(es, err.Error())
+			}
 		}
+		fr.Error = strings.Join(es, "; ")
 		r.sum.Files = append(r.sum.Files, fr)
 		r.sum.StepResult(
 			fmt.Sprintf(stepAnalyzeFile, f.Name()),
@@ -291,3 +300,44 @@ func (f *FileReport) WriteReport(r sqlcheck.Report) {
 func (w *TemplateWriter) WriteReport(r *SummaryReport) error {
 	return w.T.Execute(w.W, r)
 }
+
+func nolintRules(f *sqlcheck.File) *skipRules {
+	s := &skipRules{pos2rules: make(map[int][]string)}
+	for _, c := range f.Changes {
+		for _, d := range c.Stmt.Directive("nolint") {
+			s.pos2rules[c.Stmt.Pos] = append(s.pos2rules[c.Stmt.Pos], strings.Split(d, " ")...)
+		}
+	}
+	return s
+}
+
+type skipRules struct {
+	pos2rules map[int][]string // statement positions to rules
+	skipped   bool             // last one skipped
+}
+
+func (s *skipRules) reporterFor(rw sqlcheck.ReportWriter, az sqlcheck.Analyzer) sqlcheck.ReportWriter {
+	return sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+		var (
+			ds     = make([]sqlcheck.Diagnostic, 0, len(r.Diagnostics))
+			az, ok = az.(sqlcheck.NamedAnalyzer)
+		)
+		for _, d := range r.Diagnostics {
+			switch rules := s.pos2rules[d.Pos]; {
+			case
+				// A directive without specific classes/codes
+				// (e.g. atlas:nolint) ignore all diagnostics.
+				len(rules) == 1 && rules[0] == "",
+				// Match a specific code/diagnostic. e.g. atlas:nolint DS101.
+				slices.Contains(rules, d.Code),
+				// Skip the entire analyzer (class of changes).
+				ok && slices.Contains(rules, az.Name()):
+			default:
+				ds = append(ds, d)
+			}
+		}
+		if s.skipped = len(ds) == 0; !s.skipped {
+			rw.WriteReport(sqlcheck.Report{Text: r.Text, Diagnostics: ds})
+		}
+	})
+}
diff --git a/cmd/atlas/internal/migrate/migrate.go b/cmd/atlas/internal/migrate/migrate.go
index 9e50f766..5ea776a6 100644
--- a/cmd/atlas/internal/migrate/migrate.go
+++ b/cmd/atlas/internal/migrate/migrate.go
@@ -6,11 +6,9 @@ package migrate
 
 import (
 	"context"
-	"errors"
 
 	"ariga.io/atlas/cmd/atlas/internal/migrate/ent"
 	"ariga.io/atlas/cmd/atlas/internal/migrate/ent/revision"
-	sch "ariga.io/atlas/cmd/atlas/internal/migrate/ent/schema"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
@@ -39,9 +37,6 @@ func NewEntRevisions(ctx context.Context, ac *sqlclient.Client, opts ...Option)
 			return nil, err
 		}
 	}
-	if r.schema == "" {
-		r.schema = sch.DefaultRevisionSchema
-	}
 	// Create the connection with the underlying migrate.Driver to have it inside a possible transaction.
 	entopts := []ent.Option{ent.Driver(sql.NewDriver(r.ac.Name, sql.Conn{ExecQuerier: r.ac.Driver}))}
 	// SQLite does not support multiple schema, therefore schema-config is only needed for other dialects.
@@ -58,8 +53,10 @@ func NewEntRevisions(ctx context.Context, ac *sqlclient.Client, opts ...Option)
 				return nil, err
 			}
 		}
-		// Tell Ent to operate on that schema.
-		entopts = append(entopts, ent.AlternateSchema(ent.SchemaConfig{Revision: r.schema}))
+		// Tell Ent to operate on a given schema.
+		if r.schema != "" {
+			entopts = append(entopts, ent.AlternateSchema(ent.SchemaConfig{Revision: r.schema}))
+		}
 	}
 	// Instantiate the Ent client and migrate the revision schema.
 	r.ec = ent.NewClient(entopts...)
@@ -121,19 +118,17 @@ func (r *EntRevisions) WriteRevision(ctx context.Context, rev *migrate.Revision)
 // execution in a transaction and assumes the underlying connection is of type *sql.DB, which is not true for actually
 // reading and writing revisions.
 func (r *EntRevisions) Migrate(ctx context.Context) error {
-	return ent.NewClient(ent.Driver(sql.OpenDB(r.ac.Name, r.ac.DB))).Schema.Create(ctx,
-		entschema.WithDropColumn(true),
-		entschema.WithDiffHook(func(next entschema.Differ) entschema.Differ {
-			return entschema.DiffFunc(func(current, desired *schema.Schema) ([]schema.Change, error) {
-				t, ok := desired.Table(revision.Table)
-				if !ok {
-					return nil, errors.New("revisions table not found in desired state")
-				}
-				t.SetSchema(schema.New(r.schema))
-				return next.Diff(current, desired)
-			})
-		}),
-	)
+	c := ent.NewClient(ent.Driver(sql.OpenDB(r.ac.Name, r.ac.DB)))
+	// Ensure the ent client is bound to the requested revision schema. Open a new connection, if not.
+	if r.ac.Name != dialect.SQLite && r.ac.URL.Schema != r.schema {
+		sc, err := sqlclient.OpenURL(ctx, r.ac.URL.URL, sqlclient.OpenSchema(r.schema))
+		if err != nil {
+			return err
+		}
+		defer sc.Close()
+		c = ent.NewClient(ent.Driver(sql.OpenDB(sc.Name, sc.DB)))
+	}
+	return c.Schema.Create(ctx, entschema.WithDropColumn(true))
 }
 
 var _ migrate.RevisionReadWriter = (*EntRevisions)(nil)
diff --git a/doc/md/reference.md b/doc/md/reference.md
index 1c185d3c..5617b108 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -105,7 +105,7 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.
 #### Flags
 ```
       --log string                log format to use (default "tty")
-      --revisions-schema string   schema name where the revisions table resides (default "atlas_schema_revisions")
+      --revisions-schema string   schema name where the revisions table resides
       --dry-run                   do not actually execute any SQL but show it on screen
       --from string               calculate pending files from the given version (including it)
       --baseline string           start the first migration after the given baseline version
@@ -232,7 +232,7 @@ atlas migrate status [flags]
 ```
 #### Flags
 ```
-      --revisions-schema string   schema name where the revisions table resides (default "atlas_schema_revisions")
+      --revisions-schema string   schema name where the revisions table resides
   -u, --url string                [driver://username:password@address/dbname?param=value] select a database using the URL format
 
 ```
diff --git a/doc/md/versioned/apply.mdx b/doc/md/versioned/apply.mdx
index 46605978..a65cba76 100644
--- a/doc/md/versioned/apply.mdx
+++ b/doc/md/versioned/apply.mdx
@@ -115,36 +115,36 @@ flag:
 First time apply with baseline on production environment:
 ```shell
 atlas migrate apply \
-  --env production \
-  --baseline 20220811074144
+  --env "production" \
+  --baseline "20220811074144"
 ```
 
 Execute 1 pending migration file, but don't run, but print SQL statements on screen:
 ```shell
 atlas migrate apply 1 \
-  --env production \
-  --baseline 20220811074144 \
+  --env "production" \
+  --baseline "20220811074144" \
   --dry-run
 ```
 
 Specify revision table schema and custom migration directory path:
 ```shell
 atlas migrate apply \
-  --url mysql://root:pass@remote:3306/my_database \
-  --revisions-schema atlas_migration_history \
-  --dir file://custom/path/to/dir
+  --url "mysql://root:pass@remote:3306/my_database" \
+  --revisions-schema "atlas_migration_history" \
+  --dir "file://custom/path/to/dir"
 ```
 
 Ignore unclean database and run the first 3 migrations:
 ```shell
 atlas migrate apply 3 \
-  --url mysql://root:pass@remote:3306/my_database \
-  --dir file://custom/path/to/dir
+  --url "mysql://root:pass@remote:3306/my_database" \
+  --dir "file://custom/path/to/dir"
 ```
 
 Run all pending migrations, but do not use a transaction:
 ```shell
 atlas migrate apply \
-  --url mysql://root:pass@remote:3306/my_database \
-  --tx-mode none
+  --url "mysql://root:pass@remote:3306/my_database" \
+  --tx-mode "none"
 ```
diff --git a/doc/md/versioned/diff.mdx b/doc/md/versioned/diff.mdx
index 3b1492b6..592f1e32 100644
--- a/doc/md/versioned/diff.mdx
+++ b/doc/md/versioned/diff.mdx
@@ -323,8 +323,10 @@ values={[
 <TabItem value="mysql">
 
 ```shell
-atlas migrate diff --dir="file://migrations" --to="file://schema.hcl" \
- --dev-url="mysql://root:pass@:3306/" create_all
+atlas migrate diff create_all \
+  --dir "file://migrations" \
+  --to "file://schema.hcl" \
+  --dev-url "mysql://root:pass@:3306/"
 ```
 
 Running `cat migrations/*.sql` will print the followings:
@@ -344,8 +346,10 @@ CREATE TABLE `market`.`users` (`name` int NOT NULL) CHARSET utf8mb4 COLLATE utf8
 <TabItem value="postgres">
 
 ```shell
-atlas migrate diff --dir="file://migrations" --to="file://schema.hcl" \
- --dev-url="postgres://root:pass@:5434/database?sslmode=disable" create_all
+atlas migrate diff create_all \
+  --dir "file://migrations" \
+  --to "file://schema.hcl" \
+  --dev-url "postgres://root:pass@:5434/database?sslmode=disable"
 ```
 
 Running `cat migrations/*.sql` will print the followings:
diff --git a/doc/md/versioned/lint.md b/doc/md/versioned/lint.md
deleted file mode 100644
index f8982e74..00000000
--- a/doc/md/versioned/lint.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-id: lint
-slug: /versioned/lint
-title: Verifying migration safety
----
-With the `atlas migrate lint` command, users can analyze the migration directory
-to detect potentially dangerous changes to the database schema. This command may be
-incorporated in _continuous integration_ pipelines to enable teams to enforce desired
-policies with regard to schema changes. 
-
-[Learn more about Atlas's analyzers](/lint/analyzers)
-
-### Flags
-When using `migrate lint` to analyze migrations, users must supply multiple parameters:
-* `--dev-url` a [URL](/concepts/url) to a [Dev-database](/concepts/dev-database) that will be used
- to simulate the changes and verify their correctness.
-* `--dir` the URL of the migration directory, by default it is `file://migrations`, e.g a
- directory named `migrations` in the current working directory.
-
-### Changeset detection
-
-When we run the `lint` command, we need to instruct Atlas on how to decide what 
-set of migration files to analyze. Currently, two modes are supported.
-
-* `--git-base <branchName>`: which selects the diff between the provided branch and 
- the current one as the changeset.
-* `--latest <n>` which selects the latest n migration files as the changeset.
-
-### Output
-
-Users may supply a [Go template](https://pkg.go.dev/text/template) string as the `--log` parameter to
-format the output of the `lint` command.
-
-### Examples
-
-Analyze all changes relative to the `master` Git branch:
-```shell
-atlas migrate lint \
-  --dir file://my/project/migrations \
-  --dev-url mysql://root:pass@localhost:3306/dev \
-  --git-base master
-```
-Analyze the latest 2 migration files:
-```shell
-atlas migrate lint \
-  --dir file://my/project/migrations \
-  --dev-url mysql://root:pass@localhost:3306/dev \
-  --latest 2
-```
-Format output as JSON:
-```shell
-atlas migrate lint \
-  --dir file://my/project/migrations \
-  --dev-url mysql://root:pass@localhost:3306/dev \
-  --git-base master \
-  --log '{{ json .Files }}'
-```
-
-### Reference
-
-[CLI Command Reference](/cli-reference#atlas-migrate-lint)
\ No newline at end of file
diff --git a/doc/md/versioned/lint.mdx b/doc/md/versioned/lint.mdx
new file mode 100644
index 00000000..c10ba378
--- /dev/null
+++ b/doc/md/versioned/lint.mdx
@@ -0,0 +1,142 @@
+---
+id: lint
+slug: /versioned/lint
+title: Verifying migration safety
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+With the `atlas migrate lint` command, users can analyze the migration directory
+to detect potentially dangerous changes to the database schema. This command may be
+incorporated in _continuous integration_ pipelines to enable teams to enforce desired
+policies with regard to schema changes. 
+
+[Learn more about Atlas's analyzers](/lint/analyzers)
+
+### Flags
+
+When using `migrate lint` to analyze migrations, users must supply multiple parameters:
+* `--dev-url` a [URL](/concepts/url) to a [Dev-database](/concepts/dev-database) that will be used
+ to simulate the changes and verify their correctness.
+* `--dir` the URL of the migration directory, by default it is `file://migrations`, e.g a
+ directory named `migrations` in the current working directory.
+
+### Changeset detection
+
+When we run the `lint` command, we need to instruct Atlas on how to decide what 
+set of migration files to analyze. Currently, two modes are supported.
+
+* `--git-base <branchName>`: which selects the diff between the provided branch and 
+ the current one as the changeset.
+* `--latest <n>` which selects the latest n migration files as the changeset.
+
+### `nolint` directive
+
+Annotating a statement with the `--atlas:nolint` directive allows excluding it from the analysis reporting. For example:
+
+<Tabs
+defaultValue="all"
+values={[
+{label: 'All', value: 'all'},
+{label: 'Class of changes', value: 'by_name'},
+{label: 'Specific checks', value: 'by_check'},
+]}>
+<TabItem value="all">
+
+Using `--atlas:nolint` excludes the annotated statement from all linters.
+
+```sql
+//highlight-next-line
+-- atlas:nolint
+ALTER TABLE `t1` DROP COLUMN `c1`, ADD COLUMN `d1` varchar(255) NOT NULL;
+
+//highlight-next-line
+--atlas:nolint
+ALTER TABLE `t2` DROP COLUMN `c2`, ADD COLUMN `d2` varchar(255) NOT NULL;
+
+//highlight-next-line
+/*atlas:nolint*/
+ALTER TABLE `t3` DROP COLUMN `c3`, ADD COLUMN `d3` varchar(255) NOT NULL;
+
+//highlight-next-line
+#atlas:nolint
+ALTER TABLE `t4` DROP COLUMN `c4`, ADD COLUMN `d4` varchar(255) NOT NULL;
+```
+
+</TabItem>
+<TabItem value="by_name">
+
+Using `--atlas:nolint [names...]` excludes reporting specific analyzers for the annotated statements.
+
+```sql
+//highlight-next-line
+-- Ignore reporting destructive changes.
+//highlight-next-line
+-- atlas:nolint destructive
+ALTER TABLE `t1` DROP COLUMN `c1`, ADD COLUMN `d1` varchar(255) NOT NULL;
+
+//highlight-next-line
+-- Ignore reporting destructive and data-dependent changes.
+//highlight-next-line
+--atlas:nolint destructive data_depend
+ALTER TABLE `t2` DROP COLUMN `c2`, ADD COLUMN `d2` varchar(255) NOT NULL;
+
+//highlight-next-line
+/*atlas:nolint data_depend*/
+ALTER TABLE `t3` DROP COLUMN `c3`, ADD COLUMN `d3` varchar(255) NOT NULL;
+
+//highlight-next-line
+#atlas:nolint destructive data_depend
+ALTER TABLE `t4` DROP COLUMN `c4`, ADD COLUMN `d4` varchar(255) NOT NULL;
+```
+
+</TabItem>
+
+<TabItem value="by_check">
+
+Using `--atlas:nolint [checks...]` excludes reporting specific [checks](../lint/analyzers.md#checks) for the annotated
+statement.
+
+```sql
+//highlight-next-line
+-- atlas:nolint DS103 MY101
+ALTER TABLE `t1` DROP COLUMN `c1`, ADD COLUMN `d1` varchar(255) NOT NULL;
+```
+
+</TabItem>
+</Tabs>
+
+### Output
+
+Users may supply a [Go template](https://pkg.go.dev/text/template) string as the `--log` parameter to
+format the output of the `lint` command.
+
+### Examples
+
+Analyze all changes relative to the `master` Git branch:
+```shell
+atlas migrate lint \
+  --dir "file://my/project/migrations" \
+  --dev-url "mysql://root:pass@localhost:3306/dev" \
+  --git-base "master"
+```
+Analyze the latest 2 migration files:
+```shell
+atlas migrate lint \
+  --dir "file://my/project/migrations" \
+  --dev-url "mysql://root:pass@localhost:3306/dev" \
+  --latest 2
+```
+Format output as JSON:
+```shell
+atlas migrate lint \
+  --dir "file://my/project/migrations" \
+  --dev-url "mysql://root:pass@localhost:3306/dev" \
+  --git-base "master" \
+  --log "{{ json .Files }}"
+```
+
+### Reference
+
+[CLI Command Reference](/cli-reference#atlas-migrate-lint)
\ No newline at end of file
diff --git a/sql/mysql/mysqlcheck/mysqlcheck.go b/sql/mysql/mysqlcheck/mysqlcheck.go
index 15cd58c4..42bbc934 100644
--- a/sql/mysql/mysqlcheck/mysqlcheck.go
+++ b/sql/mysql/mysqlcheck/mysqlcheck.go
@@ -129,7 +129,7 @@ func addNotNull(p *datadepend.ColumnPass) (diags []sqlcheck.Diagnostic, err erro
 }
 
 func init() {
-	sqlcheck.Register(mysql.DriverName, func(r *schemahcl.Resource) (sqlcheck.Analyzer, error) {
+	sqlcheck.Register(mysql.DriverName, func(r *schemahcl.Resource) ([]sqlcheck.Analyzer, error) {
 		ds, err := destructive.New(r)
 		if err != nil {
 			return nil, err
@@ -140,6 +140,6 @@ func init() {
 		if err != nil {
 			return nil, err
 		}
-		return sqlcheck.Analyzers{ds, dd}, nil
+		return []sqlcheck.Analyzer{ds, dd}, nil
 	})
 }
diff --git a/sql/postgres/postgrescheck/postgrescheck.go b/sql/postgres/postgrescheck/postgrescheck.go
index e9dcf48f..268901c5 100644
--- a/sql/postgres/postgrescheck/postgrescheck.go
+++ b/sql/postgres/postgrescheck/postgrescheck.go
@@ -31,7 +31,7 @@ func addNotNull(p *datadepend.ColumnPass) (diags []sqlcheck.Diagnostic, err erro
 }
 
 func init() {
-	sqlcheck.Register(postgres.DriverName, func(r *schemahcl.Resource) (sqlcheck.Analyzer, error) {
+	sqlcheck.Register(postgres.DriverName, func(r *schemahcl.Resource) ([]sqlcheck.Analyzer, error) {
 		ds, err := destructive.New(r)
 		if err != nil {
 			return nil, err
@@ -39,6 +39,6 @@ func init() {
 		dd, err := datadepend.New(r, datadepend.Handler{
 			AddNotNull: addNotNull,
 		})
-		return sqlcheck.Analyzers{ds, dd}, nil
+		return []sqlcheck.Analyzer{ds, dd}, nil
 	})
 }
diff --git a/sql/sqlcheck/datadepend/datadepend.go b/sql/sqlcheck/datadepend/datadepend.go
index decc9028..072742ff 100644
--- a/sql/sqlcheck/datadepend/datadepend.go
+++ b/sql/sqlcheck/datadepend/datadepend.go
@@ -45,7 +45,7 @@ type (
 // New creates a new data-dependent analyzer with the given options.
 func New(r *schemahcl.Resource, h Handler) (*Analyzer, error) {
 	az := &Analyzer{Handler: h}
-	if r, ok := r.Resource("data_depend"); ok {
+	if r, ok := r.Resource(az.Name()); ok {
 		if err := r.As(&az.Options); err != nil {
 			return nil, fmt.Errorf("sql/sqlcheck: parsing datadepend check options: %w", err)
 		}
@@ -53,6 +53,11 @@ func New(r *schemahcl.Resource, h Handler) (*Analyzer, error) {
 	return az, nil
 }
 
+// Name of the analyzer. Implements the sqlcheck.NamedAnalyzer interface.
+func (*Analyzer) Name() string {
+	return "data_depend"
+}
+
 // Analyze runs data-depend analysis on MySQL changes.
 func (a *Analyzer) Analyze(ctx context.Context, p *sqlcheck.Pass) error {
 	return a.Report(p, a.Diagnostics(ctx, p))
@@ -130,21 +135,12 @@ func (a *Analyzer) Diagnostics(_ context.Context, p *sqlcheck.Pass) (diags []sql
 // between dialects.
 func (a *Analyzer) Report(p *sqlcheck.Pass, diags []sqlcheck.Diagnostic) error {
 	if len(diags) > 0 {
-		text := reportText(len(diags))
-		p.Reporter.WriteReport(sqlcheck.Report{
-			Text:        text,
-			Diagnostics: diags,
-		})
+		p.Reporter.WriteReport(sqlcheck.Report{Text: reportText, Diagnostics: diags})
 		if sqlx.V(a.Error) {
-			return errors.New(text)
+			return errors.New(reportText)
 		}
 	}
 	return nil
 }
 
-func reportText(n int) string {
-	if n > 1 {
-		return fmt.Sprintf("%d data dependent changes detected", n)
-	}
-	return "data dependent change detected"
-}
+const reportText = "data dependent changes detected"
diff --git a/sql/sqlcheck/destructive/destructive.go b/sql/sqlcheck/destructive/destructive.go
index 75c1265c..01d2ac86 100644
--- a/sql/sqlcheck/destructive/destructive.go
+++ b/sql/sqlcheck/destructive/destructive.go
@@ -25,7 +25,7 @@ type Analyzer struct {
 func New(r *schemahcl.Resource) (*Analyzer, error) {
 	az := &Analyzer{}
 	az.Error = sqlx.P(true)
-	if r, ok := r.Resource("destructive"); ok {
+	if r, ok := r.Resource(az.Name()); ok {
 		if err := r.As(&az.Options); err != nil {
 			return nil, fmt.Errorf("sql/sqlcheck: parsing destructive check options: %w", err)
 		}
@@ -40,6 +40,11 @@ var (
 	codeDropC = sqlcheck.Code("DS103")
 )
 
+// Name of the analyzer. Implements the sqlcheck.NamedAnalyzer interface.
+func (*Analyzer) Name() string {
+	return "destructive"
+}
+
 // Analyze implements sqlcheck.Analyzer.
 func (a *Analyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
 	var diags []sqlcheck.Diagnostic
@@ -89,18 +94,12 @@ func (a *Analyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
 		}
 	}
 	if len(diags) > 0 {
-		text := reportText(len(diags))
-		p.Reporter.WriteReport(sqlcheck.Report{Text: text, Diagnostics: diags})
+		p.Reporter.WriteReport(sqlcheck.Report{Text: reportText, Diagnostics: diags})
 		if sqlx.V(a.Error) {
-			return errors.New(text)
+			return errors.New(reportText)
 		}
 	}
 	return nil
 }
 
-func reportText(n int) string {
-	if n > 1 {
-		return fmt.Sprintf("%d destructive changes detected", n)
-	}
-	return "destructive change detected"
-}
+const reportText = "destructive changes detected"
diff --git a/sql/sqlcheck/sqlcheck.go b/sql/sqlcheck/sqlcheck.go
index e71b09d5..45a7b141 100644
--- a/sql/sqlcheck/sqlcheck.go
+++ b/sql/sqlcheck/sqlcheck.go
@@ -27,6 +27,14 @@ type (
 		Analyze(context.Context, *Pass) error
 	}
 
+	// A NamedAnalyzer describes an Analyzer that has a name.
+	NamedAnalyzer interface {
+		Analyzer
+		// Name of the analyzer. Identifies the analyzer
+		// in configuration and linting passes.
+		Name() string
+	}
+
 	// A Pass provides information to the Run function that
 	// applies a specific analyzer to an SQL file.
 	Pass struct {
@@ -253,17 +261,16 @@ var drivers sync.Map
 
 // Register allows drivers to register a constructor function for creating
 // analyzers from the given HCL resource.
-func Register(name string, f func(*schemahcl.Resource) (Analyzer, error)) {
+func Register(name string, f func(*schemahcl.Resource) ([]Analyzer, error)) {
 	drivers.Store(name, f)
 }
 
 // AnalyzerFor instantiates a new Analyzer from the given HCL resource
 // based on the registered constructor function.
-func AnalyzerFor(name string, r *schemahcl.Resource) (Analyzer, error) {
+func AnalyzerFor(name string, r *schemahcl.Resource) ([]Analyzer, error) {
 	f, ok := drivers.Load(name)
 	if ok {
-		return f.(func(*schemahcl.Resource) (Analyzer, error))(r)
+		return f.(func(*schemahcl.Resource) ([]Analyzer, error))(r)
 	}
-	// A nop analyzer.
-	return (Analyzers)(nil), nil
+	return nil, nil
 }
diff --git a/sql/sqlite/sqlitecheck/sqlitecheck.go b/sql/sqlite/sqlitecheck/sqlitecheck.go
index fd8e6fa0..d64263cb 100644
--- a/sql/sqlite/sqlitecheck/sqlitecheck.go
+++ b/sql/sqlite/sqlitecheck/sqlitecheck.go
@@ -36,7 +36,7 @@ func addNotNull(p *datadepend.ColumnPass) (diags []sqlcheck.Diagnostic, err erro
 }
 
 func init() {
-	sqlcheck.Register(sqlite.DriverName, func(r *schemahcl.Resource) (sqlcheck.Analyzer, error) {
+	sqlcheck.Register(sqlite.DriverName, func(r *schemahcl.Resource) ([]sqlcheck.Analyzer, error) {
 		ds, err := destructive.New(r)
 		if err != nil {
 			return nil, err
@@ -47,7 +47,7 @@ func init() {
 		if err != nil {
 			return nil, err
 		}
-		return sqlcheck.Analyzers{
+		return []sqlcheck.Analyzer{
 			sqlcheck.AnalyzerFunc(func(ctx context.Context, p *sqlcheck.Pass) error {
 				var changes []*sqlcheck.Change
 				// Detect sequence of changes using temporary table and transform them to one ModifyTable change.


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index d3fa94fd..f5d163d4 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -502,7 +502,7 @@ func TestMigrate_Lint(t *testing.T) {
 		"--latest", "1",
 	)
 	require.Error(t, err)
-	require.Equal(t, "2.sql: destructive change detected:\n\n\tL1: Dropping table \"t\"\n\n", s)
+	require.Equal(t, "2.sql: destructive changes detected:\n\n\tL1: Dropping table \"t\"\n\n", s)
 	s, err = runCmd(
 		Root, "migrate", "lint",
 		"--dir", "file://"+p,
diff --git a/cmd/atlas/internal/lint/run_test.go b/cmd/atlas/internal/lint/run_test.go
index f719e2e5..826bfadc 100644
--- a/cmd/atlas/internal/lint/run_test.go
+++ b/cmd/atlas/internal/lint/run_test.go
@@ -36,7 +36,9 @@ func TestRunner_Run(t *testing.T) {
 				testFile{name: "2.sql", content: "CREATE TABLE pets (id INT)\nDROP TABLE users"},
 			},
 		},
-		Analyzer: &testAnalyzer{},
+		Analyzers: []sqlcheck.Analyzer{
+			&testAnalyzer{},
+		},
 		ReportWriter: &lint.TemplateWriter{
 			T: lint.DefaultTemplate,
 			W: b,
@@ -44,7 +46,7 @@ func TestRunner_Run(t *testing.T) {
 	}
 	require.NoError(t, r.Run(ctx))
 
-	passes := r.Analyzer.(*testAnalyzer).passes
+	passes := r.Analyzers[0].(*testAnalyzer).passes
 	require.Len(t, passes, 1)
 	changes := passes[0].File.Changes
 	require.Len(t, changes, 2)
diff --git a/internal/integration/cockroach_test.go b/internal/integration/cockroach_test.go
index c6140089..46c0d1e6 100644
--- a/internal/integration/cockroach_test.go
+++ b/internal/integration/cockroach_test.go
@@ -369,17 +369,17 @@ func TestCockroach_CLI(t *testing.T) {
 			}`
 	t.Run("SchemaInspect", func(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
-			testCLISchemaInspect(t, h, t.dsn(), postgres.EvalHCL, "-s", "public")
+			testCLISchemaInspect(t, h, t.url(""), postgres.EvalHCL, "-s", "public")
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
-			testCLISchemaApply(t, h, t.dsn(), "-s", "public")
+			testCLISchemaApply(t, h, t.url(""), "-s", "public")
 		})
 	})
 	t.Run("SchemaApplyDryRun", func(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
-			testCLISchemaApplyDry(t, h, t.dsn())
+			testCLISchemaApplyDry(t, h, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyWithVars", func(t *testing.T) {
@@ -398,17 +398,17 @@ table "users" {
 }
 `
 		crdbRun(t, func(t *crdbTest) {
-			testCLISchemaApply(t, h, t.dsn(), "--var", "tenant=public", "-s", "public")
+			testCLISchemaApply(t, h, t.url(""), "--var", "tenant=public", "-s", "public")
 		})
 	})
 	t.Run("SchemaDiffRun", func(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
-			testCLISchemaDiff(t, t.dsn())
+			testCLISchemaDiff(t, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyAutoApprove", func(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
-			testCLISchemaApplyAutoApprove(t, h, t.dsn(), "-s", "public")
+			testCLISchemaApplyAutoApprove(t, h, t.url(""), "-s", "public")
 		})
 	})
 }
@@ -441,14 +441,14 @@ func TestCockroach_CLI_MultiSchema(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
 			t.dropSchemas("test2")
 			t.dropTables("users")
-			testCLIMultiSchemaInspect(t, h, t.dsn(), []string{"public", "test2"}, postgres.EvalHCL)
+			testCLIMultiSchemaInspect(t, h, t.url(""), []string{"public", "test2"}, postgres.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		crdbRun(t, func(t *crdbTest) {
 			t.dropSchemas("test2")
 			t.dropTables("users")
-			testCLIMultiSchemaApply(t, h, t.dsn(), []string{"public", "test2"}, postgres.EvalHCL)
+			testCLIMultiSchemaApply(t, h, t.url(""), []string{"public", "test2"}, postgres.EvalHCL)
 		})
 	})
 }
@@ -806,11 +806,7 @@ create table atlas_types_sanity
 	})
 }
 
-func (t *crdbTest) url() string {
-	return t.dsn()
-}
-
-func (t *crdbTest) dsn() string {
+func (t *crdbTest) url(_ string) string {
 	return fmt.Sprintf("postgres://root:pass@localhost:%d/defaultdb?sslmode=disable", t.port)
 }
 
@@ -924,24 +920,6 @@ func (t *crdbTest) posts() *schema.Table {
 	return postsT
 }
 
-func (t *crdbTest) revisions() *schema.Table {
-	versionsT := &schema.Table{
-		Name: "atlas_schema_revisions",
-		Columns: []*schema.Column{
-			{Name: "version", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "description", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "execution_state", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "executed_at", Type: &schema.ColumnType{Type: &schema.TimeType{T: "timestamp with time zone"}}},
-			{Name: "execution_time", Type: &schema.ColumnType{Type: &schema.IntegerType{T: "bigint"}}},
-			{Name: "hash", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "operator_version", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "meta", Type: &schema.ColumnType{Type: &schema.JSONType{T: "jsonb"}, Raw: "jsonb"}},
-		},
-	}
-	versionsT.PrimaryKey = &schema.Index{Parts: []*schema.IndexPart{{C: versionsT.Columns[0]}}}
-	return versionsT
-}
-
 func (t *crdbTest) realm() *schema.Realm {
 	r := &schema.Realm{
 		Schemas: []*schema.Schema{
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index 6ef3102e..419d66a1 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
 // T holds the elements common between dialect tests.
 type T interface {
 	testing.TB
-	url() string
+	url(string) string
 	driver() migrate.Driver
 	revisionsStorage() migrate.RevisionReadWriter
 	realm() *schema.Realm
@@ -57,7 +57,6 @@ type T interface {
 	loadUsers() *schema.Table
 	posts() *schema.Table
 	loadPosts() *schema.Table
-	revisions() *schema.Table
 	loadTable(string) *schema.Table
 	dropTables(...string)
 	dropSchemas(...string)
@@ -175,12 +174,113 @@ func testHCLIntegration(t T, full string, empty string) {
 	require.Empty(t, t.realm().Schemas[0].Tables)
 }
 
-func testCLISchemaInspect(t T, h string, dsn string, eval schemahcl.Evaluator, args ...string) {
-	err := initCLI()
+func testCLIMigrateApplyBC(t T, dialect string) {
+	require.NoError(t, initCLI())
+	ctx := context.Background()
+
+	t.dropSchemas("bc_test", "bc_test_2", "atlas_schema_revisions")
+	t.dropTables("bc_tbl", "atlas_schema_revisions")
+	t.migrate(&schema.AddSchema{S: schema.New("bc_test")})
+
+	// Connection to schema with flag will respect flag (also mimics "old" behavior).
+	out, err := exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--allow-dirty", // since database does contain more than one schema
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url("bc_test"),
+		"--revisions-schema", "atlas_schema_revisions",
+	).CombinedOutput()
+	require.NoError(t, err, string(out))
+	s, err := t.driver().InspectSchema(ctx, "atlas_schema_revisions", nil)
+	require.NoError(t, err)
+	_, ok := s.Table("atlas_schema_revisions")
+	require.True(t, ok)
+
+	// Connection to realm will see the existing schema and will not attempt to migrate.
+	out, err = exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url(""),
+	).CombinedOutput()
+	require.NoError(t, err, string(out))
+	require.Contains(t, string(out), "The migration directory is synced with the database")
+
+	// Connection to schema without flag will error.
+	out, err = exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url("bc_test"),
+	).CombinedOutput()
+	require.Error(t, err)
+	require.Contains(t, string(out), "We couldn't find a revision table in the connected schema but found one in")
+
+	// Providing the flag and we are good.
+	out, err = exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url("bc_test"),
+		"--revisions-schema", "atlas_schema_revisions",
+	).CombinedOutput()
+	require.NoError(t, err)
+	require.Contains(t, string(out), "The migration directory is synced with the database")
+
+	// Providing the flag to the schema instead will work as well.
+	t.migrate(
+		&schema.DropSchema{S: schema.New("bc_test")},
+		&schema.AddSchema{S: schema.New("bc_test")},
+	)
+	out, err = exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url("bc_test"),
+		"--revisions-schema", "bc_test",
+	).CombinedOutput()
+	require.NoError(t, err, string(out))
+	require.NotContains(t, string(out), "The migration directory is synced with the database")
+
+	// Consecutive attempts do not need the flag anymore.
+	out, err = exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url("bc_test"),
+	).CombinedOutput()
 	require.NoError(t, err)
+	require.Contains(t, string(out), "The migration directory is synced with the database")
+
+	// Last, if bound to schema and no "old" behavior extra schema does
+	// exist, the revision table will be saved in the connected one.
+	t.migrate(
+		&schema.DropSchema{S: schema.New("atlas_schema_revisions")},
+		&schema.DropSchema{S: schema.New("bc_test")},
+		&schema.AddSchema{S: schema.New("bc_test_2")},
+	)
+	out, err = exec.Command(
+		"go", "run", "ariga.io/atlas/cmd/atlas",
+		"migrate", "apply",
+		"--allow-dirty", // since database does contain more than one schema
+		"--dir", "file://testdata/migrations/"+dialect,
+		"--url", t.url("bc_test_2"),
+	).CombinedOutput()
+	require.NoError(t, err, string(out))
+	s, err = t.driver().InspectSchema(ctx, "atlas_schema_revisions", nil)
+	require.True(t, schema.IsNotExistError(err))
+	s, err = t.driver().InspectSchema(ctx, "bc_test_2", nil)
+	require.NoError(t, err)
+	_, ok = s.Table("atlas_schema_revisions")
+	require.True(t, ok)
+}
+
+func testCLISchemaInspect(t T, h string, dsn string, eval schemahcl.Evaluator, args ...string) {
+	require.NoError(t, initCLI())
 	t.dropTables("users")
 	var expected schema.Schema
-	err = evalBytes([]byte(h), &expected, eval)
+	err := evalBytes([]byte(h), &expected, eval)
 	require.NoError(t, err)
 	t.applyHcl(h)
 	runArgs := []string{
diff --git a/internal/integration/mysql_test.go b/internal/integration/mysql_test.go
index de99253e..4fb36cb7 100644
--- a/internal/integration/mysql_test.go
+++ b/internal/integration/mysql_test.go
@@ -581,6 +581,12 @@ func TestMySQL_Snapshot(t *testing.T) {
 	})
 }
 
+func TestMySQL_CLI_MigrateApplyBC(t *testing.T) {
+	myRun(t, func(t *myTest) {
+		testCLIMigrateApplyBC(t, "mysql")
+	})
+}
+
 func TestMySQL_CLI(t *testing.T) {
 	h := `
 			schema "test" {
@@ -600,14 +606,14 @@ func TestMySQL_CLI(t *testing.T) {
 		myRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"), mysql.EvalHCL)
+			testCLISchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"), mysql.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaApply(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"))
+			testCLISchemaApply(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"))
 		})
 	})
 	t.Run("SchemaApplyWithVars", func(t *testing.T) {
@@ -626,26 +632,26 @@ table "users" {
 }
 `
 		myRun(t, func(t *myTest) {
-			testCLISchemaApply(t, h, t.dsn("test"), "--var", "tenant=test")
+			testCLISchemaApply(t, h, t.url("test"), "--var", "tenant=test")
 		})
 	})
 	t.Run("SchemaApplyDryRun", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaApplyDry(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"))
+			testCLISchemaApplyDry(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"))
 		})
 	})
 	t.Run("SchemaDiffRun", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
-			testCLISchemaDiff(t, t.dsn("test"))
+			testCLISchemaDiff(t, t.url("test"))
 		})
 	})
 	t.Run("SchemaApplyAutoApprove", func(t *testing.T) {
 		myRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaApplyAutoApprove(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"))
+			testCLISchemaApplyAutoApprove(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"))
 		})
 	})
 }
@@ -684,7 +690,7 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLIMultiSchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.dsn(""), []string{"test", "test2"}, mysql.EvalHCL)
+			testCLIMultiSchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.url(""), []string{"test", "test2"}, mysql.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
@@ -693,7 +699,7 @@ func TestMySQL_CLI_MultiSchema(t *testing.T) {
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLIMultiSchemaApply(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.dsn(""), []string{"test", "test2"}, mysql.EvalHCL)
+			testCLIMultiSchemaApply(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.url(""), []string{"test", "test2"}, mysql.EvalHCL)
 		})
 	})
 }
@@ -1265,11 +1271,7 @@ create table atlas_types_sanity
 	})
 }
 
-func (t *myTest) url() string {
-	return t.dsn("")
-}
-
-func (t *myTest) dsn(dbname string) string {
+func (t *myTest) url(dbname string) string {
 	d := "mysql"
 	pass := ":pass"
 	if t.tidb() {
@@ -1417,34 +1419,6 @@ func (t *myTest) posts() *schema.Table {
 	return postsT
 }
 
-func (t *myTest) revisions() *schema.Table {
-	versionsT := &schema.Table{
-		Name: "atlas_schema_revisions",
-		Columns: []*schema.Column{
-			{Name: "version", Type: &schema.ColumnType{Type: &schema.StringType{T: t.valueByVersion(map[string]string{"mysql56": "varchar(191)"}, "varchar(255)")}}},
-			{Name: "description", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}},
-			{Name: "execution_state", Type: &schema.ColumnType{Type: &schema.EnumType{Values: []string{"ongoing", "ok", "error"}}}},
-			{Name: "executed_at", Type: &schema.ColumnType{Type: &schema.TimeType{T: "timestamp"}, Raw: "timestamp", Null: t.version != "mysql8"}},
-			{Name: "execution_time", Type: &schema.ColumnType{Type: &schema.IntegerType{T: t.valueByVersion(map[string]string{"mysql56": "bigint(20)"}, "bigint")}}},
-			{Name: "hash", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}},
-			{Name: "operator_version", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}},
-		},
-		Attrs: []schema.Attr{
-			&schema.Charset{V: "utf8mb4"},       // because of Ent
-			&schema.Collation{V: "utf8mb4_bin"}, // because of Ent
-		},
-	}
-	versionsT.PrimaryKey = &schema.Index{Parts: []*schema.IndexPart{{C: versionsT.Columns[0]}}}
-	var metaType schema.ColumnType
-	if t.version == "mysql56" {
-		metaType = schema.ColumnType{Type: &schema.BinaryType{T: "longblob"}}
-	} else {
-		metaType = schema.ColumnType{Type: &schema.JSONType{T: "json"}}
-	}
-	versionsT.Columns = append(versionsT.Columns, &schema.Column{Name: "meta", Type: &metaType})
-	return versionsT
-}
-
 func (t *myTest) valueByVersion(values map[string]string, defaults string) string {
 	if v, ok := values[t.version]; ok {
 		return v
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index 76e215e8..3e955586 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -648,6 +648,12 @@ func TestPostgres_Snapshot(t *testing.T) {
 	})
 }
 
+func TestPostgres_CLI_MigrateApplyBC(t *testing.T) {
+	pgRun(t, func(t *pgTest) {
+		testCLIMigrateApplyBC(t, "postgres")
+	})
+}
+
 func TestPostgres_CLI(t *testing.T) {
 	h := `
 			schema "public" {
@@ -663,17 +669,17 @@ func TestPostgres_CLI(t *testing.T) {
 			}`
 	t.Run("SchemaInspect", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			testCLISchemaInspect(t, h, t.dsn(""), postgres.EvalHCL)
+			testCLISchemaInspect(t, h, t.url(""), postgres.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			testCLISchemaApply(t, h, t.dsn(""))
+			testCLISchemaApply(t, h, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyDryRun", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			testCLISchemaApplyDry(t, h, t.dsn(""))
+			testCLISchemaApplyDry(t, h, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyWithVars", func(t *testing.T) {
@@ -692,17 +698,17 @@ table "users" {
 }
 `
 		pgRun(t, func(t *pgTest) {
-			testCLISchemaApply(t, h, t.dsn(""), "--var", "tenant=public")
+			testCLISchemaApply(t, h, t.url(""), "--var", "tenant=public")
 		})
 	})
 	t.Run("SchemaDiffRun", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			testCLISchemaDiff(t, t.dsn(""))
+			testCLISchemaDiff(t, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyAutoApprove", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
-			testCLISchemaApplyAutoApprove(t, h, t.dsn(""))
+			testCLISchemaApplyAutoApprove(t, h, t.url(""))
 		})
 	})
 }
@@ -735,14 +741,14 @@ func TestPostgres_CLI_MultiSchema(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
 			t.dropSchemas("test2")
 			t.dropTables("users")
-			testCLIMultiSchemaInspect(t, h, t.dsn(""), []string{"public", "test2"}, postgres.EvalHCL)
+			testCLIMultiSchemaInspect(t, h, t.url(""), []string{"public", "test2"}, postgres.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		pgRun(t, func(t *pgTest) {
 			t.dropSchemas("test2")
 			t.dropTables("users")
-			testCLIMultiSchemaApply(t, h, t.dsn(""), []string{"public", "test2"}, postgres.EvalHCL)
+			testCLIMultiSchemaApply(t, h, t.url(""), []string{"public", "test2"}, postgres.EvalHCL)
 		})
 	})
 }
@@ -1315,15 +1321,16 @@ create table atlas_types_sanity
 	})
 }
 
-func (t *pgTest) url() string {
-	return t.dsn("")
-}
-
-func (t *pgTest) dsn(schema string) string {
-	if schema == "" {
-		schema = "public"
+func (t *pgTest) url(schema string) string {
+	var (
+		format = "postgres://postgres:pass@localhost:%d/test?sslmode=disable"
+		args   = []any{t.port}
+	)
+	if schema != "" {
+		format += "&search_path=%s"
+		args = append(args, schema)
 	}
-	return fmt.Sprintf("postgres://postgres:pass@localhost:%d/test?sslmode=disable&search_path=%s", t.port, schema)
+	return fmt.Sprintf(format, args...)
 }
 
 func (t *pgTest) driver() migrate.Driver {
@@ -1436,24 +1443,6 @@ func (t *pgTest) posts() *schema.Table {
 	return postsT
 }
 
-func (t *pgTest) revisions() *schema.Table {
-	versionsT := &schema.Table{
-		Name: "atlas_schema_revisions",
-		Columns: []*schema.Column{
-			{Name: "version", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "description", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "execution_state", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "executed_at", Type: &schema.ColumnType{Type: &schema.TimeType{T: "timestamp with time zone"}}},
-			{Name: "execution_time", Type: &schema.ColumnType{Type: &schema.IntegerType{T: "bigint"}}},
-			{Name: "hash", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "operator_version", Type: &schema.ColumnType{Type: &schema.StringType{T: "character varying"}}},
-			{Name: "meta", Type: &schema.ColumnType{Type: &schema.JSONType{T: "jsonb"}, Raw: "jsonb"}},
-		},
-	}
-	versionsT.PrimaryKey = &schema.Index{Parts: []*schema.IndexPart{{C: versionsT.Columns[0]}}}
-	return versionsT
-}
-
 func (t *pgTest) realm() *schema.Realm {
 	r := &schema.Realm{
 		Schemas: []*schema.Schema{
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index 5be53c0f..0d4778df 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -96,7 +96,7 @@ func (t *myTest) setupScript(env *testscript.Env) error {
 	env.Setenv("version", t.version)
 	env.Setenv("charset", attrs[0].(*schema.Charset).V)
 	env.Setenv("collate", attrs[1].(*schema.Collation).V)
-	if err := replaceDBURL(env, t.dsn("")); err != nil {
+	if err := replaceDBURL(env, t.url("")); err != nil {
 		return err
 	}
 	return setupScript(t.T, env, t.db, "DROP SCHEMA IF EXISTS %s")
@@ -114,7 +114,7 @@ func replaceDBURL(env *testscript.Env, url string) error {
 
 func (t *pgTest) setupScript(env *testscript.Env) error {
 	env.Setenv("version", t.version)
-	u := strings.ReplaceAll(t.dsn(""), "/test", "/")
+	u := strings.ReplaceAll(t.url(""), "/test", "/")
 	if err := replaceDBURL(env, u); err != nil {
 		return err
 	}
@@ -369,11 +369,11 @@ func (t *liteTest) cmdExec(ts *testscript.TestScript, _ bool, args []string) {
 }
 
 func (t *myTest) cmdCLI(ts *testscript.TestScript, neg bool, args []string) {
-	cmdCLI(ts, neg, args, t.dsn(ts.Getenv("db")), ts.Getenv(atlasPathKey))
+	cmdCLI(ts, neg, args, t.url(ts.Getenv("db")), ts.Getenv(atlasPathKey))
 }
 
 func (t *pgTest) cmdCLI(ts *testscript.TestScript, neg bool, args []string) {
-	cmdCLI(ts, neg, args, t.dsn(ts.Getenv("db")), ts.Getenv(atlasPathKey))
+	cmdCLI(ts, neg, args, t.url(ts.Getenv("db")), ts.Getenv(atlasPathKey))
 }
 
 func (t *liteTest) cmdCLI(ts *testscript.TestScript, neg bool, args []string) {
diff --git a/internal/integration/sqlite_test.go b/internal/integration/sqlite_test.go
index 066a0b20..a46a8ee3 100644
--- a/internal/integration/sqlite_test.go
+++ b/internal/integration/sqlite_test.go
@@ -495,7 +495,7 @@ env "hello" {
 	url = "%s"
 	src = "./schema.hcl"
 }
-`, t.dsn())
+`, t.url(""))
 			wd, _ := os.Getwd()
 			envfile := filepath.Join(wd, "atlas.hcl")
 			err := os.WriteFile(envfile, []byte(env), 0600)
@@ -509,12 +509,12 @@ env "hello" {
 	})
 	t.Run("SchemaInspect", func(t *testing.T) {
 		liteRun(t, func(t *liteTest) {
-			testCLISchemaInspect(t, h, t.dsn(), sqlite.EvalHCL)
+			testCLISchemaInspect(t, h, t.url(""), sqlite.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		liteRun(t, func(t *liteTest) {
-			testCLISchemaApply(t, h, t.dsn())
+			testCLISchemaApply(t, h, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyWithVars", func(t *testing.T) {
@@ -533,22 +533,22 @@ table "users" {
 }
 `
 		liteRun(t, func(t *liteTest) {
-			testCLISchemaApply(t, h, t.dsn(), "--var", "tenant=main")
+			testCLISchemaApply(t, h, t.url(""), "--var", "tenant=main")
 		})
 	})
 	t.Run("SchemaApplyDryRun", func(t *testing.T) {
 		liteRun(t, func(t *liteTest) {
-			testCLISchemaApplyDry(t, h, t.dsn())
+			testCLISchemaApplyDry(t, h, t.url(""))
 		})
 	})
 	t.Run("SchemaDiffRun", func(t *testing.T) {
 		liteRun(t, func(t *liteTest) {
-			testCLISchemaDiff(t, t.dsn())
+			testCLISchemaDiff(t, t.url(""))
 		})
 	})
 	t.Run("SchemaApplyAutoApprove", func(t *testing.T) {
 		liteRun(t, func(t *liteTest) {
-			testCLISchemaApplyAutoApprove(t, h, t.dsn())
+			testCLISchemaApplyAutoApprove(t, h, t.url(""))
 		})
 	})
 }
@@ -905,24 +905,6 @@ func (t *liteTest) posts() *schema.Table {
 	return postsT
 }
 
-func (t *liteTest) revisions() *schema.Table {
-	versionsT := &schema.Table{
-		Name: "atlas_schema_revisions",
-		Columns: []*schema.Column{
-			{Name: "version", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}},
-			{Name: "description", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}},
-			{Name: "execution_state", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(7)"}}},
-			{Name: "executed_at", Type: &schema.ColumnType{Type: &schema.TimeType{T: "datetime"}}},
-			{Name: "execution_time", Type: &schema.ColumnType{Type: &schema.IntegerType{T: "int"}}},
-			{Name: "hash", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(44)"}}},
-			{Name: "operator_version", Type: &schema.ColumnType{Type: &schema.StringType{T: "varchar(255)"}}},
-			{Name: "meta", Type: &schema.ColumnType{Type: &schema.JSONType{T: "json"}, Raw: "json"}},
-		},
-	}
-	versionsT.PrimaryKey = &schema.Index{Parts: []*schema.IndexPart{{C: versionsT.Columns[0]}}}
-	return versionsT
-}
-
 func (t *liteTest) realm() *schema.Realm {
 	r := &schema.Realm{
 		Schemas: []*schema.Schema{
@@ -958,14 +940,10 @@ func (t *liteTest) dropTables(names ...string) {
 	})
 }
 
-func (t *liteTest) dsn() string {
+func (t *liteTest) url(_ string) string {
 	return fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", t.file)
 }
 
-func (t *liteTest) url() string {
-	return t.dsn()
-}
-
 func (t *liteTest) applyRealmHcl(spec string) {
 	t.applyHcl(spec)
 }
diff --git a/internal/integration/testdata/migrations/mysql/1_initial.sql b/internal/integration/testdata/migrations/mysql/1_initial.sql
new file mode 100644
index 00000000..be6fce30
--- /dev/null
+++ b/internal/integration/testdata/migrations/mysql/1_initial.sql
@@ -0,0 +1,2 @@
+CREATE SCHEMA IF NOT EXISTS `bc_test`;
+CREATE TABLE `bc_test`.`bc_tbl` (`col` INTEGER NULL);
\ No newline at end of file
diff --git a/internal/integration/testdata/migrations/mysql/atlas.sum b/internal/integration/testdata/migrations/mysql/atlas.sum
new file mode 100644
index 00000000..ee3241fb
--- /dev/null
+++ b/internal/integration/testdata/migrations/mysql/atlas.sum
@@ -0,0 +1,2 @@
+h1:FT0VjrL64KJmuOe1Dq4dpbG/50Kwn0lZqfopa6BhJM8=
+1_initial.sql h1:bWUYLjb0oiGQHf45Q08aKFKxVZ3pZBArJnSmuGBw9X4=
diff --git a/internal/integration/testdata/migrations/postgres/1_initial.sql b/internal/integration/testdata/migrations/postgres/1_initial.sql
new file mode 100644
index 00000000..bf01e5e2
--- /dev/null
+++ b/internal/integration/testdata/migrations/postgres/1_initial.sql
@@ -0,0 +1,2 @@
+CREATE SCHEMA IF NOT EXISTS "bc_test";
+CREATE TABLE "bc_test"."bc_tbl" ("col" INTEGER NULL);
\ No newline at end of file
diff --git a/internal/integration/testdata/migrations/postgres/atlas.sum b/internal/integration/testdata/migrations/postgres/atlas.sum
new file mode 100644
index 00000000..2d9199fd
--- /dev/null
+++ b/internal/integration/testdata/migrations/postgres/atlas.sum
@@ -0,0 +1,2 @@
+h1:80V3wCzovMg2ot2hR0arbEjEfMfKWDeQNrXZJbFPF10=
+1_initial.sql h1:53poyM34ShPWCVU41ldi4d9LUrzqPiQfBdEq//yH4Jo=
diff --git a/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt b/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt
index 6b2058cb..01eff4a3 100644
--- a/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt
@@ -38,7 +38,7 @@ ALTER TABLE users RENAME TO user, RENAME COLUMN id TO oid;
 ALTER TABLE user ADD COLUMN id int, DROP COLUMN oid;
 
 -- expected3.txt --
-3.sql: destructive change detected:
+3.sql: destructive changes detected:
 
 	L1: Dropping non-virtual column "oid"
 
diff --git a/internal/integration/testdata/mysql/cli-migrate-lint-ignore.txt b/internal/integration/testdata/mysql/cli-migrate-lint-ignore.txt
new file mode 100644
index 00000000..d6039a38
--- /dev/null
+++ b/internal/integration/testdata/mysql/cli-migrate-lint-ignore.txt
@@ -0,0 +1,76 @@
+only mysql8
+
+# Without ignoring.
+! atlas migrate lint --dir file://migrations1 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected1.txt
+
+# Ignore destructive.
+atlas migrate lint --dir file://migrations2 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected2.txt
+
+# Ignore data_depend.
+! atlas migrate lint --dir file://migrations3 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected3.txt
+
+# Ignore all.
+! atlas migrate lint --dir file://migrations4 --dev-url URL --latest=1 > got.txt
+cmp got.txt empty.txt
+
+# Ignore all.
+! atlas migrate lint --dir file://migrations5 --dev-url URL --latest=1 > got.txt
+cmp got.txt empty.txt
+
+-- migrations1/1.sql --
+CREATE TABLE users (id int, name text);
+
+-- migrations1/2.sql --
+ALTER TABLE users ADD COLUMN age int NOT NULL, DROP COLUMN name;
+
+-- expected1.txt --
+2.sql: destructive changes detected:
+
+	L1: Dropping non-virtual column "name"
+
+2.sql: data dependent changes detected:
+
+	L1: Adding a non-nullable "int" column "age" on table "users" without a default value implicitly sets existing rows with 0
+
+-- migrations2/1.sql --
+CREATE TABLE users (id int, name text);
+
+-- migrations2/2.sql --
+-- atlas:nolint destructive
+ALTER TABLE users ADD COLUMN age int NOT NULL, DROP COLUMN name;
+
+-- expected2.txt --
+2.sql: data dependent changes detected:
+
+	L2: Adding a non-nullable "int" column "age" on table "users" without a default value implicitly sets existing rows with 0
+
+-- migrations3/1.sql --
+CREATE TABLE users (id int, name text);
+
+-- migrations3/2.sql --
+-- atlas:nolint data_depend
+ALTER TABLE users ADD COLUMN age int NOT NULL, DROP COLUMN name;
+
+-- expected3.txt --
+2.sql: destructive changes detected:
+
+	L2: Dropping non-virtual column "name"
+
+-- migrations4/1.sql --
+CREATE TABLE users (id int, name text);
+
+-- migrations4/2.sql --
+-- atlas:nolint
+ALTER TABLE users ADD COLUMN age int NOT NULL, DROP COLUMN name;
+
+-- migrations5/1.sql --
+CREATE TABLE users (id int, name text);
+
+-- migrations5/2.sql --
+-- atlas:nolint destructive data_depend anything
+ALTER TABLE users ADD COLUMN age int NOT NULL, DROP COLUMN name;
+
+-- empty.txt --
diff --git a/internal/integration/testdata/mysql/cli-migrate-lint-unique-index.txt b/internal/integration/testdata/mysql/cli-migrate-lint-unique-index.txt
index 9da72635..efc6803e 100644
--- a/internal/integration/testdata/mysql/cli-migrate-lint-unique-index.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-lint-unique-index.txt
@@ -14,7 +14,7 @@ CREATE TABLE users (id int);
 ALTER TABLE users ADD UNIQUE INDEX idx (id);
 
 -- expected1.txt --
-2.sql: data dependent change detected:
+2.sql: data dependent changes detected:
 
 	L1: Adding a unique index "idx" on table "users" might fail in case column "id" contains duplicate entries
 
diff --git a/internal/integration/testdata/sqlite/cli-migrate-lint-add-notnull.txt b/internal/integration/testdata/sqlite/cli-migrate-lint-add-notnull.txt
index 6a220c45..0f0bdcb2 100644
--- a/internal/integration/testdata/sqlite/cli-migrate-lint-add-notnull.txt
+++ b/internal/integration/testdata/sqlite/cli-migrate-lint-add-notnull.txt
@@ -13,7 +13,7 @@ ALTER TABLE users ADD COLUMN c2 int NOT NULL;
 ALTER TABLE users ADD COLUMN c3 int NOT NULL DEFAULT 1;
 
 -- expected1.txt --
-2.sql: data dependent change detected:
+2.sql: data dependent changes detected:
 
 	L1: Adding a non-nullable "int" column "c2" will fail in case table "users" is not empty
 
diff --git a/internal/integration/testdata/sqlite/cli-migrate-lint-destructive.txt b/internal/integration/testdata/sqlite/cli-migrate-lint-destructive.txt
index 7bd9fe2c..2ab0a79b 100644
--- a/internal/integration/testdata/sqlite/cli-migrate-lint-destructive.txt
+++ b/internal/integration/testdata/sqlite/cli-migrate-lint-destructive.txt
@@ -20,16 +20,16 @@ DROP TABLE users;
 DROP TABLE pets;
 
 -- expected1.txt --
-3.sql: destructive change detected:
+3.sql: destructive changes detected:
 
 	L1: Dropping table "pets"
 
 -- expected2.txt --
-2.sql: destructive change detected:
+2.sql: destructive changes detected:
 
 	L1: Dropping table "users"
 
-3.sql: destructive change detected:
+3.sql: destructive changes detected:
 
 	L1: Dropping table "pets"
 
diff --git a/internal/integration/testdata/sqlite/cli-migrate-lint-ignore.txt b/internal/integration/testdata/sqlite/cli-migrate-lint-ignore.txt
new file mode 100644
index 00000000..93b0ba95
--- /dev/null
+++ b/internal/integration/testdata/sqlite/cli-migrate-lint-ignore.txt
@@ -0,0 +1,51 @@
+# Ignore all diagnostics.
+atlas migrate lint --dir file://migrations1 --dev-url URL --latest=1 > got.txt
+cmp got.txt empty.txt
+
+# Ignore specific diagnostics.
+atlas migrate lint --dir file://migrations2 --dev-url URL --latest=1 > got.txt
+cmp got.txt empty.txt
+
+# Ignore by code.
+atlas migrate lint --dir file://migrations3 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected3.txt
+
+-- migrations1/1.sql --
+CREATE TABLE users (id int);
+CREATE TABLE pets (id int);
+
+-- migrations1/2.sql --
+
+-- atlas:nolint
+ALTER TABLE users ADD COLUMN name text NOT NULL;
+
+-- atlas:nolint
+DROP TABLE pets;
+
+-- migrations2/1.sql --
+CREATE TABLE users (id int);
+CREATE TABLE pets (id int);
+
+-- migrations2/2.sql --
+
+-- atlas:nolint data_depend
+ALTER TABLE users ADD COLUMN name text NOT NULL;
+
+-- atlas:nolint destructive
+DROP TABLE pets;
+
+-- empty.txt --
+-- migrations3/1.sql --
+CREATE TABLE users (id int);
+CREATE TABLE pets (id int);
+
+-- migrations3/2.sql --
+ALTER TABLE users ADD COLUMN name text NOT NULL;
+-- atlas:nolint DS102
+DROP TABLE pets;
+
+-- expected3.txt --
+2.sql: data dependent changes detected:
+
+	L1: Adding a non-nullable "text" column "name" will fail in case table "users" is not empty
+
diff --git a/internal/integration/testdata/sqlite/cli-migrate-lint-minimal-env.txt b/internal/integration/testdata/sqlite/cli-migrate-lint-minimal-env.txt
index 86be9afc..686c9f39 100644
--- a/internal/integration/testdata/sqlite/cli-migrate-lint-minimal-env.txt
+++ b/internal/integration/testdata/sqlite/cli-migrate-lint-minimal-env.txt
@@ -11,7 +11,7 @@ CREATE TABLE users (id int);
 -- migrations/2.sql --
 DROP TABLE users;
 -- expected1.txt --
-2.sql: destructive change detected:
+2.sql: destructive changes detected:
 
 	L1: Dropping table "users"
 
diff --git a/internal/integration/tidb_test.go b/internal/integration/tidb_test.go
index c0b79666..f6de4aef 100644
--- a/internal/integration/tidb_test.go
+++ b/internal/integration/tidb_test.go
@@ -538,7 +538,7 @@ func TestTiDB_CLI_MultiSchema(t *testing.T) {
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLIMultiSchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.dsn(""), []string{"test", "test2"}, mysql.EvalHCL)
+			testCLIMultiSchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.url(""), []string{"test", "test2"}, mysql.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
@@ -547,7 +547,7 @@ func TestTiDB_CLI_MultiSchema(t *testing.T) {
 			t.dropTables("users")
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLIMultiSchemaApply(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.dsn(""), []string{"test", "test2"}, mysql.EvalHCL)
+			testCLIMultiSchemaApply(t, fmt.Sprintf(h, charset.V, collate.V, charset.V, collate.V), t.url(""), []string{"test", "test2"}, mysql.EvalHCL)
 		})
 	})
 }
@@ -571,14 +571,14 @@ func TestTiDB_CLI(t *testing.T) {
 		tidbRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"), mysql.EvalHCL)
+			testCLISchemaInspect(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"), mysql.EvalHCL)
 		})
 	})
 	t.Run("SchemaApply", func(t *testing.T) {
 		tidbRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaApply(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"))
+			testCLISchemaApply(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"))
 		})
 	})
 	t.Run("SchemaApplyWithVars", func(t *testing.T) {
@@ -597,19 +597,19 @@ table "users" {
 }
 `
 		tidbRun(t, func(t *myTest) {
-			testCLISchemaApply(t, h, t.dsn("test"), "--var", "tenant=test")
+			testCLISchemaApply(t, h, t.url("test"), "--var", "tenant=test")
 		})
 	})
 	t.Run("SchemaApplyDryRun", func(t *testing.T) {
 		tidbRun(t, func(t *myTest) {
 			attrs := t.defaultAttrs()
 			charset, collate := attrs[0].(*schema.Charset), attrs[1].(*schema.Collation)
-			testCLISchemaApplyDry(t, fmt.Sprintf(h, charset.V, collate.V), t.dsn("test"))
+			testCLISchemaApplyDry(t, fmt.Sprintf(h, charset.V, collate.V), t.url("test"))
 		})
 	})
 	t.Run("SchemaDiffRun", func(t *testing.T) {
 		tidbRun(t, func(t *myTest) {
-			testCLISchemaDiff(t, t.dsn("test"))
+			testCLISchemaDiff(t, t.url("test"))
 		})
 	})
 }
diff --git a/sql/mysql/mysqlcheck/mysqlcheck_test.go b/sql/mysql/mysqlcheck/mysqlcheck_test.go
index 32b681cb..d9e55e9c 100644
--- a/sql/mysql/mysqlcheck/mysqlcheck_test.go
+++ b/sql/mysql/mysqlcheck/mysqlcheck_test.go
@@ -62,9 +62,9 @@ func TestDataDepend_MySQL_ImplicitUpdate(t *testing.T) {
 			}),
 		}
 	)
-	az, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
+	azs, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
 	require.NoError(t, err)
-	require.NoError(t, az.Analyze(context.Background(), pass))
+	require.NoError(t, sqlcheck.Analyzers(azs).Analyze(context.Background(), pass))
 	require.Equal(t, report.Diagnostics[0].Text, `Adding a non-nullable "int" column "b" on table "users" without a default value implicitly sets existing rows with 0`)
 	require.Equal(t, report.Diagnostics[1].Text, `Adding a non-nullable "float" column "c" on table "users" without a default value implicitly sets existing rows with 0`)
 	require.Equal(t, report.Diagnostics[2].Text, `Adding a non-nullable "varchar" column "d" on table "users" without a default value implicitly sets existing rows with ""`)
@@ -110,9 +110,9 @@ func TestDataDepend_MySQL8_ImplicitUpdate(t *testing.T) {
 			}),
 		}
 	)
-	az, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
+	azs, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
 	require.NoError(t, err)
-	require.NoError(t, az.Analyze(context.Background(), pass))
+	require.NoError(t, sqlcheck.Analyzers(azs).Analyze(context.Background(), pass))
 	require.Equal(t,
 		report.Diagnostics[0].Text,
 		`Adding a non-nullable "timestamp" column "b" on table "users" without a default value implicitly sets existing rows with 0000-00-00 00:00:00`,
@@ -160,9 +160,9 @@ func TestDataDepend_MySQL_MightFail(t *testing.T) {
 			}),
 		}
 	)
-	az, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
+	azs, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
 	require.NoError(t, err)
-	require.NoError(t, az.Analyze(context.Background(), pass))
+	require.NoError(t, sqlcheck.Analyzers(azs).Analyze(context.Background(), pass))
 	require.Equal(t, report.Diagnostics[0].Text, `Adding a non-nullable "date" column "b" will fail in case table "users" is not empty`)
 	require.Equal(t, report.Diagnostics[1].Text, `Adding a non-nullable "datetime" column "c" will fail in case table "users" is not empty`)
 	require.Equal(t, report.Diagnostics[2].Text, `Adding a non-nullable "point" column "d" will fail in case table "users" is not empty`)
@@ -218,9 +218,9 @@ func TestDataDepend_Maria_ImplicitUpdate(t *testing.T) {
 			}),
 		}
 	)
-	az, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
+	azs, err := sqlcheck.AnalyzerFor(mysql.DriverName, nil)
 	require.NoError(t, err)
-	require.NoError(t, az.Analyze(context.Background(), pass))
+	require.NoError(t, sqlcheck.Analyzers(azs).Analyze(context.Background(), pass))
 	require.Equal(t, report.Diagnostics[0].Text, `Adding a non-nullable "text" column "b" on table "users" without a default value implicitly sets existing rows with ""`)
 	require.Equal(t, report.Diagnostics[1].Text, `Adding a non-nullable "json" column "c" on table "users" without a default value implicitly sets existing rows with ""`)
 	require.Equal(t, report.Diagnostics[2].Text, `Adding a non-nullable "date" column "d" on table "users" without a default value implicitly sets existing rows with 00:00:00`)
diff --git a/sql/postgres/postgrescheck/postgrescheck_test.go b/sql/postgres/postgrescheck/postgrescheck_test.go
index b5fac283..f8beb66d 100644
--- a/sql/postgres/postgrescheck/postgrescheck_test.go
+++ b/sql/postgres/postgrescheck/postgrescheck_test.go
@@ -49,9 +49,9 @@ func TestDataDepend_MightFail(t *testing.T) {
 			}),
 		}
 	)
-	az, err := sqlcheck.AnalyzerFor(postgres.DriverName, nil)
+	azs, err := sqlcheck.AnalyzerFor(postgres.DriverName, nil)
 	require.NoError(t, err)
-	require.NoError(t, az.Analyze(context.Background(), pass))
+	require.NoError(t, sqlcheck.Analyzers(azs).Analyze(context.Background(), pass))
 	require.Equal(t, report.Diagnostics[0].Text, `Adding a non-nullable "int" column "b" will fail in case table "users" is not empty`)
 }
 
diff --git a/sql/sqlcheck/datadepend/datadepend_test.go b/sql/sqlcheck/datadepend/datadepend_test.go
index 90c30776..1d173352 100644
--- a/sql/sqlcheck/datadepend/datadepend_test.go
+++ b/sql/sqlcheck/datadepend/datadepend_test.go
@@ -69,7 +69,7 @@ func TestAnalyzer_AddUniqueIndex(t *testing.T) {
 	require.NoError(t, err)
 	err = az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
-	require.Equal(t, "data dependent change detected", report.Text)
+	require.Equal(t, "data dependent changes detected", report.Text)
 	require.Len(t, report.Diagnostics, 1)
 	require.Equal(t, `Adding a unique index "idx_b" on table "users" might fail in case column "b" contains duplicate entries`, report.Diagnostics[0].Text)
 }
@@ -128,7 +128,7 @@ func TestAnalyzer_ModifyUniqueIndex(t *testing.T) {
 	require.NoError(t, err)
 	err = az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
-	require.Equal(t, "data dependent change detected", report.Text)
+	require.Equal(t, "data dependent changes detected", report.Text)
 	require.Len(t, report.Diagnostics, 1)
 	require.Equal(t, `Modifying an index "idx_b" on table "users" might fail in case of duplicate entries`, report.Diagnostics[0].Text)
 }
@@ -188,7 +188,7 @@ func TestAnalyzer_Options(t *testing.T) {
 	}, datadepend.Handler{})
 	require.NoError(t, err)
 	err = az.Analyze(context.Background(), pass)
-	require.EqualError(t, err, "data dependent change detected")
+	require.EqualError(t, err, "data dependent changes detected")
 	require.NotNil(t, report)
 }
 
diff --git a/sql/sqlcheck/destructive/destructive_test.go b/sql/sqlcheck/destructive/destructive_test.go
index e013668e..e8cb5ad5 100644
--- a/sql/sqlcheck/destructive/destructive_test.go
+++ b/sql/sqlcheck/destructive/destructive_test.go
@@ -71,7 +71,7 @@ func TestAnalyzer_DropTable(t *testing.T) {
 	require.NoError(t, err)
 	err = az.Analyze(context.Background(), pass)
 	require.Error(t, err)
-	require.Equal(t, "2 destructive changes detected", report.Text)
+	require.Equal(t, "destructive changes detected", report.Text)
 	require.Len(t, report.Diagnostics, 2)
 	require.Equal(t, `Dropping table "users"`, report.Diagnostics[0].Text)
 	require.Equal(t, `Dropping table "posts"`, report.Diagnostics[1].Text)
@@ -176,7 +176,7 @@ func TestAnalyzer_DropSchema(t *testing.T) {
 	require.NoError(t, err)
 	err = az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
-	require.Equal(t, "2 destructive changes detected", report.Text)
+	require.Equal(t, "destructive changes detected", report.Text)
 	require.Len(t, report.Diagnostics, 2)
 	require.Equal(t, `Dropping non-empty schema "test" with 2 tables`, report.Diagnostics[0].Text)
 	require.Equal(t, `Dropping schema "market"`, report.Diagnostics[1].Text)
@@ -244,7 +244,7 @@ func TestAnalyzer_DropColumn(t *testing.T) {
 	err = az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
 	require.Len(t, report.Diagnostics, 1)
-	require.Equal(t, "destructive change detected", report.Text)
+	require.Equal(t, "destructive changes detected", report.Text)
 	require.Equal(t, `Dropping non-virtual column "c"`, report.Diagnostics[0].Text)
 }
 
diff --git a/sql/sqlite/sqlitecheck/sqlitecheck_test.go b/sql/sqlite/sqlitecheck/sqlitecheck_test.go
index 46ffb6e0..5402589b 100644
--- a/sql/sqlite/sqlitecheck/sqlitecheck_test.go
+++ b/sql/sqlite/sqlitecheck/sqlitecheck_test.go
@@ -124,12 +124,12 @@ func TestDetectModifyTable(t *testing.T) {
 			}),
 		}
 	)
-	az, err := sqlcheck.AnalyzerFor(sqlite.DriverName, nil)
+	azs, err := sqlcheck.AnalyzerFor(sqlite.DriverName, nil)
 	require.NoError(t, err)
-	err = az.Analyze(context.Background(), pass)
-	require.EqualError(t, err, "3 destructive changes detected")
+	err = sqlcheck.Analyzers(azs).Analyze(context.Background(), pass)
+	require.EqualError(t, err, "destructive changes detected")
 
-	require.Equal(t, report.Text, "3 destructive changes detected")
+	require.Equal(t, report.Text, "destructive changes detected")
 	require.Len(t, report.Diagnostics, 3)
 	require.Equal(t, report.Diagnostics[0].Text, `Dropping table "users"`)
 	require.Equal(t, report.Diagnostics[1].Text, `Dropping non-virtual column "posted_at"`)