Repository: ariga/atlas
Pull request #891 (2022-06-26 13:19:55)

sql/sqlcheck: remove analyzers to packages and expose control options


Patch:
diff --git a/cmd/atlasci/main.go b/cmd/atlasci/main.go
index 44773c9e..7dfdf028 100644
--- a/cmd/atlasci/main.go
+++ b/cmd/atlasci/main.go
@@ -18,6 +18,8 @@ import (
 	_ "ariga.io/atlas/sql/mysql"
 	_ "ariga.io/atlas/sql/postgres"
 	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlcheck/datadepend"
+	"ariga.io/atlas/sql/sqlcheck/destructive"
 	"ariga.io/atlas/sql/sqlclient"
 	_ "ariga.io/atlas/sql/sqlite"
 
@@ -60,7 +62,8 @@ func main() {
 		},
 		Analyzer: sqlcheck.Analyzers{
 			// Add more analyzers here.
-			sqlcheck.Destructive,
+			datadepend.New(datadepend.Options{}),
+			destructive.New(destructive.Options{}),
 		},
 	}
 	check(r.Run(ctx))
diff --git a/sql/sqlcheck/analyzers.go b/sql/sqlcheck/analyzers.go
deleted file mode 100644
index 451f6659..00000000
--- a/sql/sqlcheck/analyzers.go
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright 2021-present The Atlas Authors. All rights reserved.
-// This source code is licensed under the Apache 2.0 license found
-// in the LICENSE file in the root directory of this source tree.
-
-package sqlcheck
-
-import (
-	"context"
-	"fmt"
-	"strings"
-	"sync"
-
-	"ariga.io/atlas/sql/internal/sqlx"
-	"ariga.io/atlas/sql/schema"
-)
-
-// Destructive checks destructive changes.
-var Destructive = &driverAware{
-	run: func(ctx context.Context, diags []Diagnostic, p *Pass) error {
-		for _, sc := range p.File.Changes {
-			for _, c := range sc.Changes {
-				switch c := c.(type) {
-				case *schema.DropSchema:
-					if p.File.SchemaSpan(c.S) != SpanTemporary {
-						var text string
-						switch n := len(c.S.Tables); {
-						case n == 0:
-							text = fmt.Sprintf("Dropping schema %q", c.S.Name)
-						case n == 1:
-							text = fmt.Sprintf("Dropping non-empty schema %q with 1 table", c.S.Name)
-						case n > 1:
-							text = fmt.Sprintf("Dropping non-empty schema %q with %d tables", c.S.Name, n)
-						}
-						diags = append(diags, Diagnostic{Pos: sc.Pos, Text: text})
-					}
-				case *schema.DropTable:
-					if p.File.SchemaSpan(c.T.Schema) != SpanDropped && p.File.TableSpan(c.T) != SpanTemporary {
-						diags = append(diags, Diagnostic{
-							Pos:  sc.Pos,
-							Text: fmt.Sprintf("Dropping table %q", c.T.Name),
-						})
-					}
-				case *schema.ModifyTable:
-					for i := range c.Changes {
-						d, ok := c.Changes[i].(*schema.DropColumn)
-						if !ok || p.File.ColumnSpan(c.T, d.C) == SpanTemporary {
-							continue
-						}
-						if g := (schema.GeneratedExpr{}); !sqlx.Has(d.C.Attrs, &g) || strings.ToUpper(g.Type) != "VIRTUAL" {
-							diags = append(diags, Diagnostic{
-								Pos:  sc.Pos,
-								Text: fmt.Sprintf("Dropping non-virtual column %q", d.C.Name),
-							})
-						}
-					}
-				}
-			}
-		}
-		if len(diags) > 0 {
-			p.Reporter.WriteReport(Report{
-				Text:        fmt.Sprintf("Destructive changes detected in file %s", p.File.Name()),
-				Diagnostics: diags,
-			})
-		}
-		return nil
-	},
-}
-
-// driverAware is a type of analyzer that allows registering driver-level diagnostic functions.
-type driverAware struct {
-	run     func(context.Context, []Diagnostic, *Pass) error
-	mu      sync.RWMutex
-	drivers map[string]func(context.Context, *Pass) ([]Diagnostic, error)
-}
-
-// Register registers driver-level run function to extend the analyzer.
-func (a *driverAware) Register(name string, run func(context.Context, *Pass) ([]Diagnostic, error)) {
-	a.mu.Lock()
-	if a.drivers == nil {
-		a.drivers = make(map[string]func(context.Context, *Pass) ([]Diagnostic, error))
-	}
-	a.drivers[name] = run
-	a.mu.Unlock()
-}
-
-// Analyze implements the Analyzer interface.
-func (a *driverAware) Analyze(ctx context.Context, p *Pass) error {
-	var diags []Diagnostic
-	if run, ok := a.drivers[p.Dev.Name]; ok {
-		d, err := run(ctx, p)
-		if err != nil {
-			return err
-		}
-		diags = d
-	}
-	return a.run(ctx, diags, p)
-}
diff --git a/sql/sqlcheck/datadepend/datadepend.go b/sql/sqlcheck/datadepend/datadepend.go
new file mode 100644
index 00000000..a6e47638
--- /dev/null
+++ b/sql/sqlcheck/datadepend/datadepend.go
@@ -0,0 +1,113 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package datadepend
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	"ariga.io/atlas/schema/schemaspec"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
+)
+
+type (
+	// Options defines the configuration options
+	// for the data-dependent changes checker.
+	Options struct {
+		// UniqueIndex indicates if the analyzer should check for modification or
+		// addition of unique indexes to tables that can cause migration to fail.
+		UniqueIndex *bool `spec:"drop_schema,omitempty"`
+
+		// Allow drivers to extend the configuration.
+		schemaspec.DefaultExtension
+	}
+
+	// Analyzer checks data-dependent changes.
+	Analyzer struct {
+		Options
+	}
+)
+
+// New creates a new destructive analyzer with the given options.
+func New(opts Options) *Analyzer {
+	unique := true
+	if opts.UniqueIndex != nil {
+		unique = *opts.UniqueIndex
+	}
+	return &Analyzer{Options: Options{UniqueIndex: &unique}}
+}
+
+// Analyze implements sqlcheck.Analyzer.
+func (a *Analyzer) Analyze(ctx context.Context, p *sqlcheck.Pass) error {
+	f, ok := drivers.Load(p)
+	if ok {
+		return f.(func(context.Context, *Analyzer, *sqlcheck.Pass) error)(ctx, a, p)
+	}
+	// Fallback to the default implementation.
+	if diags := a.Diagnostics(ctx, p); len(diags) > 0 {
+		p.Reporter.WriteReport(sqlcheck.Report{
+			Text:        fmt.Sprintf("Data dependent changes detected in file %s", p.File.Name()),
+			Diagnostics: diags,
+		})
+	}
+	return nil
+}
+
+// Diagnostics runs the common analysis on the file and returns its diagnostics.
+func (a *Analyzer) Diagnostics(_ context.Context, p *sqlcheck.Pass) (diags []sqlcheck.Diagnostic) {
+	// Skip running the analysis in case the check is disabled.
+	if !*a.UniqueIndex {
+		return
+	}
+	for _, sc := range p.File.Changes {
+		for _, c := range sc.Changes {
+			m, ok := c.(*schema.ModifyTable)
+			if !ok {
+				continue
+			}
+			for _, c := range m.Changes {
+				switch c := c.(type) {
+				case *schema.AddIndex:
+					column := func() *schema.Column {
+						for i := range c.I.Parts {
+							// We consider a column a non-new column if
+							// it was not added in this migration file.
+							if column := c.I.Parts[i].C; column != nil && p.File.ColumnSpan(m.T, column)&sqlcheck.SpanAdded == 0 {
+								return column
+							}
+						}
+						return nil
+					}()
+					// A unique index was added on an existing column.
+					if c.I.Unique && column != nil {
+						diags = append(diags, sqlcheck.Diagnostic{
+							Pos:  sc.Pos,
+							Text: fmt.Sprintf("Adding a unique index %q on table %q might fail in case column %q contains duplicate entries", c.I.Name, m.T.Name, column.Name),
+						})
+					}
+				case *schema.ModifyIndex:
+					if c.Change.Is(schema.ChangeUnique) && c.To.Unique && p.File.IndexSpan(m.T, c.To)&sqlcheck.SpanAdded == 0 {
+						diags = append(diags, sqlcheck.Diagnostic{
+							Pos:  sc.Pos,
+							Text: fmt.Sprintf("Modifying an index %q on table %q might fail in case of duplicate entries", c.To.Name, m.T.Name),
+						})
+					}
+				}
+			}
+		}
+	}
+	return
+}
+
+// drivers specific analyzers.
+var drivers sync.Map
+
+// Register allows drivers to override the
+// default analysis with custom behavior.
+func Register(name string, f func(context.Context, *Analyzer, *sqlcheck.Pass) error) {
+	drivers.Store(name, f)
+}
diff --git a/sql/sqlcheck/destructive/destructive.go b/sql/sqlcheck/destructive/destructive.go
new file mode 100644
index 00000000..44deec23
--- /dev/null
+++ b/sql/sqlcheck/destructive/destructive.go
@@ -0,0 +1,109 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package destructive
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"ariga.io/atlas/schema/schemaspec"
+	"ariga.io/atlas/sql/internal/sqlx"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
+)
+
+type (
+	// Options defines the configuration options
+	// for the destructive changes checker.
+	Options struct {
+		// DropSchema indicates if the analyzer should check for schema dropping.
+		DropSchema *bool `spec:"drop_schema,omitempty"`
+
+		// DropTable indicates if the analyzer should check for table dropping.
+		DropTable *bool `spec:"drop_schema,omitempty"`
+
+		// DropColumn indicates if the analyzer should check for column dropping.
+		DropColumn *bool `spec:"drop_column,omitempty"`
+
+		// Allow drivers to extend the configuration.
+		schemaspec.DefaultExtension
+	}
+
+	// Analyzer checks for destructive changes.
+	Analyzer struct {
+		Options
+	}
+)
+
+// New creates a new destructive changes Analyzer with the given options.
+func New(opts Options) *Analyzer {
+	d := &Analyzer{}
+	d.DropSchema = abool(opts.DropSchema, true)
+	d.DropTable = abool(opts.DropTable, true)
+	d.DropColumn = abool(opts.DropColumn, true)
+	return d
+}
+
+// Analyze implements Analyzer.
+func (d *Analyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
+	var diags []sqlcheck.Diagnostic
+	for _, sc := range p.File.Changes {
+		for _, c := range sc.Changes {
+			switch c := c.(type) {
+			case *schema.DropSchema:
+				if *d.DropSchema && p.File.SchemaSpan(c.S) != sqlcheck.SpanTemporary {
+					var text string
+					switch n := len(c.S.Tables); {
+					case n == 0:
+						text = fmt.Sprintf("Dropping schema %q", c.S.Name)
+					case n == 1:
+						text = fmt.Sprintf("Dropping non-empty schema %q with 1 table", c.S.Name)
+					case n > 1:
+						text = fmt.Sprintf("Dropping non-empty schema %q with %d tables", c.S.Name, n)
+					}
+					diags = append(diags, sqlcheck.Diagnostic{Pos: sc.Pos, Text: text})
+				}
+			case *schema.DropTable:
+				if *d.DropTable && p.File.SchemaSpan(c.T.Schema) != sqlcheck.SpanDropped && p.File.TableSpan(c.T) != sqlcheck.SpanTemporary {
+					diags = append(diags, sqlcheck.Diagnostic{
+						Pos:  sc.Pos,
+						Text: fmt.Sprintf("Dropping table %q", c.T.Name),
+					})
+				}
+			case *schema.ModifyTable:
+				if !*d.DropColumn {
+					continue
+				}
+				for i := range c.Changes {
+					d, ok := c.Changes[i].(*schema.DropColumn)
+					if !ok || p.File.ColumnSpan(c.T, d.C) == sqlcheck.SpanTemporary {
+						continue
+					}
+					if g := (schema.GeneratedExpr{}); !sqlx.Has(d.C.Attrs, &g) || strings.ToUpper(g.Type) != "VIRTUAL" {
+						diags = append(diags, sqlcheck.Diagnostic{
+							Pos:  sc.Pos,
+							Text: fmt.Sprintf("Dropping non-virtual column %q", d.C.Name),
+						})
+					}
+				}
+			}
+		}
+	}
+	if len(diags) > 0 {
+		p.Reporter.WriteReport(sqlcheck.Report{
+			Text:        fmt.Sprintf("Destructive changes detected in file %s", p.File.Name()),
+			Diagnostics: diags,
+		})
+	}
+	return nil
+}
+
+func abool(p *bool, v bool) *bool {
+	if p != nil {
+		return p
+	}
+	return &v
+}


Test patch:
diff --git a/sql/sqlcheck/datadepend/datadepend_test.go b/sql/sqlcheck/datadepend/datadepend_test.go
new file mode 100644
index 00000000..d2b99a44
--- /dev/null
+++ b/sql/sqlcheck/datadepend/datadepend_test.go
@@ -0,0 +1,180 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package datadepend_test
+
+import (
+	"context"
+	"testing"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlcheck/datadepend"
+	"ariga.io/atlas/sql/sqlclient"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestAnalyzer_AddUniqueIndex(t *testing.T) {
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{},
+			File: &sqlcheck.File{
+				File: testFile{name: "1.sql"},
+				Changes: []*sqlcheck.Change{
+					{
+						Stmt: "ALTER TABLE users",
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewColumn("a"),
+										schema.NewColumn("b"),
+									),
+								Changes: []schema.Change{
+									// Ignore new created columns.
+									&schema.AddColumn{
+										C: schema.NewColumn("a"),
+									},
+									&schema.AddIndex{
+										I: schema.NewUniqueIndex("idx_a").
+											AddColumns(schema.NewColumn("a")),
+									},
+									// Report on existing columns.
+									&schema.AddIndex{
+										I: schema.NewUniqueIndex("idx_b").
+											AddColumns(schema.NewColumn("b")),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				report = &r
+			}),
+		}
+	)
+	az := datadepend.New(datadepend.Options{})
+	err := az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+	require.Equal(t, "Data dependent changes detected in file 1.sql", 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)
+}
+
+func TestAnalyzer_ModifyUniqueIndex(t *testing.T) {
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{},
+			File: &sqlcheck.File{
+				File: testFile{name: "1.sql"},
+				Changes: []*sqlcheck.Change{
+					{
+						Stmt: "ALTER TABLE users",
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewColumn("a"),
+										schema.NewColumn("b"),
+									),
+								Changes: []schema.Change{
+									// Ignore new created columns.
+									&schema.AddColumn{
+										C: schema.NewColumn("a"),
+									},
+									&schema.ModifyIndex{
+										From: schema.NewIndex("idx_a").
+											AddColumns(schema.NewColumn("a")),
+										To: schema.NewUniqueIndex("idx_a").
+											AddColumns(schema.NewColumn("a")),
+									},
+									// Report on existing columns.
+									&schema.ModifyIndex{
+										From: schema.NewIndex("idx_b").
+											AddColumns(schema.NewColumn("b")),
+										To: schema.NewUniqueIndex("idx_b").
+											AddColumns(schema.NewColumn("b")),
+										Change: schema.ChangeUnique,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				report = &r
+			}),
+		}
+	)
+	az := datadepend.New(datadepend.Options{})
+	err := az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+	require.Equal(t, "Data dependent changes detected in file 1.sql", 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)
+}
+
+func TestAnalyzer_Options(t *testing.T) {
+	var (
+		off  bool
+		pass = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{},
+			File: &sqlcheck.File{
+				File: testFile{name: "1.sql"},
+				Changes: []*sqlcheck.Change{
+					{
+						Stmt: "ALTER TABLE users",
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewColumn("a"),
+										schema.NewColumn("b"),
+									),
+								Changes: []schema.Change{
+									&schema.AddIndex{
+										I: schema.NewIndex("idx_a").
+											AddColumns(schema.NewColumn("a")),
+									},
+									&schema.ModifyIndex{
+										From: schema.NewIndex("idx_b").
+											AddColumns(schema.NewColumn("b")),
+										To: schema.NewUniqueIndex("idx_b").
+											AddColumns(schema.NewColumn("b")),
+										Change: schema.ChangeUnique,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				t.Fatal("unexpected report", r)
+			}),
+		}
+	)
+	az := datadepend.New(datadepend.Options{UniqueIndex: &off})
+	err := az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+}
+
+type testFile struct {
+	name string
+	migrate.File
+}
+
+func (t testFile) Name() string {
+	return t.name
+}
diff --git a/sql/sqlcheck/analyzers_test.go b/sql/sqlcheck/destructive/destructive_test.go
similarity index 71%
rename from sql/sqlcheck/analyzers_test.go
rename to sql/sqlcheck/destructive/destructive_test.go
index 8b9d6968..f6522b47 100644
--- a/sql/sqlcheck/analyzers_test.go
+++ b/sql/sqlcheck/destructive/destructive_test.go
@@ -2,7 +2,7 @@
 // This source code is licensed under the Apache 2.0 license found
 // in the LICENSE file in the root directory of this source tree.
 
-package sqlcheck_test
+package destructive_test
 
 import (
 	"context"
@@ -11,12 +11,13 @@ import (
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlcheck/destructive"
 	"ariga.io/atlas/sql/sqlclient"
 
 	"github.com/stretchr/testify/require"
 )
 
-func TestDestructive_DropTable(t *testing.T) {
+func TestAnalyzer_DropTable(t *testing.T) {
 	var (
 		report *sqlcheck.Report
 		pass   = &sqlcheck.Pass{
@@ -58,7 +59,8 @@ func TestDestructive_DropTable(t *testing.T) {
 			}),
 		}
 	)
-	err := sqlcheck.Destructive.Analyze(context.Background(), pass)
+	az := destructive.New(destructive.Options{})
+	err := az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
 	require.Equal(t, `Destructive changes detected in file 1.sql`, report.Text)
 	require.Len(t, report.Diagnostics, 2)
@@ -66,7 +68,7 @@ func TestDestructive_DropTable(t *testing.T) {
 	require.Equal(t, `Dropping table "posts"`, report.Diagnostics[1].Text)
 }
 
-func TestDestructive_SkipTemporaryTable(t *testing.T) {
+func TestAnalyzer_SkipTemporaryTable(t *testing.T) {
 	var (
 		report *sqlcheck.Report
 		pass   = &sqlcheck.Pass{
@@ -94,12 +96,13 @@ func TestDestructive_SkipTemporaryTable(t *testing.T) {
 			}),
 		}
 	)
-	err := sqlcheck.Destructive.Analyze(context.Background(), pass)
+	az := destructive.New(destructive.Options{})
+	err := az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
 	require.Nil(t, report, "no report")
 }
 
-func TestDestructive_DropSchema(t *testing.T) {
+func TestAnalyzer_DropSchema(t *testing.T) {
 	var (
 		report *sqlcheck.Report
 		pass   = &sqlcheck.Pass{
@@ -142,7 +145,8 @@ func TestDestructive_DropSchema(t *testing.T) {
 			}),
 		}
 	)
-	err := sqlcheck.Destructive.Analyze(context.Background(), pass)
+	az := destructive.New(destructive.Options{})
+	err := az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
 	require.Equal(t, `Destructive changes detected in file 1.sql`, report.Text)
 	require.Len(t, report.Diagnostics, 2)
@@ -150,20 +154,7 @@ func TestDestructive_DropSchema(t *testing.T) {
 	require.Equal(t, `Dropping schema "market"`, report.Diagnostics[1].Text)
 }
 
-func TestDestructive_Register(t *testing.T) {
-	sqlcheck.Destructive.Register("mysql", func(ctx context.Context, p *sqlcheck.Pass) (diags []sqlcheck.Diagnostic, _ error) {
-		for _, cs := range p.File.Changes {
-			for _, c := range cs.Changes {
-				_, ok := c.(*schema.ModifySchema)
-				if !ok {
-					continue
-				}
-				// A fake driver-level diagnostic.
-				diags = append(diags, sqlcheck.Diagnostic{Text: "fake diagnostic", Pos: cs.Pos})
-			}
-		}
-		return
-	})
+func TestAnalyzer_DropColumn(t *testing.T) {
 	var (
 		report sqlcheck.Report
 		pass   = &sqlcheck.Pass{
@@ -207,11 +198,66 @@ func TestDestructive_Register(t *testing.T) {
 			}),
 		}
 	)
-	err := sqlcheck.Destructive.Analyze(context.Background(), pass)
+	az := destructive.New(destructive.Options{})
+	err := az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+	require.Len(t, report.Diagnostics, 1)
+	require.Equal(t, `Dropping non-virtual column "c"`, report.Diagnostics[0].Text)
+}
+
+func TestAnalyzer_Options(t *testing.T) {
+	var (
+		off  bool
+		pass = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{Name: "mysql"},
+			File: &sqlcheck.File{
+				File: testFile{name: "1.sql"},
+				Changes: []*sqlcheck.Change{
+					{
+						Stmt: "DROP DATABASE `test`",
+						Changes: schema.Changes{
+							&schema.DropSchema{
+								S: schema.New("test"),
+							},
+						},
+					},
+					{
+						Stmt: "DROP TABLE `users`",
+						Changes: schema.Changes{
+							&schema.DropTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")),
+							},
+						},
+					},
+					{
+						Stmt: "ALTER TABLE `pets`",
+						Changes: []schema.Change{
+							&schema.ModifyTable{
+								T: schema.NewTable("pets").
+									SetSchema(schema.New("test")),
+								Changes: schema.Changes{
+									&schema.DropColumn{
+										C: schema.NewColumn("c"),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				t.Fatal("unexpected report")
+			}),
+		}
+	)
+	az := destructive.New(destructive.Options{
+		DropTable:  &off,
+		DropSchema: &off,
+		DropColumn: &off,
+	})
+	err := az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
-	require.Len(t, report.Diagnostics, 2)
-	require.Equal(t, "fake diagnostic", report.Diagnostics[0].Text)
-	require.Equal(t, `Dropping non-virtual column "c"`, report.Diagnostics[1].Text)
 }
 
 type testFile struct {