Repository: ariga/atlas
Pull request #1124 (2022-09-04 13:08:03)

sql/sqlcheck: add support for atlas:nolint directive


Patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index a8113714..675f4789 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -745,7 +745,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.
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/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/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/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"`)