Repository: ariga/atlas
Pull request #908 (2022-07-04 18:05:50)

sql/mysql/mysqlcheck: extend data-depend analysis


Patch:
diff --git a/sql/mysql/diff.go b/sql/mysql/diff.go
index 54b3e48f..79456181 100644
--- a/sql/mysql/diff.go
+++ b/sql/mysql/diff.go
@@ -63,8 +63,8 @@ func (d *diff) TableAttrDiff(from, to *schema.Table) ([]schema.Change, error) {
 	if change := d.collationChange(from.Attrs, from.Schema.Attrs, to.Attrs); change != noChange {
 		changes = append(changes, change)
 	}
-	if _, ok := d.supportsCheck(); !ok && sqlx.Has(to.Attrs, &schema.Check{}) {
-		return nil, fmt.Errorf("version %q does not support CHECK constraints", d.version)
+	if !d.SupportsCheck() && sqlx.Has(to.Attrs, &schema.Check{}) {
+		return nil, fmt.Errorf("version %q does not support CHECK constraints", d.V)
 	}
 	// For MariaDB, we skip JSON CHECK constraints that were created by the databases,
 	// or by Atlas for older versions. These CHECK constraints (inlined on the columns)
@@ -134,9 +134,9 @@ func (d *diff) IsGeneratedIndexName(_ *schema.Table, idx *schema.Index) bool {
 	// mysql-server/sql/sql_table.cc#add_functional_index_to_create_list
 	const f = "functional_index"
 	switch {
-	case d.supportsIndexExpr() && idx.Name == f:
+	case d.SupportsIndexExpr() && idx.Name == f:
 		return true
-	case d.supportsIndexExpr() && strings.HasPrefix(idx.Name+"_", f):
+	case d.SupportsIndexExpr() && strings.HasPrefix(idx.Name+"_", f):
 		i, err := strconv.ParseInt(strings.TrimLeft(idx.Name, idx.Name+"_"), 10, 64)
 		return err == nil && i > 1
 	case len(idx.Parts) == 0 || idx.Parts[0].C == nil:
@@ -367,7 +367,7 @@ func (d *diff) typeChanged(from, to *schema.Column) (bool, error) {
 		toT := toT.(*schema.IntegerType)
 		// MySQL v8.0.19 dropped both display-width
 		// and zerofill from the information schema.
-		if d.supportsDisplayWidth() {
+		if d.SupportsDisplayWidth() {
 			ft, _, _, err := parseColumn(fromT.T)
 			if err != nil {
 				return false, err
@@ -565,7 +565,7 @@ func (d *diff) defaultCollate(attrs *[]schema.Attr) error {
 		return nil
 	}
 	d.ch2co.Do(func() {
-		d.ch2co.v, d.ch2co.err = d.charsetToCollate()
+		d.ch2co.v, d.ch2co.err = d.CharsetToCollate()
 	})
 	if d.ch2co.err != nil {
 		return d.ch2co.err
@@ -586,7 +586,7 @@ func (d *diff) defaultCharset(attrs *[]schema.Attr) error {
 		return nil
 	}
 	d.co2ch.Do(func() {
-		d.co2ch.v, d.co2ch.err = d.collateToCharset()
+		d.co2ch.v, d.co2ch.err = d.CollateToCharset()
 	})
 	if d.co2ch.err != nil {
 		return d.co2ch.err
diff --git a/sql/mysql/driver.go b/sql/mysql/driver.go
index 5b3bee49..4be7a442 100644
--- a/sql/mysql/driver.go
+++ b/sql/mysql/driver.go
@@ -6,8 +6,6 @@ package mysql
 
 import (
 	"context"
-	"embed"
-	"encoding/json"
 	"fmt"
 	"net/url"
 	"strings"
@@ -15,9 +13,9 @@ import (
 
 	"ariga.io/atlas/sql/internal/sqlx"
 	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/mysql/internal/mysqlversion"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
-	"golang.org/x/mod/semver"
 )
 
 type (
@@ -34,7 +32,7 @@ type (
 	conn struct {
 		schema.ExecQuerier
 		// System variables that are set on `Open`.
-		version string
+		mysqlversion.V
 		collate string
 		charset string
 	}
@@ -57,10 +55,10 @@ func Open(db schema.ExecQuerier) (migrate.Driver, error) {
 	if err != nil {
 		return nil, fmt.Errorf("mysql: query system variables: %w", err)
 	}
-	if err := sqlx.ScanOne(rows, &c.version, &c.collate, &c.charset); err != nil {
+	if err := sqlx.ScanOne(rows, &c.V, &c.collate, &c.charset); err != nil {
 		return nil, fmt.Errorf("mysql: scan system variables: %w", err)
 	}
-	if c.tidb() {
+	if c.TiDB() {
 		return &Driver{
 			conn:        c,
 			Differ:      &sqlx.Diff{DiffDriver: &tdiff{diff{conn: c}}},
@@ -133,118 +131,6 @@ func acquire(ctx context.Context, conn schema.ExecQuerier, name string, timeout
 	return nil
 }
 
-// supportsCheck reports if the connected database supports
-// the CHECK clause, and return the querying for getting them.
-func (c *conn) supportsCheck() (string, bool) {
-	v, q := "8.0.16", myChecksQuery
-	if c.mariadb() {
-		v, q = "10.2.1", marChecksQuery
-	}
-	return q, c.gteV(v)
-}
-
-// supportsIndexExpr reports if the connected database supports
-// index expressions (functional key part).
-func (c *conn) supportsIndexExpr() bool {
-	return !c.mariadb() && c.gteV("8.0.13")
-}
-
-// supportsDisplayWidth reports if the connected database supports
-// getting the display width information from the information schema.
-func (c *conn) supportsDisplayWidth() bool {
-	// MySQL v8.0.19 dropped the display width
-	// information from the information schema
-	return c.mariadb() || c.ltV("8.0.19")
-}
-
-// supportsExprDefault reports if the connected database supports
-// expressions in the DEFAULT clause on column definition.
-func (c *conn) supportsExprDefault() bool {
-	v := "8.0.13"
-	if c.mariadb() {
-		v = "10.2.1"
-	}
-	return c.gteV(v)
-}
-
-// supportsEnforceCheck reports if the connected database supports
-// the ENFORCED option in CHECK constraint syntax.
-func (c *conn) supportsEnforceCheck() bool {
-	return !c.mariadb() && c.gteV("8.0.16")
-}
-
-// supportsGeneratedColumns reports if the connected database
-// supports the generated columns in information schema.
-func (c *conn) supportsGeneratedColumns() bool {
-	v := "5.7"
-	if c.mariadb() {
-		v = "10.2"
-	}
-	return c.gteV(v)
-}
-
-// supportsRenameColumn reports if the connected database
-// supports the "RENAME COLUMN" clause.
-func (c *conn) supportsRenameColumn() bool {
-	v := "8"
-	if c.mariadb() {
-		v = "10.5.2"
-	}
-	return c.gteV(v)
-}
-
-// charsetToCollate returns the mapping from charset to its default collation.
-func (c *conn) charsetToCollate() (map[string]string, error) {
-	name := "internal/charset2collate"
-	if c.mariadb() {
-		name += ".maria"
-	}
-	return decode(name)
-}
-
-// collateToCharset returns the mapping from a collation to its charset.
-func (c *conn) collateToCharset() (map[string]string, error) {
-	name := "internal/collate2charset"
-	if c.mariadb() {
-		name += ".maria"
-	}
-	return decode(name)
-}
-
-// supportsIndexComment reports if the connected database
-// supports comments on indexes.
-func (c *conn) supportsIndexComment() bool {
-	// According to Oracle release notes, comments on
-	// indexes were added in version 5.5.3.
-	return c.mariadb() || c.gteV("5.5.3")
-}
-
-// mariadb reports if the Driver is connected to a MariaDB database.
-func (c *conn) mariadb() bool {
-	return strings.Index(c.version, "MariaDB") > 0
-}
-
-// tidb reports if the Driver is connected to a TiDB database.
-func (c *conn) tidb() bool {
-	return strings.Index(c.version, "TiDB") > 0
-}
-
-// compareV returns an integer comparing two versions according to
-// semantic version precedence.
-func (c *conn) compareV(w string) int {
-	v := c.version
-	if c.mariadb() {
-		v = v[:strings.Index(v, "MariaDB")-1]
-	}
-	return semver.Compare("v"+v, "v"+w)
-}
-
-// gteV reports if the connection version is >= w.
-func (c *conn) gteV(w string) bool { return c.compareV(w) >= 0 }
-
-// ltV reports if the connection version is < w.
-func (c *conn) ltV(w string) bool { return c.compareV(w) == -1 }
-
 // unescape strings with backslashes returned
 // for SQL expressions from information schema.
 func unescape(s string) string {
@@ -304,21 +190,6 @@ func dsn(u *url.URL) string {
 	return b.String()
 }
 
-//go:embed internal/*
-var encoding embed.FS
-
-func decode(name string) (map[string]string, error) {
-	f, err := encoding.Open(name)
-	if err != nil {
-		return nil, err
-	}
-	var m map[string]string
-	if err := json.NewDecoder(f).Decode(&m); err != nil {
-		return nil, fmt.Errorf("decode %q", name)
-	}
-	return m, nil
-}
-
 // MySQL standard column types as defined in its codebase. Name and order
 // is organized differently than MySQL.
 //
diff --git a/sql/mysql/inspect.go b/sql/mysql/inspect.go
index eea2b448..966f10d7 100644
--- a/sql/mysql/inspect.go
+++ b/sql/mysql/inspect.go
@@ -202,7 +202,7 @@ func (i *inspect) tables(ctx context.Context, realm *schema.Realm, opts *schema.
 // columns queries and appends the columns of the given table.
 func (i *inspect) columns(ctx context.Context, s *schema.Schema) error {
 	query := columnsQuery
-	if i.supportsGeneratedColumns() {
+	if i.SupportsGeneratedColumns() {
 		query = columnsExprQuery
 	}
 	rows, err := i.querySchema(ctx, query, s)
@@ -258,13 +258,13 @@ func (i *inspect) addColumn(s *schema.Schema, rows *sql.Rows) error {
 		c.Attrs = append(c.Attrs, &OnUpdate{A: attr.onUpdate})
 	}
 	if x := expr.String; x != "" {
-		if !i.mariadb() {
+		if !i.Maria() {
 			x = unescape(x)
 		}
 		c.SetGeneratedExpr(&schema.GeneratedExpr{Expr: x, Type: attr.generatedType})
 	}
 	if defaults.Valid {
-		if i.mariadb() {
+		if i.Maria() {
 			c.Default = i.marDefaultExpr(c, defaults.String)
 		} else {
 			c.Default = i.myDefaultExpr(c, defaults.String, attr)
@@ -418,7 +418,7 @@ func (i *inspect) checks(ctx context.Context, s *schema.Schema) error {
 			Name: name.String,
 			Expr: unescape(clause.String),
 		}
-		if i.mariadb() {
+		if i.Maria() {
 			check.Expr = clause.String
 			// In MariaDB, JSON is an alias to LONGTEXT. For versions >= 10.4.3, the CHARSET and COLLATE set to utf8mb4
 			// and a CHECK constraint is automatically created for the column as well (i.e. JSON_VALID(`<C>`)). However,
@@ -442,13 +442,23 @@ func (i *inspect) checks(ctx context.Context, s *schema.Schema) error {
 	return rows.Err()
 }
 
+// supportsCheck reports if the connected database supports
+// the CHECK clause, and return the querying for getting them.
+func (i *inspect) supportsCheck() (string, bool) {
+	q := myChecksQuery
+	if i.Maria() {
+		q = marChecksQuery
+	}
+	return q, i.SupportsCheck()
+}
+
 // indexQuery returns the query to retrieve the indexes of the given table.
 func (i *inspect) indexQuery() string {
 	query := indexesNoCommentQuery
-	if i.supportsIndexComment() {
+	if i.SupportsIndexComment() {
 		query = indexesQuery
 	}
-	if i.supportsIndexExpr() {
+	if i.SupportsIndexExpr() {
 		query = indexesExprQuery
 	}
 	return query
@@ -553,7 +563,7 @@ var reCurrTimestamp = regexp.MustCompile(`(?i)^current_timestamp(?:\(\d?\))?$`)
 // myDefaultExpr returns the correct schema.Expr based on the column attributes for MySQL.
 func (i *inspect) myDefaultExpr(c *schema.Column, x string, attr *extraAttr) schema.Expr {
 	// In MySQL, the DEFAULT_GENERATED indicates the column has an expression default value.
-	if i.supportsExprDefault() && attr.defaultGenerated {
+	if i.SupportsExprDefault() && attr.defaultGenerated {
 		// Skip CURRENT_TIMESTAMP, because wrapping it with parens will translate it to now().
 		if _, ok := c.Type.Type.(*schema.TimeType); ok && reCurrTimestamp.MatchString(x) {
 			return &schema.RawExpr{X: x}
@@ -618,7 +628,7 @@ func (i *inspect) marDefaultExpr(c *schema.Column, x string) schema.Expr {
 		return nil
 	}
 	// From MariaDB 10.2.7, string-based literals are quoted to distinguish them from expressions.
-	if i.gteV("10.2.7") && sqlx.IsQuoted(x, '\'') {
+	if i.GTE("10.2.7") && sqlx.IsQuoted(x, '\'') {
 		return &schema.Literal{V: x}
 	}
 	// In this case, we need to manually check if the expression is literal, or fallback to raw expression.
@@ -639,7 +649,7 @@ func (i *inspect) marDefaultExpr(c *schema.Column, x string) schema.Expr {
 			return &schema.RawExpr{X: x}
 		}
 	}
-	if !i.supportsExprDefault() {
+	if !i.SupportsExprDefault() {
 		return &schema.Literal{V: quote(x)}
 	}
 	return &schema.RawExpr{X: sqlx.MayWrap(x)}
diff --git a/sql/mysql/internal/mysqlcheck/datadepend.go b/sql/mysql/internal/mysqlcheck/datadepend.go
new file mode 100644
index 00000000..d316fd47
--- /dev/null
+++ b/sql/mysql/internal/mysqlcheck/datadepend.go
@@ -0,0 +1,190 @@
+// 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 mysqlcheck
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"ariga.io/atlas/schema/schemaspec"
+	"ariga.io/atlas/sql/mysql"
+	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlcheck"
+	"ariga.io/atlas/sql/sqlcheck/datadepend"
+)
+
+type (
+	// DataDependOptions defines the configuration options
+	// for the data-dependent changes checker.
+	DataDependOptions struct {
+		// NotNull indicates if the analyzer should check for modification or
+		// addition of not null constraints to
+		NotNull *bool `spec:"not_null,omitempty"`
+
+		// Allow drivers to extend the configuration.
+		schemaspec.DefaultExtension
+	}
+
+	// DataDepend checks data-dependent changes for MySQL.
+	DataDepend struct {
+		DataDependOptions
+		*datadepend.Analyzer
+	}
+)
+
+// Analyze runs data-depend analysis on MySQL changes.
+func (d *DataDepend) Analyze(ctx context.Context, p *sqlcheck.Pass) error {
+	diags := d.Diagnostics(ctx, p)
+	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.AddColumn:
+					ds, err := d.addColumn(p, sc, m.T, c.C)
+					if err != nil {
+						return err
+					}
+					diags = append(diags, ds...)
+				}
+			}
+		}
+	}
+	d.Report(p, diags)
+	return nil
+}
+
+// RegisterDataDepend is the constructor used in datadepend.Register.
+func RegisterDataDepend(ctx context.Context, az *datadepend.Analyzer, pass *sqlcheck.Pass) error {
+	var (
+		notnull = true
+		opts    DataDependOptions
+	)
+	if err := az.Extra.As(&opts); err != nil {
+		return err
+	}
+	if opts.NotNull == nil {
+		opts.NotNull = &notnull
+	}
+	d := &DataDepend{Analyzer: az, DataDependOptions: opts}
+	return d.Analyze(ctx, pass)
+}
+
+func (d *DataDepend) addColumn(p *sqlcheck.Pass, change *sqlcheck.Change, t *schema.Table, c *schema.Column) (diags []sqlcheck.Diagnostic, err error) {
+	// Skip this check in case it is disabled, the column is nullable
+	// or with default value, or the table was added in this file.
+	if !*d.NotNull || c.Type.Null || c.Default != nil || p.File.TableSpan(t)&sqlcheck.SpanAdded == 1 {
+		return
+	}
+	// Two types of reporting, implicit rows update and
+	// changes that may cause the migration to fail.
+	mightFail := func(tt string) {
+		diags = append(diags, sqlcheck.Diagnostic{
+			Pos: change.Pos,
+			Text: fmt.Sprintf(
+				"Adding a non-nullable %q column %q will fail in case table %q is not empty",
+				tt, c.Name, t.Name,
+			),
+		})
+	}
+	implicitUpdate := func(tt, v string) {
+		diags = append(diags, sqlcheck.Diagnostic{
+			Pos: change.Pos,
+			Text: fmt.Sprintf(
+				"Adding a non-nullable %q column %q on table %q without a default value implicitly sets existing rows with %s",
+				tt, c.Name, t.Name, v,
+			),
+		})
+	}
+	drv, ok := p.Dev.Driver.(*mysql.Driver)
+	if !ok {
+		return nil, fmt.Errorf("unexpected migrate driver %T", p.Dev.Driver)
+	}
+	switch ct := c.Type.Type.(type) {
+	case *mysql.BitType, *schema.BoolType, *schema.IntegerType, *schema.DecimalType, *schema.FloatType, *schema.BinaryType:
+		tt, err := mysql.FormatType(c.Type.Type)
+		if err != nil {
+			return nil, err
+		}
+		implicitUpdate(tt, "0")
+	case *schema.StringType:
+		switch ct.T {
+		case mysql.TypeVarchar, mysql.TypeChar:
+			implicitUpdate(ct.T, `""`)
+		case mysql.TypeText, mysql.TypeTinyText, mysql.TypeMediumText, mysql.TypeLongText:
+			// On MySQL, Existing rows are updated with ''. Skip it
+			// as we cannot propose and detect multi-steps migration
+			// (ALTER + UPDATE) at this stage.
+			if drv.Maria() {
+				implicitUpdate(ct.T, `""`)
+			}
+		}
+	case *schema.EnumType:
+		if len(ct.Values) == 0 {
+			return nil, fmt.Errorf("unexpected empty values for enum column %q.%q", t.Name, c.Name)
+		}
+		implicitUpdate("enum", strconv.Quote(ct.Values[0]))
+	case *mysql.SetType:
+		implicitUpdate("set", `""`)
+	case *schema.JSONType:
+		// On MySQL, Existing rows are updated with 'null' JSON. Same as TEXT
+		// columns, we cannot propose multi-steps migration (ALTER + UPDATE)
+		// as it cannot be detected at this stage.
+		if drv.Maria() {
+			implicitUpdate(ct.T, `""`)
+		}
+	case *schema.TimeType:
+		switch ct.T {
+		case mysql.TypeDate, mysql.TypeDateTime:
+			if drv.Maria() {
+				implicitUpdate(ct.T, "00:00:00")
+			} else {
+				// The suggested solution is to add a DEFAULT clause
+				// with valid value or set the column to nullable.
+				mightFail(ct.T)
+			}
+		case mysql.TypeYear:
+			implicitUpdate(ct.T, "0000")
+		case mysql.TypeTime:
+			implicitUpdate(ct.T, "00:00:00")
+		case mysql.TypeTimestamp:
+			v := "CURRENT_TIMESTAMP"
+			switch {
+			case drv.Maria():
+				// Maria has a special behavior for the first TIMESTAMP column.
+				// See: https://mariadb.com/kb/en/timestamp/#automatic-values
+				for i := 0; i < len(t.Columns) && t.Columns[i].Name != c.Name; i++ {
+					tt, err := mysql.FormatType(t.Columns[i].Type.Type)
+					if err != nil {
+						return nil, err
+					}
+					if strings.HasPrefix(tt, mysql.TypeTimestamp) {
+						v = "0000-00-00 00:00:00"
+						break
+					}
+				}
+			// Following MySQL 8.0.2, the explicit_defaults_for_timestamp
+			// system variable is now enabled by default.
+			case drv.GTE("8.0.2"):
+				v = "0000-00-00 00:00:00"
+			}
+			implicitUpdate(ct.T, v)
+		}
+	case *schema.SpatialType:
+		if drv.Maria() {
+			implicitUpdate(ct.T, `""`)
+		} else {
+			// The suggested solution is to add the column as
+			// null, update values and then set it to not-null.
+			mightFail(ct.T)
+		}
+	}
+	return
+}
diff --git a/sql/mysql/internal/.README.md b/sql/mysql/internal/mysqlversion/is/.README.md
similarity index 100%
rename from sql/mysql/internal/.README.md
rename to sql/mysql/internal/mysqlversion/is/.README.md
diff --git a/sql/mysql/internal/charset2collate b/sql/mysql/internal/mysqlversion/is/charset2collate
similarity index 100%
rename from sql/mysql/internal/charset2collate
rename to sql/mysql/internal/mysqlversion/is/charset2collate
diff --git a/sql/mysql/internal/charset2collate.maria b/sql/mysql/internal/mysqlversion/is/charset2collate.maria
similarity index 100%
rename from sql/mysql/internal/charset2collate.maria
rename to sql/mysql/internal/mysqlversion/is/charset2collate.maria
diff --git a/sql/mysql/internal/collate2charset b/sql/mysql/internal/mysqlversion/is/collate2charset
similarity index 100%
rename from sql/mysql/internal/collate2charset
rename to sql/mysql/internal/mysqlversion/is/collate2charset
diff --git a/sql/mysql/internal/collate2charset.maria b/sql/mysql/internal/mysqlversion/is/collate2charset.maria
similarity index 100%
rename from sql/mysql/internal/collate2charset.maria
rename to sql/mysql/internal/mysqlversion/is/collate2charset.maria
diff --git a/sql/mysql/internal/mysqlversion/mysqlversion.go b/sql/mysql/internal/mysqlversion/mysqlversion.go
new file mode 100644
index 00000000..dd784a9e
--- /dev/null
+++ b/sql/mysql/internal/mysqlversion/mysqlversion.go
@@ -0,0 +1,147 @@
+// 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 mysqlversion
+
+import (
+	"embed"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"golang.org/x/mod/semver"
+)
+
+// V provides information about MySQL versions.
+type V string
+
+// SupportsCheck reports if the version supports the CHECK
+// clause, and return the querying for getting them.
+func (v V) SupportsCheck() bool {
+	u := "8.0.16"
+	if v.Maria() {
+		u = "10.2.1"
+	}
+	return v.GTE(u)
+}
+
+// SupportsIndexExpr reports if the version supports
+// index expressions (functional key part).
+func (v V) SupportsIndexExpr() bool {
+	return !v.Maria() && v.GTE("8.0.13")
+}
+
+// SupportsDisplayWidth reports if the version supports getting
+// the display width information from the information schema.
+func (v V) SupportsDisplayWidth() bool {
+	// MySQL v8.0.19 dropped the display width
+	// information from the information schema
+	return v.Maria() || v.LT("8.0.19")
+}
+
+// SupportsExprDefault reports if the version supports
+// expressions in the DEFAULT clause on column definition.
+func (v V) SupportsExprDefault() bool {
+	u := "8.0.13"
+	if v.Maria() {
+		u = "10.2.1"
+	}
+	return v.GTE(u)
+}
+
+// SupportsEnforceCheck reports if the version supports
+// the ENFORCED option in CHECK constraint syntax.
+func (v V) SupportsEnforceCheck() bool {
+	return !v.Maria() && v.GTE("8.0.16")
+}
+
+// SupportsGeneratedColumns reports if the version supports
+// the generated columns in information schema.
+func (v V) SupportsGeneratedColumns() bool {
+	u := "5.7"
+	if v.Maria() {
+		u = "10.2"
+	}
+	return v.GTE(u)
+}
+
+// SupportsRenameColumn reports if the version supports
+// the "RENAME COLUMN" clause.
+func (v V) SupportsRenameColumn() bool {
+	u := "8"
+	if v.Maria() {
+		u = "10.5.2"
+	}
+	return v.GTE(u)
+}
+
+// SupportsIndexComment reports if the version
+// supports comments on indexes.
+func (v V) SupportsIndexComment() bool {
+	// According to Oracle release notes, comments on
+	// indexes were added in version 5.5.3.
+	return v.Maria() || v.GTE("5.5.3")
+}
+
+// CharsetToCollate returns the mapping from charset to its default collation.
+func (v V) CharsetToCollate() (map[string]string, error) {
+	name := "is/charset2collate"
+	if v.Maria() {
+		name += ".maria"
+	}
+	return decode(name)
+}
+
+// CollateToCharset returns the mapping from a collation to its charset.
+func (v V) CollateToCharset() (map[string]string, error) {
+	name := "is/collate2charset"
+	if v.Maria() {
+		name += ".maria"
+	}
+	return decode(name)
+}
+
+// Maria reports if the MySQL version is MariaDB.
+func (v V) Maria() bool {
+	return strings.Index(string(v), "MariaDB") > 0
+}
+
+// TiDB reports if the MySQL version is TiDB.
+func (v V) TiDB() bool {
+	return strings.Index(string(v), "TiDB") > 0
+}
+
+// Compare returns an integer comparing two versions according to
+// semantic version precedence.
+func (v V) Compare(w string) int {
+	u := string(v)
+	switch {
+	case v.Maria():
+		u = u[:strings.Index(u, "MariaDB")-1]
+	case v.TiDB():
+		u = u[:strings.Index(u, "TiDB")-1]
+	}
+	return semver.Compare("v"+u, "v"+w)
+}
+
+// GTE reports if the version is >= w.
+func (v V) GTE(w string) bool { return v.Compare(w) >= 0 }
+
+// LT reports if the version is < w.
+func (v V) LT(w string) bool { return v.Compare(w) == -1 }
+
+//go:embed is/*
+var encoding embed.FS
+
+func decode(name string) (map[string]string, error) {
+	f, err := encoding.Open(name)
+	if err != nil {
+		return nil, err
+	}
+	var m map[string]string
+	if err := json.NewDecoder(f).Decode(&m); err != nil {
+		return nil, fmt.Errorf("decode %q", name)
+	}
+	return m, nil
+}
diff --git a/sql/mysql/migrate.go b/sql/mysql/migrate.go
index a249c17b..e12251f8 100644
--- a/sql/mysql/migrate.go
+++ b/sql/mysql/migrate.go
@@ -349,7 +349,7 @@ func (s *state) alterTable(t *schema.Table, changes []schema.Change) error {
 					Change: change.Change,
 				})
 			case *schema.RenameColumn:
-				if s.supportsRenameColumn() {
+				if s.SupportsRenameColumn() {
 					b.P("RENAME COLUMN").Ident(change.From.Name).P("TO").Ident(change.To.Name)
 				} else {
 					b.P("CHANGE COLUMN").Ident(change.From.Name)
@@ -407,10 +407,10 @@ func (s *state) alterTable(t *schema.Table, changes []schema.Change) error {
 				case change.From.Name != change.To.Name:
 					return fmt.Errorf("mismatch check constraint names: %q != %q", change.From.Name, change.To.Name)
 				// Enforcement added.
-				case s.supportsEnforceCheck() && sqlx.Has(change.From.Attrs, &Enforced{}) && !sqlx.Has(change.To.Attrs, &Enforced{}):
+				case s.SupportsEnforceCheck() && sqlx.Has(change.From.Attrs, &Enforced{}) && !sqlx.Has(change.To.Attrs, &Enforced{}):
 					b.P("ALTER CHECK").Ident(change.From.Name).P("ENFORCED")
 				// Enforcement dropped.
-				case s.supportsEnforceCheck() && !sqlx.Has(change.From.Attrs, &Enforced{}) && sqlx.Has(change.To.Attrs, &Enforced{}):
+				case s.SupportsEnforceCheck() && !sqlx.Has(change.From.Attrs, &Enforced{}) && sqlx.Has(change.To.Attrs, &Enforced{}):
 					b.P("ALTER CHECK").Ident(change.From.Name).P("NOT ENFORCED")
 				// Expr was changed.
 				case change.From.Expr != change.To.Expr:
@@ -489,7 +489,7 @@ func (s *state) column(b *sqlx.Builder, t *schema.Table, c *schema.Column) error
 	}
 	// MariaDB does not accept [NOT NULL | NULL]
 	// as part of the generated columns' syntax.
-	if !asX || !s.mariadb() {
+	if !asX || !s.Maria() {
 		if !c.Type.Null {
 			b.P("NOT")
 		}
@@ -498,7 +498,7 @@ func (s *state) column(b *sqlx.Builder, t *schema.Table, c *schema.Column) error
 	s.columnDefault(b, c)
 	// Add manually the JSON_VALID constraint for older
 	// versions < 10.4.3. See Driver.checks for full info.
-	if _, ok := c.Type.Type.(*schema.JSONType); ok && s.mariadb() && s.ltV("10.4.3") && !sqlx.Has(c.Attrs, &schema.Check{}) {
+	if _, ok := c.Type.Type.(*schema.JSONType); ok && s.Maria() && s.LT("10.4.3") && !sqlx.Has(c.Attrs, &schema.Check{}) {
 		b.P("CHECK").Wrap(func(b *sqlx.Builder) {
 			b.WriteString(fmt.Sprintf("json_valid(`%s`)", c.Name))
 		})
@@ -734,7 +734,7 @@ func (s *state) check(b *sqlx.Builder, c *schema.Check) {
 		b.P("CONSTRAINT").Ident(c.Name)
 	}
 	b.P("CHECK", sqlx.MayWrap(c.Expr))
-	if s.supportsEnforceCheck() && sqlx.Has(c.Attrs, &Enforced{}) {
+	if s.SupportsEnforceCheck() && sqlx.Has(c.Attrs, &Enforced{}) {
 		b.P("ENFORCED")
 	}
 }
diff --git a/sql/sqlcheck/datadepend/datadepend.go b/sql/sqlcheck/datadepend/datadepend.go
index 81578288..ce17e032 100644
--- a/sql/sqlcheck/datadepend/datadepend.go
+++ b/sql/sqlcheck/datadepend/datadepend.go
@@ -43,17 +43,12 @@ func New(opts Options) *Analyzer {
 
 // Analyze implements sqlcheck.Analyzer.
 func (a *Analyzer) Analyze(ctx context.Context, p *sqlcheck.Pass) error {
-	f, ok := drivers.Load(p)
+	f, ok := drivers.Load(p.Dev.Name)
 	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,
-		})
-	}
+	a.Report(p, a.Diagnostics(ctx, p))
 	return nil
 }
 
@@ -103,6 +98,18 @@ func (a *Analyzer) Diagnostics(_ context.Context, p *sqlcheck.Pass) (diags []sql
 	return
 }
 
+// Report provides standard reporting for data-dependent changes. Drivers that
+// decorate this Analyzer should call this function to get consistent reporting
+// between dialects.
+func (a *Analyzer) Report(p *sqlcheck.Pass, diags []sqlcheck.Diagnostic) {
+	if len(diags) > 0 {
+		p.Reporter.WriteReport(sqlcheck.Report{
+			Text:        fmt.Sprintf("Data dependent changes detected in file %s", p.File.Name()),
+			Diagnostics: diags,
+		})
+	}
+}
+
 // drivers specific analyzers.
 var drivers sync.Map
 


Test patch:
diff --git a/sql/mysql/internal/mysqlcheck/datadepend_test.go b/sql/mysql/internal/mysqlcheck/datadepend_test.go
new file mode 100644
index 00000000..971f860e
--- /dev/null
+++ b/sql/mysql/internal/mysqlcheck/datadepend_test.go
@@ -0,0 +1,239 @@
+// 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 mysqlcheck_test
+
+import (
+	"context"
+	"testing"
+
+	"ariga.io/atlas/sql/migrate"
+	"ariga.io/atlas/sql/mysql"
+	"ariga.io/atlas/sql/mysql/internal/mysqlcheck"
+	"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 init() {
+	datadepend.Register("mysql", mysqlcheck.RegisterDataDepend)
+}
+
+func TestDataDepend_MySQL_ImplicitUpdate(t *testing.T) {
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{
+				Name:   "mysql",
+				Driver: &mysql.Driver{},
+			},
+			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.NewIntColumn("a", mysql.TypeInt),
+										schema.NewIntColumn("b", mysql.TypeInt),
+										schema.NewFloatColumn("c", mysql.TypeFloat),
+										schema.NewStringColumn("d", mysql.TypeVarchar, schema.StringSize(10)),
+										schema.NewEnumColumn("e", schema.EnumValues("foo", "bar")),
+										schema.NewTimeColumn("f", mysql.TypeTimestamp),
+									),
+								Changes: []schema.Change{
+									&schema.AddColumn{C: schema.NewIntColumn("b", mysql.TypeInt)},
+									&schema.AddColumn{C: schema.NewFloatColumn("c", mysql.TypeFloat)},
+									&schema.AddColumn{C: schema.NewStringColumn("d", mysql.TypeVarchar, schema.StringSize(10))},
+									&schema.AddColumn{C: schema.NewEnumColumn("e", schema.EnumValues("foo", "bar"))},
+									&schema.AddColumn{C: schema.NewTimeColumn("f", mysql.TypeTimestamp)},
+								},
+							},
+						},
+					},
+				},
+			},
+			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, 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 ""`)
+	require.Equal(t, report.Diagnostics[3].Text, `Adding a non-nullable "enum" column "e" on table "users" without a default value implicitly sets existing rows with "foo"`)
+	require.Equal(t, report.Diagnostics[4].Text, `Adding a non-nullable "timestamp" column "f" on table "users" without a default value implicitly sets existing rows with CURRENT_TIMESTAMP`)
+}
+
+func TestDataDepend_MySQL8_ImplicitUpdate(t *testing.T) {
+	drv := &mysql.Driver{}
+	drv.V = "8.0.19"
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{
+				Name:   "mysql",
+				Driver: drv,
+			},
+			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.NewIntColumn("a", mysql.TypeInt),
+										schema.NewTimeColumn("b", mysql.TypeTimestamp),
+									),
+								Changes: []schema.Change{
+									&schema.AddColumn{C: schema.NewTimeColumn("b", mysql.TypeTimestamp)},
+								},
+							},
+						},
+					},
+				},
+			},
+			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,
+		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`,
+		"explicit_defaults_for_timestamp is enabled by default for versions >= 8.0.2",
+	)
+}
+
+func TestDataDepend_MySQL_MightFail(t *testing.T) {
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{
+				Name:   "mysql",
+				Driver: &mysql.Driver{},
+			},
+			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.NewIntColumn("a", mysql.TypeInt),
+										schema.NewTimeColumn("b", mysql.TypeDate),
+										schema.NewTimeColumn("c", mysql.TypeDateTime),
+										schema.NewSpatialColumn("d", mysql.TypePoint),
+									),
+								Changes: []schema.Change{
+									&schema.AddColumn{C: schema.NewTimeColumn("b", mysql.TypeDate)},
+									&schema.AddColumn{C: schema.NewTimeColumn("c", mysql.TypeDateTime)},
+									&schema.AddColumn{C: schema.NewSpatialColumn("d", mysql.TypePoint)},
+								},
+							},
+						},
+					},
+				},
+			},
+			Reporter: sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
+				report = &r
+			}),
+		}
+	)
+	datadepend.Register("mysql", mysqlcheck.RegisterDataDepend)
+	az := datadepend.New(datadepend.Options{})
+	err := az.Analyze(context.Background(), pass)
+	require.NoError(t, err)
+	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`)
+}
+
+func TestDataDepend_Maria_ImplicitUpdate(t *testing.T) {
+	drv := &mysql.Driver{}
+	drv.V = "10.7.1-MariaDB"
+	var (
+		report *sqlcheck.Report
+		pass   = &sqlcheck.Pass{
+			Dev: &sqlclient.Client{
+				Name:   "mysql",
+				Driver: drv,
+			},
+			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.NewIntColumn("a", mysql.TypeInt),
+										schema.NewIntColumn("b", mysql.TypeText),
+										schema.NewJSONColumn("c", mysql.TypeJSON),
+										schema.NewTimeColumn("d", mysql.TypeDate),
+										schema.NewTimeColumn("e", mysql.TypeDateTime),
+										schema.NewSpatialColumn("f", mysql.TypePoint),
+										schema.NewTimeColumn("first_ts", mysql.TypeTimestamp),
+										schema.NewTimeColumn("second_ts", mysql.TypeTimestamp),
+									),
+								Changes: []schema.Change{
+									&schema.AddColumn{C: schema.NewStringColumn("b", mysql.TypeText)},
+									&schema.AddColumn{C: schema.NewJSONColumn("c", mysql.TypeJSON)},
+									&schema.AddColumn{C: schema.NewTimeColumn("d", mysql.TypeDate)},
+									&schema.AddColumn{C: schema.NewTimeColumn("e", mysql.TypeDateTime)},
+									&schema.AddColumn{C: schema.NewSpatialColumn("f", mysql.TypePoint)},
+									&schema.AddColumn{C: schema.NewTimeColumn("first_ts", mysql.TypeTimestamp)},
+									&schema.AddColumn{C: schema.NewTimeColumn("second_ts", mysql.TypeTimestamp)},
+								},
+							},
+						},
+					},
+				},
+			},
+			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, 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`)
+	require.Equal(t, report.Diagnostics[3].Text, `Adding a non-nullable "datetime" column "e" on table "users" without a default value implicitly sets existing rows with 00:00:00`)
+	require.Equal(t, report.Diagnostics[4].Text, `Adding a non-nullable "point" column "f" on table "users" without a default value implicitly sets existing rows with ""`)
+	require.Equal(t, report.Diagnostics[5].Text, `Adding a non-nullable "timestamp" column "first_ts" on table "users" without a default value implicitly sets existing rows with CURRENT_TIMESTAMP`)
+	require.Equal(t, report.Diagnostics[6].Text, `Adding a non-nullable "timestamp" column "second_ts" on table "users" without a default value implicitly sets existing rows with 0000-00-00 00:00:00`)
+
+}
+
+type testFile struct {
+	name string
+	migrate.File
+}
+
+func (t testFile) Name() string {
+	return t.name
+}