Repository: ariga/atlas
Pull request #1511 (2023-03-06 21:52:39)

sql/sqlcheck: add backwards-incompatible checks
Still a draft. Don't review yet


Patch:
diff --git a/cmd/atlas/internal/sqlparse/myparse/myparse.go b/cmd/atlas/internal/sqlparse/myparse/myparse.go
index 475323f0..89daa830 100644
--- a/cmd/atlas/internal/sqlparse/myparse/myparse.go
+++ b/cmd/atlas/internal/sqlparse/myparse/myparse.go
@@ -25,8 +25,9 @@ type Parser struct{}
 
 // ColumnFilledBefore checks if the column was filled before the given position.
 func (p *Parser) ColumnFilledBefore(f migrate.File, t *schema.Table, c *schema.Column, pos int) (bool, error) {
+	pr := parser.New()
 	return parseutil.MatchStmtBefore(f, pos, func(s *migrate.Stmt) (bool, error) {
-		stmt, err := parser.New().ParseOneStmt(s.Text, "", "")
+		stmt, err := pr.ParseOneStmt(s.Text, "", "")
 		if err != nil {
 			return false, err
 		}
@@ -70,8 +71,9 @@ func (p *Parser) ColumnFilledAfter(f migrate.File, t *schema.Table, c *schema.Co
 	default:
 		stmts = stmts[i:]
 	}
+	pr := parser.New()
 	for _, s := range stmts {
-		stmt, err := parser.New().ParseOneStmt(s.Text, "", "")
+		stmt, err := pr.ParseOneStmt(s.Text, "", "")
 		if err != nil {
 			return false, err
 		}
@@ -153,6 +155,31 @@ func (p *Parser) ColumnHasReferences(stmt *migrate.Stmt, c1 *schema.Column) (boo
 	return false, nil
 }
 
+// CreateViewAfter checks if the view was created after the position with the given name to a table.
+func (p *Parser) CreateViewAfter(f migrate.File, old, new string, pos int) (bool, error) {
+	pr := parser.New()
+	return parseutil.MatchStmtAfter(f, pos, func(s *migrate.Stmt) (bool, error) {
+		stmt, err := pr.ParseOneStmt(s.Text, "", "")
+		if err != nil {
+			return false, err
+		}
+		v, ok := stmt.(*ast.CreateViewStmt)
+		if !ok || v.ViewName.Name.O != old {
+			return false, nil
+		}
+		sc, ok := v.Select.(*ast.SelectStmt)
+		if !ok || sc.From == nil || sc.From.TableRefs == nil || sc.From.TableRefs.Left == nil || sc.From.TableRefs.Right != nil {
+			return false, nil
+		}
+		t, ok := sc.From.TableRefs.Left.(*ast.TableSource)
+		if !ok || t.Source == nil {
+			return false, nil
+		}
+		name, ok := t.Source.(*ast.TableName)
+		return ok && name.Name.O == new, nil
+	})
+}
+
 // FixChange fixes the changes according to the given statement.
 func (p *Parser) FixChange(d migrate.Driver, s string, changes schema.Changes) (schema.Changes, error) {
 	stmt, err := parser.New().ParseOneStmt(s, "", "")
diff --git a/cmd/atlas/internal/sqlparse/parseutil/parseutil.go b/cmd/atlas/internal/sqlparse/parseutil/parseutil.go
index 15c4322f..4985a1ee 100644
--- a/cmd/atlas/internal/sqlparse/parseutil/parseutil.go
+++ b/cmd/atlas/internal/sqlparse/parseutil/parseutil.go
@@ -20,16 +20,31 @@ type Rename struct {
 // RenameColumn patches DROP/ADD column commands to RENAME.
 func RenameColumn(modify *schema.ModifyTable, r *Rename) {
 	changes := schema.Changes(modify.Changes)
-	i := changes.IndexDropColumn(r.From)
-	j := changes.IndexAddColumn(r.To)
-	if i != -1 && j != -1 {
+	switch i, j := changes.IndexDropColumn(r.From), changes.IndexAddColumn(r.To); {
+	case j == -1:
+	// Rename column.
+	case i != -1:
 		changes[max(i, j)] = &schema.RenameColumn{
 			From: changes[i].(*schema.DropColumn).C,
 			To:   changes[j].(*schema.AddColumn).C,
 		}
 		changes.RemoveIndex(min(i, j))
-		modify.Changes = changes
+	// Rename column and add the previous name back.
+	default:
+		if i = changes.IndexModifyColumn(r.From); i == -1 {
+			// ADD COLUMN must come after the RENAME.
+			return
+		}
+		modify := changes[i].(*schema.ModifyColumn)
+		changes[min(i, j)] = &schema.RenameColumn{
+			From: modify.From,
+			To:   changes[j].(*schema.AddColumn).C,
+		}
+		changes[max(i, j)] = &schema.AddColumn{
+			C: modify.To,
+		}
 	}
+	modify.Changes = changes
 }
 
 // RenameIndex patches DROP/ADD index commands to RENAME.
@@ -85,6 +100,31 @@ func MatchStmtBefore(f migrate.File, pos int, p func(*migrate.Stmt) (bool, error
 	return false, nil
 }
 
+// MatchStmtAfter reports if the file contains any statement that matches the predicate after the given position.
+func MatchStmtAfter(f migrate.File, pos int, p func(*migrate.Stmt) (bool, error)) (bool, error) {
+	stmts, err := f.StmtDecls()
+	if err != nil {
+		return false, err
+	}
+	i := slices.IndexFunc(stmts, func(s *migrate.Stmt) bool {
+		return s.Pos > pos
+	})
+	if i == -1 {
+		return false, nil
+	}
+	stmts = stmts[i:]
+	for _, s := range stmts {
+		m, err := p(s)
+		if err != nil {
+			return false, err
+		}
+		if m {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
 func max(i, j int) int {
 	if i > j {
 		return i
diff --git a/cmd/atlas/internal/sqlparse/pgparse/pgparse.go b/cmd/atlas/internal/sqlparse/pgparse/pgparse.go
index 7c517bb1..40cd6795 100644
--- a/cmd/atlas/internal/sqlparse/pgparse/pgparse.go
+++ b/cmd/atlas/internal/sqlparse/pgparse/pgparse.go
@@ -50,6 +50,25 @@ func (p *Parser) ColumnFilledBefore(f migrate.File, t *schema.Table, c *schema.C
 	})
 }
 
+// CreateViewAfter checks if the view was created after the position with the given name to a table.
+func (p *Parser) CreateViewAfter(f migrate.File, old, new string, pos int) (bool, error) {
+	return parseutil.MatchStmtAfter(f, pos, func(s *migrate.Stmt) (bool, error) {
+		stmt, err := parser.ParseOne(s.Text)
+		if err != nil {
+			return false, err
+		}
+		v, ok := stmt.AST.(*tree.CreateView)
+		if !ok || v.AsSource == nil || v.Name.String() != old {
+			return false, nil
+		}
+		sc, ok := v.AsSource.Select.(*tree.SelectClause)
+		if !ok || len(sc.From.Tables) != 1 {
+			return false, nil
+		}
+		return tree.AsString(sc.From.Tables[0]) == new, nil
+	})
+}
+
 // FixChange fixes the changes according to the given statement.
 func (p *Parser) FixChange(_ migrate.Driver, s string, changes schema.Changes) (schema.Changes, error) {
 	stmt, err := parser.ParseOne(s)
diff --git a/sql/mysql/diff.go b/sql/mysql/diff.go
index 2dfac8c3..cf8f5649 100644
--- a/sql/mysql/diff.go
+++ b/sql/mysql/diff.go
@@ -488,7 +488,9 @@ func (*diff) generatedChanged(from, to *schema.Column) (bool, error) {
 	if !fromHas && !toHas || fromHas && toHas && sqlx.MayWrap(fromX.Expr) == sqlx.MayWrap(toX.Expr) && storedOrVirtual(fromX.Type) == storedOrVirtual(toX.Type) {
 		return false, nil
 	}
-	return true, checkChangeGenerated(from, to)
+	// Checking validity of the change is done
+	// by the planner (checkChangeGenerated).
+	return true, nil
 }
 
 // equalIntValues report if the 2 int default values are ~equal.
diff --git a/sql/mysql/inspect.go b/sql/mysql/inspect.go
index 44c4d700..73986822 100644
--- a/sql/mysql/inspect.go
+++ b/sql/mysql/inspect.go
@@ -708,6 +708,7 @@ FROM
 	ON t1.TABLE_COLLATION = t2.COLLATION_NAME
 WHERE
 	TABLE_SCHEMA IN (%s)
+	AND TABLE_TYPE = 'BASE TABLE'
 ORDER BY
 	TABLE_SCHEMA, TABLE_NAME
 `
@@ -728,6 +729,7 @@ FROM
 WHERE
 	TABLE_SCHEMA IN (%s)
 	AND TABLE_NAME IN (%s)
+	AND TABLE_TYPE = 'BASE TABLE'
 ORDER BY
 	TABLE_SCHEMA, TABLE_NAME
 `
diff --git a/sql/mysql/mysqlcheck/mysqlcheck.go b/sql/mysql/mysqlcheck/mysqlcheck.go
index f0513484..998c330e 100644
--- a/sql/mysql/mysqlcheck/mysqlcheck.go
+++ b/sql/mysql/mysqlcheck/mysqlcheck.go
@@ -18,6 +18,7 @@ import (
 	"ariga.io/atlas/sql/sqlcheck/condrop"
 	"ariga.io/atlas/sql/sqlcheck/datadepend"
 	"ariga.io/atlas/sql/sqlcheck/destructive"
+	"ariga.io/atlas/sql/sqlcheck/incompatible"
 )
 
 var (
@@ -211,6 +212,10 @@ func init() {
 		if err != nil {
 			return nil, err
 		}
-		return []sqlcheck.Analyzer{ds, dd, cd, sqlcheck.AnalyzerFunc(inlineRefs)}, nil
+		bc, err := incompatible.New(r)
+		if err != nil {
+			return nil, err
+		}
+		return []sqlcheck.Analyzer{ds, dd, cd, bc, sqlcheck.AnalyzerFunc(inlineRefs)}, nil
 	})
 }
diff --git a/sql/postgres/postgrescheck/postgrescheck.go b/sql/postgres/postgrescheck/postgrescheck.go
index bfc8dbf1..d17f9275 100644
--- a/sql/postgres/postgrescheck/postgrescheck.go
+++ b/sql/postgres/postgrescheck/postgrescheck.go
@@ -13,6 +13,7 @@ import (
 	"ariga.io/atlas/sql/sqlcheck/condrop"
 	"ariga.io/atlas/sql/sqlcheck/datadepend"
 	"ariga.io/atlas/sql/sqlcheck/destructive"
+	"ariga.io/atlas/sql/sqlcheck/incompatible"
 )
 
 func addNotNull(p *datadepend.ColumnPass) (diags []sqlcheck.Diagnostic, err error) {
@@ -44,6 +45,10 @@ func init() {
 		dd, err := datadepend.New(r, datadepend.Handler{
 			AddNotNull: addNotNull,
 		})
-		return []sqlcheck.Analyzer{ds, dd, cd}, nil
+		bc, err := incompatible.New(r)
+		if err != nil {
+			return nil, err
+		}
+		return []sqlcheck.Analyzer{ds, dd, cd, bc}, nil
 	})
 }
diff --git a/sql/schema/migrate.go b/sql/schema/migrate.go
index 7bc19dd4..a9a1bd1a 100644
--- a/sql/schema/migrate.go
+++ b/sql/schema/migrate.go
@@ -304,8 +304,8 @@ type (
 // Changes is a list of changes allow for searching and mutating changes.
 type Changes []Change
 
-// IndexAddTable returns the index of the first AddTable in the changes with
-// the given name, or -1 if there is no such change in the Changes.
+// IndexAddTable returns the index of the first AddTable in the changes
+// with the given name, or -1 if there is no such change in the Changes.
 func (c Changes) IndexAddTable(name string) int {
 	return c.search(func(c Change) bool {
 		a, ok := c.(*AddTable)
@@ -313,8 +313,8 @@ func (c Changes) IndexAddTable(name string) int {
 	})
 }
 
-// IndexDropTable returns the index of the first DropTable in the changes with
-// the given name, or -1 if there is no such change in the Changes.
+// IndexDropTable returns the index of the first DropTable in the changes
+// with the given name, or -1 if there is no such change in the Changes.
 func (c Changes) IndexDropTable(name string) int {
 	return c.search(func(c Change) bool {
 		a, ok := c.(*DropTable)
@@ -322,8 +322,8 @@ func (c Changes) IndexDropTable(name string) int {
 	})
 }
 
-// IndexAddColumn returns the index of the first AddColumn in the changes with
-// the given name, or -1 if there is no such change in the Changes.
+// IndexAddColumn returns the index of the first AddColumn in the changes
+// with the given name, or -1 if there is no such change in the Changes.
 func (c Changes) IndexAddColumn(name string) int {
 	return c.search(func(c Change) bool {
 		a, ok := c.(*AddColumn)
@@ -331,8 +331,8 @@ func (c Changes) IndexAddColumn(name string) int {
 	})
 }
 
-// IndexDropColumn returns the index of the first DropColumn in the changes with
-// the given name, or -1 if there is no such change in the Changes.
+// IndexDropColumn returns the index of the first DropColumn in the changes
+// with the given name, or -1 if there is no such change in the Changes.
 func (c Changes) IndexDropColumn(name string) int {
 	return c.search(func(c Change) bool {
 		d, ok := c.(*DropColumn)
@@ -340,8 +340,17 @@ func (c Changes) IndexDropColumn(name string) int {
 	})
 }
 
-// IndexAddIndex returns the index of the first AddIndex in the changes with
-// the given name, or -1 if there is no such change in the Changes.
+// IndexModifyColumn returns the index of the first ModifyColumn in the changes
+// with the given name, or -1 if there is no such change in the Changes.
+func (c Changes) IndexModifyColumn(name string) int {
+	return c.search(func(c Change) bool {
+		a, ok := c.(*ModifyColumn)
+		return ok && a.From.Name == name
+	})
+}
+
+// IndexAddIndex returns the index of the first AddIndex in the changes
+// with the given name, or -1 if there is no such change in the Changes.
 func (c Changes) IndexAddIndex(name string) int {
 	return c.search(func(c Change) bool {
 		a, ok := c.(*AddIndex)
@@ -349,8 +358,8 @@ func (c Changes) IndexAddIndex(name string) int {
 	})
 }
 
-// IndexDropIndex returns the index of the first DropIndex in the changes with
-// the given name, or -1 if there is no such change in the Changes.
+// IndexDropIndex returns the index of the first DropIndex in the changes
+// with the given name, or -1 if there is no such change in the Changes.
 func (c Changes) IndexDropIndex(name string) int {
 	return c.search(func(c Change) bool {
 		a, ok := c.(*DropIndex)
diff --git a/sql/sqlcheck/incompatible/incompatible.go b/sql/sqlcheck/incompatible/incompatible.go
new file mode 100644
index 00000000..b1665aaa
--- /dev/null
+++ b/sql/sqlcheck/incompatible/incompatible.go
@@ -0,0 +1,114 @@
+// 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 incompatible
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"ariga.io/atlas/schemahcl"
+	"ariga.io/atlas/sql/internal/sqlx"
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
+)
+
+// Analyzer checks for backwards-incompatible (breaking) changes.
+type Analyzer struct {
+	sqlcheck.Options
+}
+
+// New creates a new backwards-incompatible changes Analyzer with the given options.
+func New(r *schemahcl.Resource) (*Analyzer, error) {
+	az := &Analyzer{}
+	if r, ok := r.Resource(az.Name()); ok {
+		if err := r.As(&az.Options); err != nil {
+			return nil, fmt.Errorf("sql/sqlcheck: parsing incompatible check options: %w", err)
+		}
+	}
+	return az, nil
+}
+
+// List of codes.
+var (
+	codeRenameT = sqlcheck.Code("BC101")
+	codeRenameC = sqlcheck.Code("BC102")
+)
+
+// Name of the analyzer. Implements the sqlcheck.NamedAnalyzer interface.
+func (*Analyzer) Name() string {
+	return "incompatible"
+}
+
+// Analyze implements sqlcheck.Analyzer.
+func (a *Analyzer) Analyze(_ context.Context, p *sqlcheck.Pass) error {
+	var diags []sqlcheck.Diagnostic
+	for i, sc := range p.File.Changes {
+		for _, c := range sc.Changes {
+			switch c := c.(type) {
+			case *schema.RenameTable:
+				if p.File.SchemaSpan(c.From.Schema)&sqlcheck.SpanAdded == 0 && p.File.TableSpan(c.From)&sqlcheck.SpanAdded == 0 &&
+					!ViewForRenamedT(p.File, c.From.Name, c.To.Name, sc.Stmt.Pos) {
+					diags = append(diags, sqlcheck.Diagnostic{
+						Code: codeRenameT,
+						Pos:  sc.Stmt.Pos,
+						Text: fmt.Sprintf("Renaming table %q to %q", c.From.Name, c.To.Name),
+					})
+				}
+			case *schema.ModifyTable:
+				for j := range c.Changes {
+					r, ok := c.Changes[j].(*schema.RenameColumn)
+					if ok && p.File.TableSpan(c.T)&sqlcheck.SpanAdded == 0 && !wasAddedBack(p.File.Changes[i:], r.From) {
+						diags = append(diags, sqlcheck.Diagnostic{
+							Code: codeRenameC,
+							Pos:  sc.Stmt.Pos,
+							Text: fmt.Sprintf("Renaming column %q to %q", r.From.Name, r.To.Name),
+						})
+					}
+				}
+			}
+		}
+	}
+	if len(diags) > 0 {
+		const reportText = "backwards-incompatible changes detected"
+		p.Reporter.WriteReport(sqlcheck.Report{Text: reportText, Diagnostics: diags})
+		if sqlx.V(a.Error) {
+			return errors.New(reportText)
+		}
+	}
+	return nil
+}
+
+// ViewForRenamedT checks if a view was created was a table that was renamed after the given position.
+func ViewForRenamedT(f *sqlcheck.File, old, new string, pos int) bool {
+	// The parser used for parsing this file can check if the
+	// given nullable column was filled before the given position.
+	p, ok := f.Parser.(interface {
+		CreateViewAfter(f migrate.File, old, new string, pos int) (bool, error)
+	})
+	if !ok {
+		return false
+	}
+	created, _ := p.CreateViewAfter(f, old, new, pos)
+	return created
+}
+
+func wasAddedBack(changes []*sqlcheck.Change, old *schema.Column) bool {
+	for _, sc := range changes {
+		for _, c := range sc.Changes {
+			m, ok := c.(*schema.ModifyTable)
+			if !ok {
+				continue
+			}
+			// Although it is recommended to add the renamed column as generated,
+			// adding it as a regular column is considered backwards compatible.
+			if schema.Changes(m.Changes).IndexAddColumn(old.Name) != -1 {
+				return true
+			}
+		}
+	}
+	return false
+}


Test patch:
diff --git a/cmd/atlas/internal/sqlparse/myparse/myparse_test.go b/cmd/atlas/internal/sqlparse/myparse/myparse_test.go
index 120938cf..cb865988 100644
--- a/cmd/atlas/internal/sqlparse/myparse/myparse_test.go
+++ b/cmd/atlas/internal/sqlparse/myparse/myparse_test.go
@@ -127,6 +127,22 @@ func TestFixChange_RenameTable(t *testing.T) {
 		},
 		changes,
 	)
+	changes, err = p.FixChange(
+		nil,
+		"RENAME TABLE `s1`.`t1` TO `s1`.`t2`;",
+		schema.Changes{
+			&schema.DropTable{T: schema.NewTable("t1")},
+			&schema.AddTable{T: schema.NewTable("t2")},
+		},
+	)
+	require.NoError(t, err)
+	require.Equal(
+		t,
+		schema.Changes{
+			&schema.RenameTable{From: schema.NewTable("t1"), To: schema.NewTable("t2")},
+		},
+		changes,
+	)
 	changes, err = p.FixChange(
 		nil,
 		"RENAME TABLE t1 TO t2, t3 TO t4",
@@ -316,6 +332,62 @@ UPDATE t SET c = CONCAT('tenant_', d) WHERE c = 0;
 	}
 }
 
+func TestCreateViewAfter(t *testing.T) {
+	for i, tt := range []struct {
+		file        string
+		pos         int
+		wantCreated bool
+		wantErr     bool
+	}{
+		{
+			file: `
+ALTER TABLE old RENAME TO new;
+CREATE VIEW old AS SELECT * FROM new;
+`,
+			pos:         1,
+			wantCreated: true,
+		},
+		{
+			file: `
+ALTER TABLE old RENAME TO new;
+CREATE VIEW old AS SELECT * FROM users;
+`,
+			pos: 1,
+		},
+		{
+			file: `
+ALTER TABLE old RENAME TO new;
+CREATE VIEW old AS SELECT * FROM new JOIN new;
+`,
+			pos: 1,
+		},
+		{
+			file: `
+ALTER TABLE old RENAME TO new;
+CREATE VIEW old AS SELECT * FROM new;
+`,
+			pos: 100,
+		},
+		{
+			file: `
+ALTER TABLE old RENAME TO new;
+CREATE VIEW old AS SELECT a, b, c FROM new;
+`,
+			wantCreated: true,
+		},
+	} {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			var (
+				p myparse.Parser
+				f = migrate.NewLocalFile("file", []byte(tt.file))
+			)
+			created, err := p.CreateViewAfter(f, "old", "new", tt.pos)
+			require.Equal(t, err != nil, tt.wantErr, err)
+			require.Equal(t, created, tt.wantCreated)
+		})
+	}
+}
+
 func TestColumnHasReferences(t *testing.T) {
 	for i, tt := range []struct {
 		stmt    string
diff --git a/cmd/atlas/internal/sqlparse/pgparse/pgparse_test.go b/cmd/atlas/internal/sqlparse/pgparse/pgparse_test.go
index 7873d1aa..4ac9cd1d 100644
--- a/cmd/atlas/internal/sqlparse/pgparse/pgparse_test.go
+++ b/cmd/atlas/internal/sqlparse/pgparse/pgparse_test.go
@@ -172,3 +172,59 @@ func TestColumnFilledBefore(t *testing.T) {
 		})
 	}
 }
+
+func TestCreateViewAfter(t *testing.T) {
+	for i, tt := range []struct {
+		file        string
+		pos         int
+		wantCreated bool
+		wantErr     bool
+	}{
+		{
+			file: `
+ALTER TABLE old RENAME TO new;
+CREATE VIEW old AS SELECT * FROM new;
+`,
+			pos:         1,
+			wantCreated: true,
+		},
+		{
+			file: `
+		ALTER TABLE old RENAME TO new;
+		CREATE VIEW old AS SELECT * FROM users;
+		`,
+			pos: 1,
+		},
+		{
+			file: `
+		ALTER TABLE old RENAME TO new;
+		CREATE VIEW old AS (SELECT * FROM "new");
+		`,
+			pos: 1,
+		},
+		{
+			file: `
+		ALTER TABLE old RENAME TO new;
+		CREATE VIEW old AS SELECT * FROM new;
+		`,
+			pos: 100,
+		},
+		{
+			file: `
+		ALTER TABLE old RENAME TO new;
+		CREATE VIEW old AS SELECT a, b, c FROM new;
+		`,
+			wantCreated: true,
+		},
+	} {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			var (
+				p pgparse.Parser
+				f = migrate.NewLocalFile("file", []byte(tt.file))
+			)
+			created, err := p.CreateViewAfter(f, "old", "new", tt.pos)
+			require.Equal(t, err != nil, tt.wantErr, err)
+			require.Equal(t, created, tt.wantCreated)
+		})
+	}
+}
diff --git a/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt b/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt
index 01eff4a3..72d97db3 100644
--- a/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-lint-destructive.txt
@@ -24,6 +24,7 @@ ALTER TABLE users RENAME COLUMN id TO oid;
 CREATE TABLE users (id int);
 
 -- migrations2/2.sql --
+-- atlas:nolint incompatible
 /* Rename table mixed with rename column is not a destructive change. */
 ALTER TABLE users RENAME TO user, RENAME COLUMN id TO oid;
 
diff --git a/internal/integration/testdata/mysql/cli-migrate-lint-incompatible.txt b/internal/integration/testdata/mysql/cli-migrate-lint-incompatible.txt
new file mode 100644
index 00000000..776c9b92
--- /dev/null
+++ b/internal/integration/testdata/mysql/cli-migrate-lint-incompatible.txt
@@ -0,0 +1,49 @@
+only mysql8
+
+atlas migrate lint --dir file://migrations1 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected1.txt
+
+atlas migrate lint --dir file://migrations2 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected2.txt
+
+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);
+
+-- migrations1/2.sql --
+ALTER TABLE users RENAME TO Users;
+
+-- expected1.txt --
+2.sql: backwards-incompatible changes detected:
+
+	L1: Renaming table "users" to "Users"
+
+-- migrations2/1.sql --
+CREATE TABLE users (id int);
+
+-- migrations2/2.sql --
+ALTER TABLE users RENAME COLUMN id TO uid;
+
+-- expected2.txt --
+2.sql: backwards-incompatible changes detected:
+
+	L1: Renaming column "id" to "uid"
+
+-- migrations3/1.sql --
+CREATE TABLE users (id int);
+CREATE TABLE posts (id int);
+CREATE TABLE cards (id int);
+
+-- migrations3/2.sql --
+ALTER TABLE users RENAME TO Users;
+CREATE VIEW users AS SELECT * FROM Users;
+ALTER TABLE posts RENAME COLUMN id TO uid, ADD COLUMN id int AS (uid);
+ALTER TABLE cards RENAME COLUMN id TO cid, ADD COLUMN name varchar(255);
+
+-- expected3.txt --
+2.sql: backwards-incompatible changes detected:
+
+	L4: Renaming column "id" to "cid"
+
diff --git a/internal/integration/testdata/postgres/cli-migrate-lint-incompatible.txt b/internal/integration/testdata/postgres/cli-migrate-lint-incompatible.txt
new file mode 100644
index 00000000..5547f8d7
--- /dev/null
+++ b/internal/integration/testdata/postgres/cli-migrate-lint-incompatible.txt
@@ -0,0 +1,55 @@
+only postgres15
+
+atlas migrate lint --dir file://migrations1 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected1.txt
+
+atlas migrate lint --dir file://migrations2 --dev-url URL --latest=1 > got.txt
+cmp got.txt expected2.txt
+
+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);
+
+-- migrations1/2.sql --
+ALTER TABLE users RENAME TO atlas_users;
+
+-- expected1.txt --
+2.sql: backwards-incompatible changes detected:
+
+	L1: Renaming table "users" to "atlas_users"
+
+-- migrations2/1.sql --
+CREATE TABLE users (id int);
+
+-- migrations2/2.sql --
+ALTER TABLE users RENAME COLUMN id TO uid;
+
+-- expected2.txt --
+2.sql: backwards-incompatible changes detected:
+
+	L1: Renaming column "id" to "uid"
+
+-- migrations3/1.sql --
+CREATE TABLE "users" (id int);
+CREATE TABLE posts (id int);
+CREATE TABLE cards (id int);
+
+-- migrations3/2.sql --
+ALTER TABLE "users" RENAME TO "atlas_users";
+CREATE VIEW "users" AS SELECT * FROM "atlas_users";
+ALTER TABLE posts RENAME COLUMN id TO uid;
+/*
+Although it is recommended to add the renamed column as generated,
+adding it as a regular column is considered backwards compatible.
+*/
+ALTER TABLE posts ADD COLUMN id int DEFAULT 0;
+ALTER TABLE cards RENAME COLUMN id TO cid;
+ALTER TABLE cards ADD COLUMN name text;
+
+-- expected3.txt --
+2.sql: backwards-incompatible changes detected:
+
+	L9: Renaming column "id" to "cid"
+
diff --git a/sql/sqlcheck/incompatible/incompatible_test.go b/sql/sqlcheck/incompatible/incompatible_test.go
new file mode 100644
index 00000000..c5d085a9
--- /dev/null
+++ b/sql/sqlcheck/incompatible/incompatible_test.go
@@ -0,0 +1,280 @@
+// 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 incompatible_test
+
+import (
+	"context"
+	"testing"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlcheck/incompatible"
+	"ariga.io/atlas/sql/sqlclient"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestAnalyzer_RenameColumn(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: &migrate.Stmt{
+							Text: "CREATE TABLE `users` (`id` int)",
+						},
+						Changes: schema.Changes{
+							&schema.AddTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")).
+									AddColumns(schema.NewIntColumn("id", "int")),
+							},
+						},
+					},
+					// Skip column that was added in the same file.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `users` RENAME COLUMN `id` TO `uid`",
+						},
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")).
+									AddColumns(schema.NewIntColumn("uid", "int")),
+								Changes: schema.Changes{
+									&schema.RenameColumn{
+										From: schema.NewIntColumn("id", "int"),
+										To:   schema.NewIntColumn("uid", "int"),
+									},
+								},
+							},
+						},
+					},
+					// Skip if a new generated column was added with the previous name.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `pets` RENAME COLUMN `id` TO `uid`",
+						},
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("pets").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewIntColumn("uid", "int"),
+										schema.NewIntColumn("id", "int").
+											SetGeneratedExpr(&schema.GeneratedExpr{
+												Expr: "uid", // point to the renamed column.
+											}),
+									),
+								Changes: schema.Changes{
+									&schema.RenameColumn{
+										From: schema.NewIntColumn("id", "int"),
+										To:   schema.NewIntColumn("uid", "int"),
+									},
+									&schema.AddColumn{
+										C: schema.NewIntColumn("id", "int").
+											SetGeneratedExpr(&schema.GeneratedExpr{
+												Expr: "uid", // point to the renamed column.
+											}),
+									},
+								},
+							},
+						},
+					},
+					// Skip if a new column was added with the previous name in consecutive statement.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `pets` RENAME COLUMN `id` TO `uid`;ALTER TABLE `pets` ADD COLUMN `id` int;",
+						},
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("pets").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewIntColumn("uid", "int"),
+										schema.NewIntColumn("id", "int").
+											SetGeneratedExpr(&schema.GeneratedExpr{
+												Expr: "uid", // point to the renamed column.
+											}),
+									),
+								Changes: schema.Changes{
+									&schema.RenameColumn{
+										From: schema.NewIntColumn("id", "int"),
+										To:   schema.NewIntColumn("uid", "int"),
+									},
+								},
+							},
+							&schema.ModifyTable{
+								T: schema.NewTable("pets").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewIntColumn("uid", "int"),
+										schema.NewIntColumn("id", "int").
+											SetGeneratedExpr(&schema.GeneratedExpr{
+												Expr: "uid", // point to the renamed column.
+											}),
+									),
+								Changes: schema.Changes{
+									&schema.AddColumn{
+										C: schema.NewIntColumn("id", "int").
+											SetGeneratedExpr(&schema.GeneratedExpr{
+												Expr: "uid", // point to the renamed column.
+											}),
+									},
+								},
+							},
+						},
+					},
+					// Detect rename.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `cards` RENAME COLUMN `id` TO `uid`",
+						},
+						Changes: schema.Changes{
+							&schema.ModifyTable{
+								T: schema.NewTable("cards").
+									SetSchema(schema.New("test")).
+									AddColumns(
+										schema.NewIntColumn("uid", "int"),
+									),
+								Changes: schema.Changes{
+									&schema.RenameColumn{
+										From: schema.NewIntColumn("id", "int"),
+										To:   schema.NewIntColumn("uid", "int"),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				report = &r
+			}),
+		}
+	)
+	az, err := incompatible.New(nil)
+	require.NoError(t, err)
+	err = az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+	require.NotNil(t, report)
+	require.Len(t, report.Diagnostics, 1)
+	require.Equal(t, "BC102", report.Diagnostics[0].Code)
+	require.Equal(t, `Renaming column "id" to "uid"`, report.Diagnostics[0].Text)
+}
+
+func TestAnalyzer_RenameTable(t *testing.T) {
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{},
+			File: &sqlcheck.File{
+				File: testFile{
+					name: "1.sql",
+					stmts: []*migrate.Stmt{
+						{Text: "CREATE TABLE `users` (`id` int)", Pos: 1},
+						{Text: "ALTER TABLE `users` RENAME TO `Users`", Pos: 2},
+						{Text: "ALTER TABLE `pets` RENAME TO `Pets`", Pos: 3},
+						{Text: "ALTER TABLE `cards` RENAME TO `Cards`", Pos: 4},
+						{Text: "CREATE VIEW `cards` AS SELECT * FROM `Cards`", Pos: 5},
+					},
+				},
+				Parser: testParser{matchOn: "cards"},
+				Changes: []*sqlcheck.Change{
+					{
+						Stmt: &migrate.Stmt{
+							Text: "CREATE TABLE `users` (`id` int)",
+						},
+						Changes: schema.Changes{
+							&schema.AddTable{
+								T: schema.NewTable("users").
+									SetSchema(schema.New("test")).
+									AddColumns(schema.NewIntColumn("id", "int")),
+							},
+						},
+					},
+					// Skip table that was added in the same file.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `users` RENAME TO `Users`",
+						},
+						Changes: schema.Changes{
+							&schema.RenameTable{
+								From: schema.NewTable("users").
+									SetSchema(schema.New("test")),
+								To: schema.NewTable("Users").
+									SetSchema(schema.New("test")),
+							},
+						},
+					},
+					// Detect rename.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `pets` RENAME TO `Pets`",
+						},
+						Changes: schema.Changes{
+							&schema.RenameTable{
+								From: schema.NewTable("pets").
+									SetSchema(schema.New("test")),
+								To: schema.NewTable("Pets").
+									SetSchema(schema.New("test")),
+							},
+						},
+					},
+					// Skip rename if a view was created.
+					{
+						Stmt: &migrate.Stmt{
+							Text: "ALTER TABLE `cards` RENAME TO `Cards`",
+							Pos:  4,
+						},
+						Changes: schema.Changes{
+							&schema.RenameTable{
+								From: schema.NewTable("cards").
+									SetSchema(schema.New("test")),
+								To: schema.NewTable("Cards").
+									SetSchema(schema.New("test")),
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				report = &r
+			}),
+		}
+	)
+	az, err := incompatible.New(nil)
+	require.NoError(t, err)
+	err = az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+	require.NotNil(t, report)
+	require.Len(t, report.Diagnostics, 1)
+	require.Equal(t, "BC101", report.Diagnostics[0].Code)
+	require.Equal(t, `Renaming table "pets" to "Pets"`, report.Diagnostics[0].Text)
+}
+
+type testFile struct {
+	name  string
+	stmts []*migrate.Stmt
+	migrate.File
+}
+
+func (t testFile) Name() string {
+	return t.name
+}
+
+func (t testFile) StmtDecls() ([]*migrate.Stmt, error) {
+	return t.stmts, nil
+}
+
+type testParser struct{ matchOn string }
+
+func (t testParser) CreateViewAfter(_ migrate.File, old, _ string, _ int) (bool, error) {
+	return t.matchOn == old, nil
+}