Help us shape the future of Go! Take the 2026 Go Developer Survey

statement

package
v0.19.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: Apache-2.0 Imports: 26 Imported by: 6

README

Statement

The statement package provides SQL statement parsing and analysis capabilities for Spirit. It wraps pkg/parser (Spirit's MySQL-only fork of the TiDB parser) to extract structured information from DDL statements and determine their safety characteristics for online schema changes.

Design Philosophy

Spirit needs to understand DDL statements to:

  1. Validate that statements are supported for online migration (i.e., not an INSERT statement)
  2. Extract table names, schema names, and ALTER clauses
  3. Analyze whether operations are safe for INPLACE algorithm
  4. Transform statements (e.g., rewrite CREATE INDEX to ALTER TABLE)
  5. Parse CREATE TABLE statements into structured data for comparison
  6. Normalize parsed CREATE TABLE definitions to MySQL's canonical form so equivalent schemas compare equal (see Normalization)

Rather than implementing a parser from scratch, Spirit maintains a fork of the TiDB parser (see pkg/parser), which provides:

  • Battle-tested SQL parsing compatible with MySQL syntax
  • AST (Abstract Syntax Tree) representation of statements
  • Ability to restore modified ASTs back to SQL

The statement package adds Spirit-specific logic on top of the parser, such as safety analysis and structured CREATE TABLE parsing.

Core Types

AbstractStatement

AbstractStatement represents a parsed DDL statement with extracted metadata:

type AbstractStatement struct {
    Schema    string          // Schema name (if fully qualified)
    Table     string          // Table name
    Alter     string          // ALTER clause (empty for non-ALTER statements)
    Statement string          // Original SQL statement
    StmtNode  *ast.StmtNode   // Parsed AST node
}

Key Points:

  • For multi-table statements (e.g., DROP TABLE t1, t2), only the first table is stored in Table
  • Alter contains the normalized ALTER clause without ALTER TABLE table_name prefix
  • StmtNode provides access to the full AST for advanced operations
CreateTable

CreateTable represents a parsed CREATE TABLE statement with structured access to all components:

type CreateTable struct {
    Raw          *ast.CreateTableStmt
    TableName    string
    Temporary    bool
    IfNotExists  bool
    Columns      Columns
    Indexes      Indexes
    Constraints  Constraints
    TableOptions *TableOptions
    Partition    *PartitionOptions
}

This structured representation makes it easy to:

  • Compare table definitions
  • Extract specific columns or indexes
  • Generate modified CREATE TABLE statements
  • Validate table structure

Supported Statements

ALTER TABLE

The primary statement type for Spirit migrations:

stmts, err := statement.New("ALTER TABLE t1 ADD COLUMN c INT")
// stmts[0].Table = "t1"
// stmts[0].Alter = "ADD COLUMN `c` INT"

Features:

  • Normalizes ALTER clauses (adds backticks, standardizes formatting)
  • Supports fully qualified table names (schema.table)
  • Can parse multiple ALTER statements in one call
  • Parses ALGORITHM and LOCK clauses but does not reject them; callers should invoke AlterContainsUnsupportedClause on the resulting AbstractStatement if they need to enforce that these clauses are not present (Spirit manages these)
  • Detects column renames via ColumnRenameMap(), which returns a map of old→new column names for both RENAME COLUMN and CHANGE COLUMN syntax
CREATE TABLE

Supports CREATE TABLE for table creation operations:

stmts, err := statement.New("CREATE TABLE t1 (id INT PRIMARY KEY)")
// stmts[0].Table = "t1"
// stmts[0].Alter = "" (empty for non-ALTER)

For structured parsing:

ct, err := statement.ParseCreateTable("CREATE TABLE t1 (id INT PRIMARY KEY)")
// ct.TableName = "t1"
// ct.Columns[0].Name = "id"
// ct.Columns[0].Type = "int"
// ct.Columns[0].PrimaryKey = false (normalized into ct.Indexes)
// ct.Indexes.ByName("").Type = "PRIMARY KEY"
CREATE INDEX

Automatically rewritten to ALTER TABLE:

stmts, err := statement.New("CREATE INDEX idx ON t1 (a)")
// stmts[0].Table = "t1"
// stmts[0].Alter = "ADD INDEX `idx` (`a`)"
// stmts[0].Statement = "/* rewritten from CREATE INDEX */ ALTER TABLE `t1` ADD INDEX `idx` (`a`)"

Functional index expressions and mixed expression/column key parts are preserved in the rewrite. DROP INDEX idx ON t1 is also rewritten, as ALTER TABLE `t1` DROP INDEX `idx` .

DROP TABLE

Supports DROP TABLE operations:

stmts, err := statement.New("DROP TABLE t1")
// stmts[0].Table = "t1"
// stmts[0].Alter = "" (empty for non-ALTER)

Validation:

  • Multi-table drops must use the same schema (e.g., DROP TABLE test.t1, test.t2 is valid, but DROP TABLE test.t1, prod.t2 is not)
RENAME TABLE

Supports RENAME TABLE operations:

stmts, err := statement.New("RENAME TABLE t1 TO t2")
// stmts[0].Table = "t1"
// stmts[0].Alter = "" (empty for non-ALTER)

Validation:

  • Cannot rename across schemas (e.g., RENAME TABLE test.t1 TO prod.t2 is rejected)

Safety Analysis

The statement package provides methods to determine if ALTER operations are safe for online execution.

AlgorithmInplaceConsideredSafe

Determines if an ALTER statement can use MySQL's INPLACE algorithm safely:

stmt := statement.MustNew("ALTER TABLE t1 RENAME INDEX a TO b")[0]
err := stmt.AlgorithmInplaceConsideredSafe()
// err == nil (safe - metadata-only operation)

stmt = statement.MustNew("ALTER TABLE t1 ADD COLUMN c INT")[0]
err = stmt.AlgorithmInplaceConsideredSafe()
// err == ErrUnsafeForInplace (unsafe - requires table rebuild)

This feature exists because some DDL changes in MySQL only respond to the INPLACE DDL assertion, even though they are actually INSTANT operations (metadata-only). Since not all INPLACE operations are safe for online execution, we explicitly parse the statement to identify only known safe operations. See https://bugs.mysql.com/bug.php?id=113355.

AlterContainsUnsupportedClause

Checks for clauses that conflict with Spirit's operation:

stmt := statement.MustNew("ALTER TABLE t1 ADD INDEX (a), ALGORITHM=INPLACE")[0]
err := stmt.AlterContainsUnsupportedClause()
// err != nil (ALGORITHM clause not allowed)

Unsupported Clauses:

  • ALGORITHM=... (Spirit manages algorithm selection)
  • LOCK=... (Spirit manages locking strategy)
AlterContainsAddUnique

Detects if an ALTER adds a UNIQUE index:

stmt := statement.MustNew("ALTER TABLE t1 ADD UNIQUE INDEX (email)")[0]
err := stmt.AlterContainsAddUnique()
// err == ErrAlterContainsUnique

This is used to customize the error message if a checksum operation fails. This is because adding a UNIQUE index on non-unique data will result in a checksum failure, and it's helpful to hint this out to the user.

CREATE TABLE Parsing

The package provides detailed parsing of CREATE TABLE statements into structured data. This is extensively used by the lint package.

Basic Usage
ct, err := statement.ParseCreateTable(`
    CREATE TABLE users (
        id INT PRIMARY KEY AUTO_INCREMENT,
        email VARCHAR(255) NOT NULL UNIQUE,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        status ENUM('active', 'inactive') DEFAULT 'active'
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)

// Access columns
for _, col := range ct.Columns {
    fmt.Printf("%s: %s\n", col.Name, col.Type)
}

// Access indexes
for _, idx := range ct.Indexes {
    fmt.Printf("%s (%s): %v\n", idx.Name, idx.Type, idx.Columns)
}

// Access table options
if ct.TableOptions.Engine != nil {
    fmt.Printf("Engine: %s\n", *ct.TableOptions.Engine)
}
Column Information

Each Column provides detailed information:

type Column struct {
    Raw        *ast.ColumnDef    // Raw AST node from parser
    Name       string
    Type       string            // "int", "varchar", "decimal", etc.
    Length     *int              // For VARCHAR(100), Length = 100
    Precision  *int              // For DECIMAL(10,2), Precision = 10
    Scale      *int              // For DECIMAL(10,2), Scale = 2
    Unsigned   *bool
    Zerofill   *bool
    EnumValues []string          // For ENUM('a','b'), EnumValues = ["a", "b"]
    SetValues  []string          // For SET('x','y'), SetValues = ["x", "y"]
    Nullable   bool
    Default    *string           // normalized stored value, used for comparison
    DefaultIsExpr bool           // expression default, emitted in parentheses
    DefaultKind DefaultKind      // literal form read from the AST
    DefaultAsWritten *DefaultLiteral // the literal as the schema spelled it (emitted); nil for an expression default
    OnUpdate   *string           // ON UPDATE CURRENT_TIMESTAMP[(n)] for TIMESTAMP/DATETIME
    GeneratedExpr   *string      // Expression for GENERATED ALWAYS AS (...) columns
    GeneratedStored bool         // true = STORED, false = VIRTUAL
    Checks     []ColumnCheck     // Column-level CHECKs (name, expression, NOT ENFORCED); hoisted into Constraints
    SRID       *uint32           // SRID attribute for spatial columns
    Invisible  bool              // INVISIBLE column (8.0.23+); explicit VISIBLE is not recorded
    NotSecondary bool            // NOT SECONDARY
    ColumnFormat *string         // COLUMN_FORMAT FIXED|DYNAMIC; DEFAULT is not recorded
    Storage    *string           // STORAGE DISK|MEMORY; DEFAULT is not recorded
    SecondaryEngineAttribute *string // SECONDARY_ENGINE_ATTRIBUTE JSON as written; compared as JSON
    AutoInc    bool
    PrimaryKey bool              // Column-level PRIMARY KEY
    Unique     bool              // Column-level UNIQUE
    Comment    *string
    Charset    *string
    Collation  *string
    Options    map[string]string // Additional column options
}

Example:

col := ct.Columns.ByName("email")
// col.Name = "email"
// col.Type = "varchar"
// *col.Length = 255
// col.Nullable = false
// col.Unique = false (normalized into ct.Indexes)
// ct.Indexes.ByName("email").Type = "UNIQUE"
Index Information

Each Index provides:

type Index struct {
    Name         string
    Type         string            // "PRIMARY KEY", "UNIQUE", "INDEX", "FULLTEXT", "SPATIAL"
    Columns      []string          // plain column names; use ColumnList for full details
    ColumnList   []IndexColumn     // names, expressions, prefix lengths, descending order
    InlineDerived bool             // synthesized from an inline UNIQUE
    Invisible    *bool
    Using        *string           // "BTREE", "HASH", "RTREE"
    Comment      *string
    KeyBlockSize *uint64
    ParserName   *string           // For FULLTEXT indexes
    Options      map[string]string // Additional index options
    SecondaryEngineAttribute *string // JSON text as written; compared as JSON
}
Table Options

TableOptions holds every table option SHOW CREATE TABLE reports. An option MySQL treats as unset (STATS_PERSISTENT=DEFAULT, KEY_BLOCK_SIZE=0, SECONDARY_ENGINE_ATTRIBUTE='', ROW_FORMAT=DEFAULT, ...) parses as nil/false, and Diff emits that same value to clear an option the target no longer declares. ROW_FORMAT (and with it KEY_BLOCK_SIZE) is only compared under IgnoreRowFormat: false; the clearing ROW_FORMAT=DEFAULT rebuilds the table, as every row format change does, and fires once. A table KEY_BLOCK_SIZE change on a table that stays compressed does not converge: MySQL keeps the old size on every existing index and reports it there. Compressed tables are out of scope (Spirit targets Aurora, which does not support them), so the diff does not re-create the indexes for it. If a follow-up diff finds the old size on the primary key, it returns an unsupported-primary-key-options error and no statements. An option-only change to a primary key's KEY_BLOCK_SIZE or SECONDARY_ENGINE_ATTRIBUTE is likewise rejected: MySQL ignores it in a combined DROP PRIMARY KEY, ADD PRIMARY KEY, and a primary key cannot use the temporary name swap used for secondary indexes. Primary key comments still use the combined statement, which MySQL applies.

type TableOptions struct {
    Engine, Charset, Collation, Comment, RowFormat *string
    AutoIncrement    *uint64
    KeyBlockSize     *uint64 // compressed page size; diffed with ROW_FORMAT (IgnoreRowFormat)
    AutoextendSize   *uint64 // bytes: AUTOEXTEND_SIZE=4M parses as 4194304
    StatsPersistent  *bool
    StatsAutoRecalc  *bool
    StatsSamplePages *uint64
    PackKeys         *bool
    Checksum         bool
    DelayKeyWrite    bool
    AvgRowLength, MinRows, MaxRows *uint64
    SecondaryEngineAttribute *string // JSON text as written; compared as JSON
}

Example:

idx := ct.Indexes.ByName("") // parsed PRIMARY KEY indexes have an empty name
// idx.Type = "PRIMARY KEY"
// idx.Columns = ["id"]

idx = ct.Indexes.ByName("email")
// idx.Type = "UNIQUE"
// idx.Columns = ["email"]
Constraint Information

Each Constraint represents CHECK or FOREIGN KEY constraints:

type Constraint struct {
    Raw         *ast.Constraint      // Raw AST node from parser
    Name        string
    Type        string                // "CHECK", "FOREIGN KEY"
    Columns     []string
    Expression  *string               // For CHECK constraints
    References  *ForeignKeyReference  // For FOREIGN KEY constraints
    Definition  *string               // Full constraint definition
    NotEnforced bool                  // For CHECK constraints: true when NOT ENFORCED
    Options     map[string]any        // Additional constraint options
}

A ForeignKeyReference records the referenced Schema when the reference is qualified (REFERENCES db.parent) and leaves it empty otherwise. MySQL qualifies a reference in SHOW CREATE TABLE only when the parent is in another schema, and a reference qualified with the table's own schema reads back unqualified, so an empty Schema means the table's own schema — which a parsed CREATE TABLE does not know. Two references therefore differ on schema only when both are qualified and name different schemas; a desired REFERENCES db2.parent is not told apart from a live REFERENCES parent.

Partition Information

For partitioned tables, PartitionOptions provides:

type PartitionOptions struct {
    Type         string                // "RANGE", "LIST", "HASH", "KEY"
    Expression   *string               // For HASH, RANGE and LIST
    Columns      []string              // For KEY, RANGE COLUMNS, LIST COLUMNS
    Linear       bool
    KeyAlgorithm uint64                // For KEY: 1, or 0 for MySQL's default (2)
    Partitions   uint64
    Definitions  []PartitionDefinition
    SubPartition *SubPartitionOptions
}

Partitioning is compared as a whole, and a difference is emitted as one clause:

Change Clause
HASH/KEY partition count only ADD PARTITION PARTITIONS n / COALESCE PARTITION n
RANGE/LIST partitions appended after the existing ones ADD PARTITION (...) (in-place, metadata-only)
A contiguous run of RANGE/LIST partitions split, merged, renamed or re-bounded, when the run keeps its outer RANGE bound or its set of LIST values REORGANIZE PARTITION ... INTO (...)
Anything else (type, expression, subpartitioning, a shrunk range, a dropped LIST value) a complete PARTITION BY, which replaces the existing partitioning
Partitioning removed REMOVE PARTITIONING

ADD PARTITION, COALESCE PARTITION and REORGANIZE PARTITION can't share an ALTER TABLE with other clauses. When columns, indexes or table options change too, an ADD PARTITION (...) append is emitted as a second statement (it is still metadata-only), unless a column the partitioning reads changes (directly, or through a generated column it reads, followed transitively): the first statement could then convert a stored value past the last existing partition before the second adds the new one. That case, and the other standalone clauses, become a PARTITION BY in the same statement. PARTITION BY and REMOVE PARTITIONING go last in that statement, separated by a space: MySQL rejects them after a comma.

DROP PARTITION is never emitted, because it deletes the partition's rows. A LIST REORGANIZE that leaves out a value deletes the rows holding it without an error, so it is only emitted when the value set is unchanged and every value in it is a constant. A value left as an expression (see partitionBoundConstantNormalizer below) disqualifies the run even when its text is unchanged: UNIX_TIMESTAMP reads the session time zone, so the same text can name a different value when the REORGANIZE runs. Otherwise the change is a PARTITION BY, which fails with error 1526 if a row has no partition to go to.

A PARTITION BY carries the SUBPARTITION BY clause, partition and subpartition comments and storage options (DATA DIRECTORY, INDEX DIRECTORY, MAX_ROWS, MIN_ROWS, TABLESPACE, NODEGROUP), and any explicitly named subpartitions; ADD PARTITION and REORGANIZE PARTITION carry the same per-partition options. InnoDB rejects INDEX DIRECTORY (error 1031) and any tablespace other than innodb_file_per_table (error 1478); they are compared and emitted anyway, so a desired schema that sets one fails rather than being silently dropped. The per-partition ENGINE clause is the one thing deliberately not compared: MySQL requires every partition to use the table's engine, so it carries no information, yet SHOW CREATE TABLE always prints it while authored SQL does not.

Statement Planning

Diff folds every change into one ALTER TABLE where MySQL lets it, with the clauses in the order columns (DROP, then ADD/MODIFY in target order), indexes, constraints, table options, partitioning. The exceptions are the changes MySQL rejects or silently ignores in that shape. Each is planned so that the emitted statements apply in order and a second diff against the result is empty:

Change Plan Why
A column changing to or from a VIRTUAL generated column (VIRTUAL ↔ STORED, regular → VIRTUAL) DROP COLUMN and ADD COLUMN in the same ALTER, at the column's target position MySQL refuses the MODIFY (error 3106, "Changing the STORED status"). Nothing is lost: a generated column holds no data of its own and MySQL recomputes it. Regular ↔ STORED stays a MODIFY
A VIRTUAL generated column becoming a regular column Two statements: the first rebuilds it as a STORED generated column with its old expression (the DROP+ADD above, with whatever reads it); the second is the ordinary diff from that intermediate table, in which the column is a MODIFY that carries its type and attribute changes, next to everything else the diff emits A DROP+ADD of the regular column would leave it NULL, because a VIRTUAL column has no data for the added column to inherit. MySQL fills a STORED column from its expression when it is added, and keeps the values of a STORED column that a MODIFY turns into a regular one, even in the same ALTER as the DROP of a column the expression read
A functional index or CHECK constraint that reads a rebuilt column DROP INDEX/DROP CHECK and the matching ADD in the same ALTER, even when the definition is unchanged Either blocks the DROP COLUMN (errors 3837 and 3959) unless the same statement drops it. An index that names the column as a plain key part survives the rebuild on its own. A generated column that reads a rebuilt column is rebuilt with it (error 3108), unless the target makes it a regular column: then it is a MODIFY in the same ALTER, which keeps a STORED column's values. A foreign key on a rebuilt column is left alone, so MySQL's error 1828 surfaces rather than a referential constraint being dropped
An SRID attribute added, removed or changed on a column with a spatial index DROP INDEX as a statement of its own before the primary ALTER; the MODIFY COLUMN and the ADD SPATIAL INDEX from the target share the primary ALTER MySQL refuses the SRID change while the index exists, even when the same ALTER drops it (error 3644). Any other change to the column is a plain MODIFY
An index whose column list is unchanged but whose WITH PARSER, KEY_BLOCK_SIZE or SECONDARY_ENGINE_ATTRIBUTE differs A swap after the primary ALTER: ADD INDEX under a temporary name (_<name>_new, numbered when that is taken) and DROP INDEX of the old one, every swapped index in one statement (a FULLTEXT one in a statement of its own, error 1795), then a final statement that RENAME INDEXes every replacement back MySQL pairs a same-name, same-columns DROP+ADD in one ALTER and keeps the old index, ignoring the option change. Adding the replacement before dropping the old index is what gets the change through where a standalone DROP INDEX is refused: the only index on an AUTO_INCREMENT column (error 1075) or the index a foreign key depends on (error 1553). A run that stops between the two statements leaves the index under the temporary name; the next diff drops it and adds the target's. A primary key with one of these option changes returns an unsupported-change error and no statements: a same-column DROP PRIMARY KEY, ADD PRIMARY KEY ignores the option, and a primary key cannot take a temporary name
More than one FULLTEXT index added (or rebuilt) The first ADD FULLTEXT INDEX stays in the primary ALTER; each further one is a statement of its own after it InnoDB builds one FULLTEXT index per ALTER TABLE (error 1795)
A foreign key whose definition changes under the same name, or a name differing only in case Re-created under a replacement name in the primary ALTER: DROP FOREIGN KEY of the old constraint and ADD CONSTRAINT of the new definition as _<name>_new (numbered when that is taken), which it keeps. A foreign key changed under a new name fits the primary ALTER under its own MySQL rejects a same-name DROP+ADD FOREIGN KEY in one ALTER (error 1826, "Duplicate foreign key constraint name"), and foreign key names are case-insensitive and unique per schema. Dropping in one statement and re-adding in the next would leave the child table unconstrained in between: a row with no parent inserted then is accepted, and the re-add fails on it. Moving the new definition to its own name afterwards would be a second ADD FOREIGN KEY, a table copy when foreign_key_checks is on, for a name the diff does not compare: a foreign key is paired by definition whatever its name, so the next diff finds the table converged, and a later change under the desired name fits one ALTER under the two names and brings the name back. The replacement name avoids every index and constraint name on either side (MySQL names the index it creates for a foreign key after the constraint); one that collides with a foreign key of another table in the schema fails the ALTER with error 1826, and nothing has changed
ADD PARTITION, COALESCE PARTITION, REORGANIZE PARTITION alongside other changes see the previous section MySQL does not accept them next to other clauses
Matching tables by name

DeclarativeToImperative pairs current and desired tables by name and refuses a schema that names the same table twice. Names match exactly unless DiffOptions.LowerCaseTableNames is nonzero. Set it to the target server's @@lower_case_table_names: on a server where it is 1 or 2, orders and Orders are the same table, and with the option at its default of 0 a case-only rename plans a CREATE the server refuses and a DROP of the live table. With it set, the two match, the ALTER names the table as it exists, and a foreign key reference that differs from the stored one only in case is not re-created. spirit diff sets it from --source-dsn.

Normalization

MySQL rewrites many constructs when it stores a table definition, so the form a human writes rarely matches what SHOW CREATE TABLE reports. Left unhandled, this produces spurious diffs — a schema file that says active BOOLEAN would appear to differ from the live active tinyint(1), and a diff would emit a pointless MODIFY COLUMN. To prevent this, ParseCreateTable runs a pipeline of normalization rules over the parsed CreateTable before returning it, canonicalizing both sides so Diff compares like with like.

Normalization decides what is compared, not what is emitted. A literal DEFAULT is emitted as the schema spelled it (Column.DefaultAsWritten), while the rules rewrite Column.Default to the value MySQL stores for it. MySQL reads the written literal in a MODIFY COLUMN exactly as it did in the CREATE TABLE, so the emitted DDL stores the same value, where the text SHOW CREATE TABLE reports would not: it prints a float with six significant digits, so a MODIFY that carried the live '1234570' would store a different value than the declared 1234567. A reading is also never allowed to establish an equality the literals do not have. A rule reads a literal exactly or not at all: a float is compared by its exact stored value, not by the six digits SHOW CREATE TABLE prints (under which 1234567 and 1234568 would be one default), and a temporal fraction that MySQL rounds under the default sql_mode but truncates under TIME_TRUNCATE_FRACTIONAL is left as written rather than read under an assumed mode. A literal left as written keeps diffing against the live table — the safe direction: the same MODIFY is emitted again rather than a change missed. The one rule that rewrites the emitted form on purpose is charUTF8MB4DefaultNormalizer, whose _utf8mb4 x'…' form is what keeps a live hex reading correct on a column of another charset.

Charset introducers in stored expressions (generated columns, CHECKs, functional indexes, expression defaults, partition expressions) follow the same principle. MySQL keeps a literal's introducer in the expression it stores, and an introducer can change the value even of an ASCII literal: UPPER(_latin5'i') is 'İ' where UPPER('i') is 'I', and STRCMP(_latin1'a', _latin1'a ') is 0 where the bare pair is -1. Spirit does not evaluate expressions, so restoreExprText keeps every introducer as written except _utf8mb4, which a bare literal parses to and which MySQL writes on every bare literal stored from a utf8mb4 session. Two consequences, both in the safe direction: an expression stored from a session of another charset reports that charset's introducer (_latin1 from the mysql client's default, _utf8mb3 from an older client), so a schema spelling the literal bare diffs once, and the emitted statement, stored from Spirit's utf8mb4 session, makes the two agree; and an expression MySQL rewrites on storage, such as CONCAT(_latin1'x', 'y') stored as concat(_utf8mb4'x',_utf8mb4'y'), never converges until the schema spells what MySQL stores. The parser spells _utf8mb3 as _utf8, an alias MySQL accepts.

Two layers of canonicalization apply:

  1. The parser already folds most type aliases before Spirit sees them: BOOL/BOOLEAN → tinyint(1), SERIAL → bigint unsigned NOT NULL AUTO_INCREMENT UNIQUE, INTEGER → int, DEC → decimal. Nothing in Spirit is needed for these. NCHAR/NVARCHAR are an exception: the parser folds them to char/varchar but drops the utf8mb3 character set MySQL stores them with (#1299).

  2. Spirit's normalization rules handle the canonicalizations the parser does not — each mirrors something MySQL does when storing the table:

    Rule (normalize_*.go) Canonicalization
    primaryKeyNormalizer inline id INT PRIMARY KEY → table-level PRIMARY KEY index
    primaryKeyNotNullNormalizer marks every primary key column NOT NULL, as MySQL stores it: a INT, PRIMARY KEY (a) → a int NOT NULL. The promotion is implicit only: a key column that explicitly declares NULL or DEFAULT NULL, which MySQL refuses to create (error 1171), stays nullable, and Diff and DeclarativeToImperative reject a target schema in that state
    autoIncrementNotNullNormalizer marks an AUTO_INCREMENT column NOT NULL and drops its DEFAULT NULL, as MySQL stores it: id INT AUTO_INCREMENT, id INT NULL AUTO_INCREMENT and id INT AUTO_INCREMENT DEFAULT NULL are all reported as id int NOT NULL AUTO_INCREMENT, and without the rule each diffed to a MODIFY COLUMN ... NULL AUTO_INCREMENT (a full copy) on every run. The implication is positional, as MySQL applies the attributes in order: a NULL written after the AUTO_INCREMENT (id INT AUTO_INCREMENT NULL) keeps the column nullable, and formatColumn writes such a column as AUTO_INCREMENT NULL so MySQL stores it that way. A nullable AUTO_INCREMENT column cannot converge, though: SHOW CREATE TABLE reports it as int AUTO_INCREMENT, which as CREATE TABLE input means NOT NULL, so the live column reads back NOT NULL. A primary key column is left to primaryKeyNotNullNormalizer, since MySQL rejects an explicit NULL there in any position (error 1171)
    indexNormalizer inline c INT UNIQUE → table-level UNIQUE KEY; assigns MySQL's default names to unnamed indexes
    indexDefaultsNormalizer drops the index options MySQL accepts and does not store, so a definition that spells them out matches the SHOW CREATE TABLE that omits them: VISIBLE on any index (including PRIMARY KEY (id) VISIBLE, which used to emit an ALTER INDEX with an empty name); an index KEY_BLOCK_SIZE equal to the table's own, which SHOW CREATE TABLE omits on any engine; on InnoDB (named or default engine) USING HASH, which InnoDB builds as a B-tree and does not report (an explicit USING BTREE is reported and kept), and an index KEY_BLOCK_SIZE on a table that is not compressed (ROW_FORMAT=COMPRESSED or a table-level KEY_BLOCK_SIZE), which InnoDB drops on creation. Other engines keep their options
    columnCheckNormalizer hoists every column-level CHECK into a table-level constraint, keeping its name and NOT ENFORCED: c INT CHECK (c > 0) CHECK (c < 10) is two constraints
    expressionParenNormalizer rewrites expression-DEFAULT, CHECK, generated-column, functional-index, partition and subpartition expressions into a canonical parenthesization, keeping only the parentheses the expression's own precedence does not already imply: MySQL stores them fully parenthesized and the parser preserves input parens verbatim, so CHECK ((a=1) OR ((b=2) AND (c=3))) and CHECK (a=1 OR b=2 AND c=3) both canonicalize to the latter, the KEY k (((c + 1))) MySQL reports for a functional index matches the authored KEY k ((c+1)) instead of being dropped and re-added on every run, and the DEFAULT (-(1)) MySQL reports for an expression default matches the authored DEFAULT (-1) instead of emitting a MODIFY COLUMN on every run. It also drops every unary plus, which MySQL discards when it parses the expression (CHECK (c > +1) is stored as CHECK ((c > 1)), DEFAULT (+1) as DEFAULT (1)); an expression default that this leaves a bare literal takes the literal's kind, so DEFAULT (+'1') compares equal to the DEFAULT (_utf8mb4'1') MySQL reports. A string-literal expression default (DEFAULT ('{}')) holds a value, not an expression, and is left alone. Only a nested AND or OR is regrouped (a AND (b AND c) and (a AND b) AND c both canonicalize to a AND b AND c, which is also how MySQL stores them); every other operator keeps the written grouping, because it decides the value — the bitwise &, | and ^ operate on binary strings when both operands are binary strings and on integers otherwise, so _binary'12' & (_binary'21' & 7) is 4 where (_binary'12' & _binary'21') & 7 is 0
    functionAliasNormalizer rewrites a function name to the one MySQL stores, in expression DEFAULTs, generated columns, CHECKs, functional indexes and partition expressions: STRING_TO_VECTOR → to_vector, LCASE → lower, SUBSTRING/MID → substr, DAY → dayofmonth, and the timestamp family inside an expression default → now()
    columnReferenceCaseNormalizer respells a column reference in a generated column, CHECK, functional index, partition or subpartition expression to the case the column is declared in, as MySQL reports it: g INT AS (C + 1) over a column c reads back as ((c + 1)), and without the rule diffed to a MODIFY COLUMN (a full copy) on every run; a functional index to a DROP+ADD, a partition expression to a PARTITION BY. MySQL keeps the written case in a CHECK at CREATE TABLE but re-renders it in declared case after a later ALTER, so both sides are respelled. A name that is not a declared column is left as written. Expression DEFAULTs cannot reference a column
    timestampFspZeroNormalizer drops an explicit fractional-seconds precision of 0 from the timestamp functions (CURRENT_TIMESTAMP, NOW, LOCALTIME, LOCALTIMESTAMP, CURTIME, CURRENT_TIME, UTC_TIMESTAMP, UTC_TIME, SYSDATE), in a literal DEFAULT, ON UPDATE and expression DEFAULTs, as MySQL stores them: DEFAULT CURRENT_TIMESTAMP(0) → DEFAULT CURRENT_TIMESTAMP, DEFAULT (NOW(0) + INTERVAL 1 DAY) → DEFAULT ((now() + interval 1 day)). A non-zero fsp is kept
    binaryAttributeNormalizer resolves the legacy BINARY column attribute to the column charset's _bin collation. A COLLATE in the same definition is overridden when the column declares no charset of its own, and kept when it does (as every NCHAR/NVARCHAR does)
    defaultCollationNormalizer fills in the charset's default collation on a column or table that declares a charset without a COLLATE, for every charset whose default is fixed: CHARACTER SET latin1 → latin1_swedish_ci, ascii → ascii_general_ci, utf8mb3 (including NCHAR/NVARCHAR, which always use it) → utf8mb3_general_ci. SHOW CREATE TABLE writes the collation out on a column whose charset was declared, so without this the two sides disagree. The table default is filled too, so DEFAULT CHARSET=latin1 means latin1_swedish_ci and a table on latin1_bin is converged onto it. utf8mb4 is excluded, because its default depends on the server version and default_collation_for_utf8mb4: MySQL gives a table declared DEFAULT CHARSET=utf8mb4 without a COLLATE the server's default_collation_for_utf8mb4, whatever the schema default is, and that variable accepts only utf8mb4_0900_ai_ci and utf8mb4_general_ci. Such a table therefore matches those two collations and no others: against a table on another collation (such as utf8mb4_bin), the diff emits DEFAULT CHARSET=utf8mb4 alone, which MySQL resolves to the variable's value, and MODIFYs each inheriting column in the same ALTER. A MODIFY of an inheriting column in that ALTER is written with CHARACTER SET utf8mb4, because MySQL resolves a MODIFY without a charset there to utf8mb4_0900_ai_ci even when the variable is utf8mb4_general_ci. A table with no charset clause inherits the schema default, which can be any collation, so it stays underdetermined. A column that names CHARACTER SET utf8mb4 without a COLLATE also takes the server's default_collation_for_utf8mb4, not the table's collation, so whatever the table default is it matches the same two collations and no others. A column with the BINARY attribute is left to binaryAttributeNormalizer, which selects the _bin collation. The binary charset needs nothing here: the parser gives every type that declares CHARACTER SET binary the binary collation, including enum/set, on which SHOW CREATE TABLE writes it out
    binaryCharsetNormalizer rewrites a character column whose charset resolves to binary to its binary type, as MySQL stores it: varchar → varbinary, char → binary, text → blob (and the other *text sizes). Covers a column that inherits a DEFAULT CHARSET=binary or DEFAULT COLLATE=binary table default and one that writes COLLATE binary; the parser already handles an explicit CHARACTER SET binary. enum/set keep their type, and a column that declares its own charset or collation is not rewritten. The table default is canonicalized to DEFAULT CHARSET=binary with no COLLATE, as SHOW CREATE TABLE reports it
    textBlobLengthNormalizer resolves a TEXT(M)/BLOB(M) column to the type MySQL stores: the smallest of tiny/plain/medium/long that holds M bytes (255, 65535, 16777215), where M is bytes for BLOB and characters at the charset's maximum bytes per character for TEXT: blob(100) → tinyblob, text(0) → tinytext, text(64) on utf8mb4 → text, text(100) CHARACTER SET latin1 → tinytext. A table that names no charset takes the database default, so its TEXT(M) is rewritten only when every charset (1 to 4 bytes per character) gives the same size. Otherwise the column keeps its written length (Column.Length) and is emitted as text(M), so MySQL resolves the size at the charset the column gets; a plain text would narrow text(20000) on a utf8mb4 schema (stored as mediumtext) or widen text(64) on a latin1 one (stored as tinytext). Diff compares such a column at the other side's charset, the only side that determines it: text(20000) matches a utf8mb4 mediumtext and differs from a utf8mb4 text, with or without IgnoreCharsetCollation (which stops charset differences being diffed but not a MODIFY storing the column at a real charset). Two such columns match when their lengths give the same size at every width: text(20000) and text(20001)
    binaryDefaultBytesNormalizer rewrites the literal DEFAULT of a binary(N) or varbinary(N) column to the bytes MySQL stores. Every literal form is stored as bytes: a string, TRUE/FALSE, an integer of any size (as its decimal digits, so +007 is '7'), a hex literal and a bit literal (which stores one byte per 8 digits written). On binary(N) the bytes are right-padded with NULs to the column width: binary(3) DEFAULT 'a' is reported as DEFAULT 'a\0\0'; varbinary(4) DEFAULT x'61' is reported as DEFAULT 'a'. The value is recorded as a string, or as a hex literal when its bytes are not valid utf8mb3, since SHOW CREATE TABLE then reports it as hex (binary(3) DEFAULT x'ff' → DEFAULT 0xFF0000). Also covers a char/varchar column that the binary charset makes binary/varbinary. Left alone: binary(0), whose only default '' has nothing to pad (a width-less binary is binary(1) and is padded), binary wider than 255 and a default longer than the width (MySQL rejects both), decimal and float literals (numericDefaultNormalizer formats those and pads them on binary), and expression defaults
    charDefaultSpacesNormalizer rewrites the string DEFAULT of a char(N) or varchar(N) column to the value SHOW CREATE TABLE reports. A char value is read back with every trailing space stripped, in every charset and collation (NO PAD collations included): char(4) DEFAULT 'a ' is reported as DEFAULT 'a', DEFAULT ' ' as DEFAULT ''. Only U+0020 is stripped; a tab or NUL is data. A varchar default keeps its trailing spaces, except spaces past the column width, which MySQL drops (varchar(4) DEFAULT 'ab ' → DEFAULT 'ab '). Stripping both sides also converges a reading taken under the PAD_CHAR_TO_FULL_LENGTH sql_mode, which reports the char default padded to the width. A hex or bit literal default is read as the string its bytes form wherever charBinaryLiteralDefaultNormalizer reads it as one, then handled the same way (char(4) DEFAULT x'612020' → DEFAULT 'a'), so the two rules agree in either order. Left alone: a char/varchar whose charset resolves to binary (spaces are data there; see binaryDefaultBytesNormalizer), varchar spaces past the width unless the charset resolves to utf8mb4/utf8mb3/latin1/ascii (MySQL rejects them in utf16/utf32/ucs2, and a charset the statement does not determine is inherited from the schema, which may be one of those), other whitespace past the width, and expression defaults
    integerBinaryLiteralDefaultNormalizer rewrites a hex or bit literal DEFAULT on an integer or unscaled decimal column to the integer MySQL stores, which reads the literal's bytes as a big-endian unsigned integer: int DEFAULT 0x1A is reported as DEFAULT '26', int DEFAULT b'1010' as DEFAULT '10'. Left alone: an empty literal and one longer than 8 bytes (MySQL rejects both), a value of 2^63 or more on decimal (MySQL rejects it as hex, but not in decimal), expression defaults, and scaled decimal/year/float/double, which store the value in their own form (decimal(5,2) DEFAULT 0x1A is '26.00', year DEFAULT 0x07 is '2007'; numericDefaultNormalizer folds the first three, yearDefaultNormalizer the last)
    zerofillDefaultNormalizer rewrites the literal DEFAULT of a ZEROFILL integer column to the value MySQL stores, left-padded with zeros to the display width: int(10) zerofill DEFAULT 5 is reported as DEFAULT '0000000005'. A width that is unwritten or 0 is the type's unsigned default width (int 10, bigint 20, ...). Every literal form is converted first: hex and bit literals, TRUE/FALSE, decimals and strings (rounded half up, exactly), and floats (the double's exact value, rounded half to even). A string skips leading spaces and tabs and ignores trailing whitespace. A negative value is converted only where MySQL accepts it: a decimal that is exactly zero (-0.0), or a float or string that rounds to zero (-0.5e0, '-0.49'). A value longer than the width is not truncated. Left alone: expression defaults, other negative values, a string that is not a number, and ZEROFILL on decimal/float/double
    numericDefaultNormalizer rewrites a literal DEFAULT to the value MySQL stores for it on the column's type, in the text SHOW CREATE TABLE reports, so decimal(6,2) DEFAULT 1.2 (reported as DEFAULT '1.20') stops emitting a MODIFY COLUMN that stores '1.20' again on every run. Integer types round to an integer — half away from zero for a decimal literal or a string (int DEFAULT 2.5 → '3', '001' → '1', ' 7 ' → '7'), half to even for a float literal (2.5e0 → '2') — within the type's range. decimal(M,D) rounds half away from zero to D places and pads (1.235 → '1.24', 1 → '1.00', TRUE → '1.00', 0x1A → '26.00'; a float literal through its shortest round-trip text, so 0.1e0 is '0.10' at any scale) within M−D integer digits. double stores the nearest binary value, read as MySQL prints a double: the shortest round-trip text, fixed notation up to 15 integer digits and down to 14 leading zeros, exponent notation past that in MySQL's spelling (1e2 → '100', 1.0E-7 → '0.0000001', 1e15 → '1e15', 123456789012345678 → '1.2345678901234568e17'); float by the exact value it stores, printed as a double (0.1 → '0.10000000149011612', 1234567 → '1234567'), not by the six significant digits SHOW CREATE TABLE prints ('0.1', '1234570'), under which 1234567 and 1234568 would compare equal; a float literal whose six-digit text does not read back as the same float (1234567, 1.23456789) therefore keeps diffing until the schema spells one that does (1234570, 1.23457), while 0.1, 1e-45 ('1.4013e-45') and 1e38 converge; float(M,D)/double(M,D) round the fraction in double arithmetic and print D decimals (double(10,3) DEFAULT 2.0005 → '2.001'). char/varchar/binary/varbinary store a numeric literal's canonical text (-007 → '-7', 1.50 → '1.50', +.5 → '0.5', 5. → '5', 1.5E+2 → '150'; binary padded with NULs) when it fits the width. A string on a numeric column skips leading spaces and tabs and trailing whitespace. Left alone: expression defaults, NULL, anything MySQL rejects (not a number, out of range, a negative value on an unsigned column, longer than a string width), ZEROFILL columns, and year/bit/enum/set and the temporal types, which have rules of their own (yearDefaultNormalizer, bitDefaultNormalizer, enumSetDefaultNormalizer, temporalDefaultNormalizer)
    temporalDefaultNormalizer rewrites a literal DEFAULT on a date, datetime, timestamp or time column to the value MySQL stores, in the text SHOW CREATE TABLE reports — 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS' or '[-]HH:MM:SS' with exactly the column's fractional digits — so datetime DEFAULT '2020-1-1' (reported as DEFAULT '2020-01-01 00:00:00') stops emitting a MODIFY COLUMN on every run. Reads every spelling MySQL reads unambiguously: a date without a time, fields of any width, a two-digit year (1970-2069), any punctuation between fields and a T, spaces or punctuation between the date and the time, a trailing separator, the compact YYMMDD/YYYYMMDD/YYMMDDHHMMSS/YYYYMMDDHHMMSS strings, and a number (20200101, 101 → '2000-01-01 00:00:00'); for time, '[-][D ]H:M[:S]', a right-aligned digit string and a number ('1 2:3:4.5' → '26:03:05', 100 → '00:01:00'). A fraction past the column's precision is read only where MySQL's two ways of shortening it agree: the default sql_mode rounds it half up in MySQL's two steps (the digit past the microseconds first — the seventh digit of a datetime string, the last digit of a time string) and carries through the seconds and the date, while TIME_TRUNCATE_FRACTIONAL truncates it, and the rule cannot see the session of the CREATE; so datetime(3) DEFAULT '2020-01-01 10:00:00.1234' → '2020-01-01 10:00:00.123' and time(1) DEFAULT 1.54 → '00:00:01.5' are read, while '2020-01-01 23:59:59.9' ('2020-01-02 00:00:00' under one mode, '2020-01-01 23:59:59' under the other) and 1.55 are left as written; a date keeps its date when nothing carries into it; a negative zero time drops its sign. The value is recorded as a string, the form SHOW CREATE TABLE reports. Left alone: anything MySQL rejects (an invalid date, a zero month or day, a field out of range, a carry past 9999-12-31 or 838:59:59), a time-zone suffix (converted to the session time zone), a float literal (read through a double), the spellings MySQL reads by rules not worth reproducing (a compact digit string of another width, a thirteen-digit number, a time string starting with a colon, with a date part ('1999-12-29 12:00' stores '12:00:00') or long enough to be read as a datetime first), hex and bit literals, TRUE/FALSE, expression defaults and NULL. A timestamp default is also converted through the session time zones of the CREATE and of the SHOW CREATE TABLE, which no rule can undo. A literal left as written keeps diffing against the live table on every run; the MODIFY carries it as written, so what it stores is what the CREATE stored under the session's own mode. Every carry into year 0000 is left as written too: MySQL does not carry it but stores the zero date (datetime DEFAULT '0000-12-09 23:59:59.5' → '0000-00-00 00:00:00', and '0000-12-09 23:59:59' under TIME_TRUNCATE_FRACTIONAL)
    bitDefaultNormalizer rewrites the literal DEFAULT of a bit(N) column to the bit literal MySQL reports: the stored value in binary with no leading zeros. Covers hex literals (bit(8) DEFAULT x'61' → b'1100001'), bit literals, non-negative integers (bit(1) DEFAULT 0 → b'0') and strings, which are read as their bytes (bit(8) DEFAULT '0' → b'110000'). Left alone: a value that does not fit in N bits, an empty hex or bit literal, more than 8 bytes and negative integers (MySQL rejects all of them), decimals and floats (MySQL rounds them), TRUE/FALSE (folded by booleanKeywordDefaultNormalizer), and expression defaults
    charBinaryLiteralDefaultNormalizer rewrites a hex or bit literal DEFAULT on a char/varchar column to the string its bytes spell: varchar(4) DEFAULT 0x61 is reported as DEFAULT 'a'. Only where the bytes are reported unchanged: on utf8mb4/utf8mb3 when they are valid utf8mb3 (otherwise MySQL reports hex, which the written literal already matches, or rejects them), and, when every byte is ASCII, on the charsets that map those bytes unchanged (asciiCompatibleCharsets: latin1, ascii, gbk, sjis, and the others measured). Left alone: a column whose charset the statement does not determine (it takes the database default, which can be utf16: char(2) DEFAULT x'6162' there is U+6162), other charsets (utf16 and the other wide charsets pad, and swe7 maps x'5b' to Ä), bytes above 0x7F outside UTF-8 (latin1 transcodes them), a value longer than the width, expression defaults, enum/set, and a column the binary charset makes binary/varbinary (handled by binaryDefaultBytesNormalizer)
    charUTF8MB4DefaultNormalizer records a DEFAULT on a utf8mb4 char/varchar column that is not valid utf8mb3 as the bytes SHOW CREATE TABLE reports (MySQL 8.0.33+): char(4) DEFAULT '😀' → DEFAULT 0xF09F9880. Covers strings and hex/bit literals. A char default's trailing spaces are stripped first, and a varchar default's spaces past the width are truncated, as MySQL stores them. The value is recorded, and a live hex default on such a column rewritten, as _utf8mb4 x'f09f9880': the introducer makes the emitted MODIFY store the character on whatever charset the column has (a bare hex literal on a utf16 column, reachable with IgnoreCharsetCollation, stores different characters). Left alone: other charsets (utf16, utf32 and gb18030 report the bytes of their own encoding), a column whose charset the table does not determine, a charset introducer other than _utf8mb4/_binary (MySQL reads the bytes in that charset: _latin1'😀' is '😀'), enum/set (whose non-utf8mb3 members are reported as '?'), a value longer than the width, and expression defaults
    enumSetDefaultNormalizer rewrites the string DEFAULT of an enum or set column to the member text SHOW CREATE TABLE reports. MySQL stores the default as the member(s) it names: it strips the trailing spaces (U+0020 only, NO PAD collations included), then looks the rest up under the column's collation, so enum('a','b') DEFAULT 'b ' is reported as DEFAULT 'b' and enum('a','B') DEFAULT 'b' on utf8mb4_0900_ai_ci as DEFAULT 'B'. A set default is split on commas after the strip, and its members are reported once each in definition order (set('a','b') DEFAULT 'b,a ' → DEFAULT 'a,b'). The rule resolves only a member equal byte for byte, or, on a collation known to fold ASCII case (utf8mb4_0900_ai_ci/_as_ci, latin1_swedish_ci, and the _general_ci, _general_mysql500_ci, _unicode_ci and _unicode_520_ci families except cp866_general_ci and latin7_general_ci), a member that differs only in the case of ASCII letters; MySQL rejects collation-duplicate members, so that member is the one it stores. Tailored collations are not folded: their contractions (Danish aa, Czech ch, ...) and Turkish dotless i compare ASCII case pairs unequal. The legacy BINARY attribute selects _bin, which strips spaces but does not fold. Left alone: a column whose charset is binary (spaces are data), a column whose charset and collation the definition does not determine (the database default may be binary or case-sensitive), a set default of only spaces (MySQL rejects it), a set default naming the empty member (reported the same as the empty set), a default MySQL matches through its collation alone (an accent, a non-ASCII case pair, an ignorable character, a contraction), numeric, TRUE/FALSE, hex and bit literal defaults, and expression defaults
    enumSetMemberSpacesNormalizer strips the trailing spaces (U+0020 only) from each member of an enum or set column whose charset is not binary, as MySQL does when it stores the column: enum('a','b ') → enum('a','b'). Under the binary charset — the column's own CHARACTER SET binary or COLLATE binary, or a DEFAULT CHARSET=binary/DEFAULT COLLATE=binary table default — the spaces are data and are kept, so a MODIFY COLUMN emitted for the column writes the member MySQL stores. The parser keeps every member as written, because the grammar rule for the type cannot see a COLLATE or the table default. Left alone: a column whose charset the definition does not determine (no charset or collation of its own and no table default), since the database default it inherits may be binary. Diff decides such a column's members under the table default the emitted ALTER runs under: the target's when the ALTER sets it, otherwise the source's (as with a schema file that omits DEFAULT CHARSET, or under IgnoreCharsetCollation)
    integerDisplayWidthNormalizer strips deprecated integer display widths (int(11) → int), keeping signed tinyint(1) and ZEROFILL (tinyint(1) unsigned → tinyint unsigned, as MySQL stores it). A ZEROFILL column declared without a width, or with a width of 0, gets the type's default unsigned width, as MySQL stores it (int zerofill and int(0) zerofill → int(10) unsigned zerofill, where the parser fills in the signed int(11) for the width-less form); any other explicit width, such as int(11) zerofill, is kept
    decimalZeroPrecisionNormalizer rewrites a zero-precision DECIMAL to the default MySQL stores for it (decimal(0), decimal(0,0) → decimal(10,0))
    booleanKeywordDefaultNormalizer folds a bare TRUE/FALSE keyword DEFAULT to the 1/0 MySQL stores, and records the literal form Spirit emits it in: a bare number on a numeric column (DEFAULT 0), a quoted string on a string one (DEFAULT '0'), a bit literal on a bit one (DEFAULT b'1'). SHOW CREATE TABLE quotes the value on numeric and string columns alike, and only bit reports a literal of its own — but quotedness is not part of column identity on a numeric column, so a folded bare 0 and the live '0' compare equal. Folds on the integer types, unscaled decimal, double/float, varchar/char, varbinary and bit. Left alone where the keyword is stored as something else: scaled decimal pads to its scale (folded by numericDefaultNormalizer), YEAR reads it as a year (folded by yearDefaultNormalizer), binary pads to the column width with NULs (folded by binaryDefaultBytesNormalizer), and enum/set resolve the keyword differently across supported server versions
    vectorDimensionNormalizer fills in the default dimension of a VECTOR column declared without one (vector → vector(2048), MySQL 9.7+)
    yearDisplayWidthNormalizer drops the deprecated display width from YEAR: year(4) → year, as MySQL stores it (YEAR(4) is the only width MySQL 8.0 accepts)
    yearDefaultNormalizer rewrites a literal DEFAULT on a YEAR column to the four-digit year MySQL stores: 1-69 → 2001-2069 and 70-99 → 1970-1999, from a number, a digit-only string, a hex or bit literal, or TRUE/FALSE (year DEFAULT 99 is reported as DEFAULT '1999'). Zero depends on the form: a number (0, FALSE, 0x00) and the four-character string '0000' store '0000', any other string zero ('0', '00') stores '2000'. A number's sign and leading zeros are dropped first (+5 → '2005', -0 → '0000'). Left alone: values MySQL rejects (100-1900, above 2155, negative), fractions and exponents (MySQL rounds them first), strings with whitespace or a sign, and expression defaults
    charsetlessTypeNormalizer drops charset/collation from the types that cannot carry one (VECTOR, spatial) — both the parser's synthetic binary charset and one an author wrote by hand, which MySQL accepts and silently discards
    partitionBoundConstantNormalizer folds a constant expression in a VALUES LESS THAN/VALUES IN value into the integer MySQL stores: 10+10 → 20, 7 DIV 2 * 100 → 300, MOD(20, 7) → 6, TO_DAYS('2030-01-01') → 741443, and likewise TO_SECONDS and YEAR of a 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' literal. Integer arithmetic follows MySQL's per-operator typing (MOD takes the dividend's type, the others are unsigned if either operand is), and is folded only when the default sql_mode and NO_UNSIGNED_SUBTRACTION give the same value; TestPartitionBoundConstantFoldingMatchesMySQL checks this against the server. Left alone, and emitted as written for MySQL to evaluate or reject: anything MySQL rejects (an out-of-range intermediate result, division by zero, a DECIMAL result such as -(-5)), /, a datetime with a fractional second (MySQL rounds it first), other date formats, and UNIX_TIMESTAMP, whose result depends on the session time zone. The valid ones apply but do not converge: the live table stores the integer, so every later diff against it emits a REORGANIZE or PARTITION BY again, and spirit runs each as a full table copy. Write such a bound as the integer it evaluates to
    partitionListValueOrderNormalizer sorts each VALUES IN list, whose order has no meaning: NULL, then numbers by value, then string literals, then unfolded expressions by text; a LIST COLUMNS tuple sorts element by element. MySQL keeps the written order (LIST (expr) moves NULL first), so without this a desired schema listing values in another order re-emits a REORGANIZE on every run. A foldable expression sorts by its folded value, so the order does not depend on partitionBoundConstantNormalizer
    partitionOptionsNormalizer rewrites partition and subpartition options to the form SHOW CREATE TABLE prints: a partition's options (COMMENT included) move onto each of its named subpartitions that does not set its own; an empty COMMENT, MAX_ROWS = 0 and MIN_ROWS = 0 are dropped (NODEGROUP = 0 is kept, as MySQL prints it); TABLESPACE = innodb_file_per_table is dropped, because MySQL prints it on every partition once one sets it and REORGANIZE cannot remove it (it only has an effect with innodb_file_per_table=OFF, and the live definition cannot show where); trailing slashes are dropped from DATA DIRECTORY/INDEX DIRECTORY, which MySQL prints with or without one depending on the statement that set it
    partitionKeyAlgorithmNormalizer folds KEY ALGORITHM=2, MySQL's default, into the omitted form SHOW CREATE TABLE prints, on partitioning and subpartitioning. ALGORITHM=1 is a different row placement and is kept
    partitionCommentNormalizer pushes a partition-level COMMENT down onto explicitly named subpartitions that have none, and clears it from the partition — what MySQL stores for PARTITION p0 ... COMMENT 'c' (SUBPARTITION s0, SUBPARTITION s1). A partition comment on implicit subpartitions (SUBPARTITIONS n) stays on the partition
Pipeline

Rules implement the Normalizer interface (normalize.go):

type Normalizer interface {
    Name() string
    Normalize(ct *CreateTable) *CreateTable
}
  • Each rule lives in its own normalize_<name>.go file and self-registers via init() calling registerNormalizer(...) — the same registration pattern pkg/lint uses for linters, so a new rule is added by dropping in a file with no change to Diff or the parser.
  • runNormalizers applies every registered rule at the tail of parseToStruct, after all fields are populated. Rules therefore see the whole struct and are order-independent.
  • Rules rewrite the structured fields of CreateTable (Columns, Indexes, …), never Raw. Code that reads Column.Raw / CreateTable.Raw (e.g. AST Restore, some linters) bypasses normalization.

Because canonicalization happens at parse time, Diff assumes normalized input: two CreateTables obtained from ParseCreateTable are always canonical, so the diff logic compares them structurally without re-deriving equivalences (inline vs. table-level keys, unnamed indexes, etc.). A CreateTable built by hand — without going through ParseCreateTable — is not normalized.

Relationship to spirit fmt

Normalization is an offline, best-effort approximation of what MySQL does: it needs no database and covers the common cases. spirit fmt is the ground-truth canonicalizer — it round-trips a CREATE TABLE through a live MySQL server and reads back SHOW CREATE TABLE, so it captures every transformation, including ones normalization does not implement (e.g. the national character set of NCHAR/NVARCHAR columns, and the expression rewrites that restructure rather than rename — MOD(a,b) → (a % b), INSTR(a,b) → locate(b,a), WEEKOFYEAR(d) → week(d,3)). Use spirit fmt to canonicalize schema files on disk; normalization keeps in-memory parsing and diffing accurate without a server.

Helper Functions

RemoveSecondaryIndexes

Removes regular secondary indexes from a CREATE TABLE statement while preserving PRIMARY KEY, UNIQUE, FULLTEXT, and SPATIAL indexes, plus one regular index if required to support AUTO_INCREMENT. Among supporting regular indexes, it prefers the fewest key parts:

original := `CREATE TABLE t1 (
    id INT PRIMARY KEY,
    email VARCHAR(255) UNIQUE,
    name VARCHAR(100),
    description TEXT,
    INDEX idx_name (name),
    FULLTEXT idx_description (description)
)`

modified, err := statement.RemoveSecondaryIndexes(original)
// Result: CREATE TABLE with PRIMARY KEY, UNIQUE, and FULLTEXT preserved, but without idx_name

What's Preserved:

  • PRIMARY KEY (fundamental to table structure)
  • UNIQUE indexes (enforce data integrity constraints)
  • FULLTEXT and SPATIAL indexes (specialized index types)
  • One regular index leading with AUTO_INCREMENT when no retained PRIMARY or UNIQUE key already supports it

What's Removed:

  • Regular INDEX (non-unique secondary indexes), except the required AUTO_INCREMENT support index

This functionality is used by move tables operations to defer regular index creation until after data is copied, improving copy performance.

For example, PRIMARY KEY(p), KEY wide(id,p), KEY narrow(id) on a table with id INT AUTO_INCREMENT retains narrow and defers wide.

RemoveSecondaryIndexesForComparison removes all regular indexes so schema comparisons can ignore equivalent AUTO_INCREMENT support under different names or with different trailing columns. Its output is for comparison only and may not be executable DDL.

GetMissingSecondaryIndexes

Compares two CREATE TABLE statements and generates ALTER TABLE to add missing indexes:

source := `CREATE TABLE t1 (
    id INT PRIMARY KEY,
    email VARCHAR(255),
    created_at DATETIME,
    INDEX idx_email (email),
    INDEX idx_created (created_at)
)`

target := `CREATE TABLE t1 (
    id INT PRIMARY KEY,
    email VARCHAR(255),
    created_at DATETIME,
    INDEX idx_email (email)
)`

alterStmt, err := statement.GetMissingSecondaryIndexes(source, target, "t1")
// alterStmt = "ALTER TABLE `t1` ADD INDEX `idx_created` (`created_at`)"

This is used in combination with RemoveSecondaryIndexes to re-add secondary indexes in move tables operations.

Usage Examples

Basic Statement Parsing
stmts, err := statement.New("ALTER TABLE users ADD COLUMN age INT")
if err != nil {
    return err
}

for _, stmt := range stmts {
    fmt.Printf("Table: %s\n", stmt.Table)
    fmt.Printf("Alter: %s\n", stmt.Alter)
    
    if stmt.IsAlterTable() {
        // Perform safety checks
        if err := stmt.AlgorithmInplaceConsideredSafe(); err != nil {
            fmt.Println("Requires Spirit migration")
        } else {
            fmt.Println("Can use native INPLACE")
        }
    }
}
Multiple Statements
sql := `
    ALTER TABLE t1 ADD COLUMN c1 INT;
    ALTER TABLE t2 ADD INDEX (c2);
    ALTER TABLE t3 RENAME INDEX old TO new;
`

stmts, err := statement.New(sql)
if err != nil {
    return err
}

// Process each statement
for _, stmt := range stmts {
    fmt.Printf("Processing %s.%s: %s\n", stmt.Schema, stmt.Table, stmt.Alter)
}
CREATE TABLE Analysis
// Get canonical CREATE TABLE from database
var tableName string
var createStmt string
err := db.QueryRow("SHOW CREATE TABLE users").Scan(&tableName, &createStmt)
if err != nil {
    return err
}

// Parse into structured format
ct, err := statement.ParseCreateTable(createStmt)
if err != nil {
    return err
}

// Check for invisible indexes
if ct.Indexes.HasInvisible() {
    fmt.Println("Table has invisible indexes")
}

// Check for foreign keys
if ct.Constraints.HasForeignKeys() {
    fmt.Println("Table has foreign key constraints")
}

// Inline UNIQUE declarations have already become table-level indexes.
for _, idx := range ct.Indexes {
    if idx.Type == "UNIQUE" && len(idx.ColumnList) == 1 && idx.ColumnList[0].Name == "email" {
        fmt.Println("Email column has a single-column UNIQUE index")
    }
}
Safety Validation
stmt := statement.MustNew("ALTER TABLE t1 ADD INDEX (email)")[0]

// Check if safe for INPLACE
if err := stmt.AlgorithmInplaceConsideredSafe(); err != nil {
    switch err {
    case statement.ErrUnsafeForInplace:
        fmt.Println("Requires table rebuild - use Spirit migration")
    case statement.ErrMultipleAlterClauses:
        fmt.Println("Multiple clauses with mixed safety - split into separate ALTERs")
    }
}

// Check for unsupported clauses
if err := stmt.AlterContainsUnsupportedClause(); err != nil {
    fmt.Println("Statement contains ALGORITHM or LOCK clause - remove it")
}

// Check for UNIQUE index
if err := stmt.AlterContainsAddUnique(); err == nil {
    fmt.Println("No UNIQUE index detected")
} else {
    fmt.Println("UNIQUE index detected - may fail if duplicates exist")
}
Table Comparison
// Get source and target CREATE TABLE statements
sourceCreate := getCreateTable(db, "source_table")
targetCreate := getCreateTable(db, "target_table")

// Find missing indexes
alterStmt, err := statement.GetMissingSecondaryIndexes(sourceCreate, targetCreate, "target_table")
if err != nil {
    return err
}

if alterStmt != "" {
    fmt.Println("Need to add indexes:", alterStmt)
    // Execute alterStmt to bring target in sync with source
}

Limitations

  1. Index rewrite options: CREATE INDEX preserves the key type, name and key parts, but does not carry all index options into the rewritten ALTER. Use ALTER TABLE ADD INDEX when specifying options such as visibility, comments, or a parser.

  2. Single Schema: Multi-table operations must use the same schema

  3. Statements must be parseable by pkg/parser: unparseable DDL cannot be migrated. The most commonly occurring scenarios tend to be complex DEFAULT or CHECK expressions; since the parser is part of this repo, fixes land here directly.

  4. FLOAT default precision: SHOW CREATE TABLE and INFORMATION_SCHEMA.COLUMNS.COLUMN_DEFAULT print unscaled FLOAT defaults with only six significant digits. Distinct stored values can therefore have identical metadata. Comparing the declared literal exactly prevents additional rounding, but cannot recover precision already lost by the server: a live default of 1234567 reports 1234570, so a desired default of 1234570 can still produce no diff. Apply that default change explicitly when exact values matter.

  5. Implicit foreign-key indexes: when writing a desired schema by hand, declare each foreign key's supporting index explicitly. Otherwise a later diff can try to drop the implicit index shown by MySQL, and MySQL refuses the drop while the foreign key needs it.

Best Practices

  1. Use SHOW CREATE TABLE for live schemas: Parse the server's definition when comparing against an existing table. Desired schemas can be authored CREATE statements; normalization handles equivalent representations, subject to the limitations above.
  2. Use ALTER TABLE for complete index definitions: Index-statement rewriting is best-effort. ALTER TABLE ADD INDEX preserves index options directly.

See Also

Documentation

Overview

Package statement is a wrapper around the parser with some added functionality.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrNotSupportedStatement   = errors.New("not a supported statement type")
	ErrNotAlterTable           = errors.New("not an ALTER TABLE statement")
	ErrMultipleSchemas         = errors.New("statement attempts to modify tables across multiple schemas")
	ErrNoStatements            = errors.New("could not find any compatible statements to execute")
	ErrMixMatchMultiStatements = errors.New("when performing atomic schema changes, all statements must be of type ALTER TABLE")
	ErrUnsafeForInplace        = errors.New("statement contains operations that are not safe for INPLACE algorithm")
	ErrAlterNoSpecs            = errors.New("ALTER TABLE does not specify any changes to make")
	ErrMultipleAlterClauses    = errors.New("ALTER contains multiple clauses. Combinations of INSTANT and INPLACE operations cannot be detected safely. Consider executing these as separate ALTER statements")
	ErrAlterContainsUnique     = errors.New("ALTER contains adding a unique index")
)

Functions

func ByName

func ByName[T HasName](slice []T, name string) *T

ByName is a generic function that finds an element by name in any slice of types with Name field NOTE: This function assumes that names are unique within the slice! That will be true for "canonical" CREATE TABLE statements as returned by SHOW CREATE TABLE, but may not be true for arbitrary input.

func DefaultCollationForCharset added in v0.17.0

func DefaultCollationForCharset(name string) (cs, collation string, ok bool)

DefaultCollationForCharset returns the charset and the collation MySQL applies to it when no COLLATE is written, and whether cs names a charset the parser knows. Both are spelled the way EffectiveCharsetCollation spells them, so values from the two can be compared directly — callers that need to supply a default for DDL which declares no charset at all should come through here rather than reading the parser's registry themselves.

func DiffCreateTables added in v0.17.0

func DiffCreateTables(table, wantCreate, gotCreate string, opts *DiffOptions) (string, error)

DiffCreateTables compares two CREATE TABLE statements and returns a runnable ALTER TABLE statement describing how they differ, or an empty string if they are equivalent under opts.

The comparison is performed by parsing both statements and diffing the structured form via CreateTable.Diff, so it is insensitive to the textual noise two servers can put in SHOW CREATE TABLE output. What is and isn't compared is controlled by opts; with NewDiffOptions the comparison:

  • ignores AUTO_INCREMENT counter values (instance-specific noise),
  • ignores ENGINE and ROW_FORMAT cosmetic defaults,
  • DOES compare column types, nullability, defaults, and per-column / per-table CHARACTER SET and COLLATE,
  • DOES compare indexes (including the primary key) and constraints.

"want" is the schema treated as the source of truth; "got" is the schema being validated against it. The returned statement describes the transformation that would turn "got" into "want", which is what makes the message actionable. "table" is the real (logical) table name used to build the runnable "ALTER TABLE <table>" prefix, escaped so identifiers containing backticks remain valid — the two CREATE TABLE statements themselves may name tables on different instances, so their names are normalized away before the diff and never compared.

CreateTable.Diff deliberately splits some reconciliations across more than one ALTER — a partition-type change, or an option-only index change that MySQL would no-op if its DROP and ADD shared a statement. Those are emitted as separate semicolon-separated ALTERs, in order, rather than merged into one (which would produce SQL that silently does the wrong thing).

If opts is nil, NewDiffOptions() defaults are used.

func GetMissingSecondaryIndexes added in v0.10.1

func GetMissingSecondaryIndexes(sourceCreateTable, targetCreateTable, tableName string) (string, error)

GetMissingSecondaryIndexes compares two CREATE TABLE statements (source and target) and returns an ALTER TABLE statement that adds any missing secondary indexes. Returns an empty string if no indexes need to be added. Considers UNIQUE, FULLTEXT, SPATIAL, and regular INDEX types. PRIMARY KEY is excluded as it's fundamental to table structure.

func ModifyColumnIsMetadataOnly added in v0.16.0

func ModifyColumnIsMetadataOnly(spec *ast.AlterTableSpec) bool

ModifyColumnIsMetadataOnly returns true if a MODIFY/CHANGE COLUMN spec only changes metadata: a VARCHAR redeclaration that neither reorders the column nor declares NOT NULL. Both of those are accepted by MySQL under ALGORITHM=INPLACE but performed with a full table rebuild.

This is a statement-level judgement: without the current column definition we can't tell a VARCHAR length change from an INT-to-VARCHAR conversion, so a true result means "not provably a rebuild" rather than "provably not".

func NormalizeCharsetName added in v0.18.0

func NormalizeCharsetName(cs string) string

NormalizeCharsetName returns a charset name in the spelling EffectiveCharsetCollation uses: lower case, with the legacy "utf8" spelling of the 3-byte UTF-8 charset folded onto MySQL 8.0's "utf8mb3". The parser keeps the legacy spelling, so a column or table written CHARACTER SET utf8mb3 (or declared NCHAR/NVARCHAR) reports "utf8"; compare names through this function so the two spellings match.

func RemoveSecondaryIndexes added in v0.10.1

func RemoveSecondaryIndexes(createStmt string) (string, error)

RemoveSecondaryIndexes takes a CREATE TABLE statement and returns a modified version without secondary indexes (regular INDEX only). PRIMARY KEY, UNIQUE, and FULLTEXT indexes are preserved, as is an index needed to support AUTO_INCREMENT.

func RemoveSecondaryIndexesForComparison added in v0.18.0

func RemoveSecondaryIndexesForComparison(createStmt string) (string, error)

RemoveSecondaryIndexesForComparison removes every regular index, including AUTO_INCREMENT support. Its output is for schema comparison, not execution.

func SpecOnlyChangesComment added in v0.16.0

func SpecOnlyChangesComment(spec *ast.AlterTableSpec) bool

SpecOnlyChangesComment returns true if every table option in an AlterTableOption spec is a COMMENT change. A table comment change is in-place and metadata-only, but other table options (ENGINE=, ROW_FORMAT=, AUTO_INCREMENT=, ...) can force a table rebuild, so a spec that mixes any of them in is not safe for INPLACE.

Types

type AbstractStatement

type AbstractStatement struct {
	Schema    string // this will be empty unless the table name is fully qualified (ALTER TABLE test.t1 ...)
	Table     string // for statements that affect multiple tables (DROP TABLE t1, t2), only the first is set here!
	Alter     string // may be empty.
	Statement string
	StmtNode  *ast.StmtNode
}

func DeclarativeToImperative added in v0.11.3

func DeclarativeToImperative(current, desired []table.TableSchema, opts *DiffOptions) ([]*AbstractStatement, error)

DeclarativeToImperative compares current and desired schemas and returns the imperative DDL statements (ALTER, CREATE, DROP) needed to transform current into desired.

This is the core of declarative schema management: given two sets of table definitions, compute the minimal set of changes. It is used by spirit's diff subcommand, strata, and GAP.

The returned statements are ordered as CREATE → ALTER → DROP. This ordering is a correctness property: it ensures the output is safe to execute sequentially (e.g. an ALTER that adds a foreign key referencing a newly-created table will run after the CREATE, and a table referenced by a FK won't be dropped before the referencing ALTER runs). Within the CREATE and DROP groups, tables follow their foreign keys: a table is created after the tables its foreign keys reference (MySQL error 1824 otherwise) and dropped before the tables that reference it (error 3730). Tables with no such dependency between them are sorted alphabetically, and ALTERs always are. A reference is matched by table name alone; a reference to a table outside the group, or a table's reference to itself, imposes no order. Two orders the output cannot satisfy are left to MySQL: a cycle of references among new tables (MySQL creates neither table without FOREIGN_KEY_CHECKS=0) and an ALTER that depends on another table's ALTER.

Tables are matched by name the way the target server compares them (see DiffOptions.LowerCaseTableNames). A schema that names the same table twice is refused rather than planned from one of its definitions: the caller's intent is ambiguous, and the definition left out would never be applied.

If opts is nil, NewDiffOptions() defaults are used for table diffs.

func MustNew

func MustNew(statement string) []*AbstractStatement

MustNew is like New but panics if the statement cannot be parsed. It is used by tests.

func New

func New(statement string) ([]*AbstractStatement, error)

func NewWithOptions added in v0.12.0

func NewWithOptions(statement string, opts Options) ([]*AbstractStatement, error)

func (*AbstractStatement) AlgorithmInplaceConsideredSafe

func (a *AbstractStatement) AlgorithmInplaceConsideredSafe() error

AlgorithmInplaceConsideredSafe checks to see if all clauses of an ALTER statement are "safe". We consider an operation to be "safe" if it is "In Place" and "Only Modifies Metadata". See https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html for details. INPLACE DDL is not generally safe for online use in MySQL 8.0, because ADD INDEX can block replicas.

func (*AbstractStatement) AlterContainsAddUnique

func (a *AbstractStatement) AlterContainsAddUnique() error

AlterContainsAddUnique checks to see if any clauses of an ALTER contains add UNIQUE index. We use this to customize the error returned from checksum fails.

func (*AbstractStatement) AlterContainsUnsupportedClause

func (a *AbstractStatement) AlterContainsUnsupportedClause() error

AlterContainsUnsupportedClause checks to see if any clauses of an ALTER statement are unsupported by Spirit. These include clauses like ALGORITHM and LOCK, because they step on the toes of Spirit's own locking and algorithm selection.

func (*AbstractStatement) AlterWithRenamedConstraints added in v0.19.0

func (a *AbstractStatement) AlterWithRenamedConstraints(checkRenames, foreignKeyRenames map[string]string) (string, []string, error)

AlterWithRenamedConstraints returns this ALTER's clauses with its check constraint and foreign key symbols rewritten for a table other than the one the user named: the copy algorithm's _new table, which holds the same constraints under different names because check constraint and foreign key names are unique per schema rather than per table.

checkRenames maps a lower-cased check constraint name on the user's table to the name the same constraint has on the table the ALTER will be applied to. Names in DROP CHECK / DROP CONSTRAINT and ALTER CHECK clauses are translated through it. foreignKeyRenames does the same for the names in DROP FOREIGN KEY clauses. A name that is not in its map is left alone, so MySQL still reports it as missing rather than spirit guessing at what was meant.

A named check constraint being added by this same ALTER under a name it also drops (the "widen this constraint" idiom: DROP CHECK c, ADD CONSTRAINT c CHECK (...)) has its symbol removed, because the user's table still owns that name for as long as it exists, and adding it to a second table in the schema is an error. MySQL generates a name instead - the same outcome the copy algorithm already produces for every check constraint it copies, and the resolution recommended in issue #418. Those names are returned so the caller can report them.

func (*AbstractStatement) AsAlterTable

func (a *AbstractStatement) AsAlterTable() (*ast.AlterTableStmt, bool)

AsAlterTable is a helper function that simply wraps the type case so the caller doesn't have to import the ast package and use the cast syntax

func (*AbstractStatement) CheckConstraintsReferenced added in v0.17.0

func (a *AbstractStatement) CheckConstraintsReferenced() []string

CheckConstraintsReferenced returns the check constraint names this ALTER refers to by name, i.e. the names in its DROP CHECK / DROP CONSTRAINT and ALTER CHECK clauses. It returns nil for a statement that is not an ALTER TABLE, or one that names no check constraints.

Note that MySQL's DROP CONSTRAINT is not specific to check constraints - it also drops a foreign key or a unique constraint of that name - so a name it contributes here is only a candidate. Callers match it against the check constraints that the table actually has.

func (*AbstractStatement) ColumnCollationChange added in v0.18.0

func (a *AbstractStatement) ColumnCollationChange(column string, current, tableDefault CharsetCollation) (change ColumnCollationChange, determined bool, err error)

ColumnCollationChange resolves the collation column compares under once the ALTER TABLE applies, following MySQL's rules:

  • CONVERT TO CHARACTER SET re-collates every column that carries a charset once the statement applies, overriding a collation the same statement declares on the column.
  • A MODIFY or CHANGE COLUMN resolves the redeclared definition the way a CREATE TABLE would, against the table's defaults as the statement leaves them. A redeclaration that omits COLLATE therefore drops a collation the column declared explicitly, and one that omits both CHARACTER SET and COLLATE picks up a default the same statement changes.
  • Any other column keeps the collation it has.

current is what the column compares under now: the zero value for a column that carries no charset, and a charset without a collation when only the charset is known. A column whose charset is not known either cannot be described here, and a caller must not classify it. tableDefault is the table's current default, the zero value when it is not known.

determined is false when whether the collation changes depends on a default the inputs do not carry: CONVERT TO CHARACTER SET DEFAULT uses the schema's default, a redeclaration that inherits the table default needs tableDefault, and naming utf8mb4 without a collation takes the server's default for it — which decides nothing unless the column is under another charset now.

func (*AbstractStatement) ColumnRenameMap added in v0.13.0

func (a *AbstractStatement) ColumnRenameMap() map[string]string

ColumnRenameMap returns a mapping of old column name → new column name for any RENAME COLUMN or CHANGE COLUMN (with a different name) specs in this ALTER TABLE statement. Returns nil if there are no renames or if this is not an ALTER TABLE statement. MySQL column identifiers are case-insensitive, so a case-only change (e.g. foo → FOO) is not considered a rename: the data mapping is unaffected and case-insensitive identity matching handles it. The map keys and values keep the case as typed in the ALTER; consumers must match them against declared column names case-insensitively.

func (*AbstractStatement) ForeignKeysDropped added in v0.19.0

func (a *AbstractStatement) ForeignKeysDropped() []string

ForeignKeysDropped returns the names in this ALTER's DROP FOREIGN KEY clauses.

func (*AbstractStatement) GenericConstraintDrops added in v0.17.0

func (a *AbstractStatement) GenericConstraintDrops() []string

GenericConstraintDrops returns the names in this ALTER's DROP CONSTRAINT clauses - the subset of CheckConstraintsReferenced that does not say which kind of constraint it means. DROP CHECK and ALTER CHECK do say, so they are not returned.

MySQL resolves such a name against the table's CHECK, FOREIGN KEY, UNIQUE and PRIMARY KEY constraints, which are separate namespaces, and refuses the ALTER when more than one of them holds it: "Table has multiple constraints with the name 'x'. Please use constraint specific 'DROP' clause" (error 3939). A caller that resolves the name itself has to reproduce that rather than pick one.

func (*AbstractStatement) IsAlterTable

func (a *AbstractStatement) IsAlterTable() bool

func (*AbstractStatement) IsCreateTable

func (a *AbstractStatement) IsCreateTable() bool

func (*AbstractStatement) IsDropTable added in v0.11.1

func (a *AbstractStatement) IsDropTable() bool

func (*AbstractStatement) IsRenameTable added in v0.11.1

func (a *AbstractStatement) IsRenameTable() bool

func (*AbstractStatement) ParseCreateTable

func (a *AbstractStatement) ParseCreateTable() (*CreateTable, error)

func (*AbstractStatement) SetsAutoIncrement added in v0.18.0

func (a *AbstractStatement) SetsAutoIncrement() bool

SetsAutoIncrement reports whether this ALTER TABLE sets the table's AUTO_INCREMENT counter explicitly (a table option such as AUTO_INCREMENT=N). It returns false for any statement that is not an ALTER TABLE.

func (*AbstractStatement) StoredEnumSetMembers added in v0.18.0

func (a *AbstractStatement) StoredEnumSetMembers(colDef *ast.ColumnDef, tableDefault CharsetCollation) (members []string, determined bool, err error)

StoredEnumSetMembers returns the members MySQL stores for an ENUM or SET column definition in this ALTER TABLE: as written when the column's charset resolves to binary, and with the trailing spaces of each stripped otherwise (see enumSetMemberSpacesNormalizer). The charset resolves the way ColumnCollationChange resolves it, with one difference MySQL makes: CONVERT TO CHARACTER SET does not convert a column that declares the binary charset, so its members keep their spaces.

tableDefault is the table's current default, the zero value when it is not known. determined is false when a member ends in a space and the column's charset is not decided: it inherits a table default that neither tableDefault nor the statement carries, or the statement converts the table with CONVERT TO CHARACTER SET DEFAULT, which takes the schema's default.

func (*AbstractStatement) TrimAlter

func (a *AbstractStatement) TrimAlter() string

type CharsetCollation added in v0.18.0

type CharsetCollation struct {
	Charset, Collation string
}

CharsetCollation is a charset and a collation of it. Collation is empty when only the charset is known: a definition that names utf8mb4 without a collation leaves it to the server's default_collation_for_utf8mb4.

type Classification added in v0.11.1

type Classification struct {
	Type   StatementType
	Table  string // First table referenced (empty for unparseable statements)
	Schema string // Schema if fully qualified (e.g. "test" from "test.t1")
}

Classification holds the result of classifying a single SQL statement.

func Classify added in v0.11.1

func Classify(sql string) ([]Classification, error)

Classify parses one or more SQL statements and returns their classifications. Unlike New(), this accepts any statement type including DML and TRUNCATE.

type Column

type Column struct {
	Raw             *ast.ColumnDef `json:"-"`
	Name            string         `json:"name"`
	Type            string         `json:"type"`
	Length          *int           `json:"length,omitempty"` // nil = no width; 0 is a real width (varchar(0))
	Precision       *int           `json:"precision,omitempty"`
	Scale           *int           `json:"scale,omitempty"`
	Unsigned        *bool          `json:"unsigned,omitempty"`
	Zerofill        *bool          `json:"zerofill,omitempty"`    // ZEROFILL display attribute (implies unsigned)
	EnumValues      []string       `json:"enum_values,omitempty"` // Permitted values for ENUM type
	SetValues       []string       `json:"set_values,omitempty"`  // Permitted values for SET type
	Nullable        bool           `json:"nullable"`
	Default         *string        `json:"default,omitempty"`
	DefaultIsExpr   bool           `json:"default_is_expr,omitempty"`  // true when default is an expression (needs parens), e.g. DEFAULT (json_object())
	DefaultKind     DefaultKind    `json:"default_kind,omitempty"`     // the literal form the default was written as, read off the AST — see DefaultKind
	OnUpdate        *string        `json:"on_update,omitempty"`        // ON UPDATE expression for TIMESTAMP/DATETIME, e.g. "current_timestamp"
	GeneratedExpr   *string        `json:"generated_expr,omitempty"`   // Expression for GENERATED ALWAYS AS (...) columns
	GeneratedStored bool           `json:"generated_stored,omitempty"` // true = STORED, false = VIRTUAL (only meaningful when GeneratedExpr is set)
	// DefaultAsWritten is the literal DEFAULT as the schema spelled it, kept
	// for emission. Default holds the value MySQL stores for that literal, as
	// the normalization rules read it (the text SHOW CREATE TABLE reports
	// where that text is exact), and Diff compares it. The two can name
	// different values: MySQL reports a float with six significant digits, so
	// the live `float DEFAULT 1234567` reads back as '1234570', and a MODIFY
	// that emitted that text would store 1234570. The written literal is the
	// one MySQL reads itself, so emitting it stores exactly what a CREATE
	// TABLE with it would have.
	// Nil for an expression default, which is emitted from Default, and when a
	// rule rewrites the emitted form on purpose (charUTF8MB4DefaultNormalizer).
	// Not compared: see columnFieldsNotCompared.
	DefaultAsWritten *DefaultLiteral `json:"default_as_written,omitempty"`
	Checks           []ColumnCheck   `json:"checks,omitempty"`        // Column-level CHECK constraints, in declaration order; hoisted into Constraints by columnCheckNormalizer
	SRID             *uint32         `json:"srid,omitempty"`          // SRID attribute for spatial columns
	Invisible        bool            `json:"invisible,omitempty"`     // INVISIBLE (MySQL 8.0.23+); VISIBLE is the default and is not recorded
	NotSecondary     bool            `json:"not_secondary,omitempty"` // NOT SECONDARY: excluded from the secondary engine
	ColumnFormat     *string         `json:"column_format,omitempty"` // COLUMN_FORMAT FIXED|DYNAMIC; DEFAULT is not recorded
	Storage          *string         `json:"storage,omitempty"`       // STORAGE DISK|MEMORY; DEFAULT is not recorded
	// SecondaryEngineAttribute is the SECONDARY_ENGINE_ATTRIBUTE JSON text as
	// written. MySQL reports it re-serialized, so it is compared as JSON
	// (engineAttributeEqual) and emitted as written.
	SecondaryEngineAttribute *string           `json:"secondary_engine_attribute,omitempty"`
	AutoInc                  bool              `json:"auto_increment"`
	PrimaryKey               bool              `json:"primary_key"`
	Unique                   bool              `json:"unique"`
	Comment                  *string           `json:"comment,omitempty"`
	Charset                  *string           `json:"charset,omitempty"`
	Collation                *string           `json:"collation,omitempty"`
	Options                  map[string]string `json:"options,omitempty"`
}

Column represents a table column definition

func (*Column) CarriesCharset added in v0.17.0

func (c *Column) CarriesCharset() bool

CarriesCharset reports whether the column's type stores text, and therefore has a charset and collation that participate in comparisons. Numeric, date, binary, JSON and spatial types are excluded: they carry at most a synthetic "binary" charset that is identical for any two columns of the same type.

func (*Column) EffectiveCharsetCollation added in v0.17.0

func (c *Column) EffectiveCharsetCollation(table *CreateTable) (cs, collation string)

EffectiveCharsetCollation returns the charset and collation the column actually compares under, given the table that owns it. It resolves the column's own clauses against the table defaults exactly as MySQL does (see resolvedCharsetCollation), and then fills in the charset's *default* collation when no COLLATE was written anywhere. That last step matters because SHOW CREATE TABLE can omit COLLATE when it is the charset default, so a table spelled `DEFAULT CHARSET=latin1` means latin1_swedish_ci and must compare unequal to one that spells `COLLATE=latin1_bin`.

For utf8mb4 that default is an assumption. A server resolves utf8mb4 named without a collation to its default_collation_for_utf8mb4, which can be utf8mb4_general_ci, while this always answers utf8mb4_0900_ai_ci, MySQL 8.0's default. A caller that refuses a statement on the strength of the answer must not rely on that guess, and uses determinedCharsetCollation instead.

Either return value is "" when the statement does not determine it: a table with no DEFAULT CHARSET at all (only reachable from hand-written DDL, since SHOW CREATE TABLE always emits one) inherits the schema/server default, and a charset this parser does not know has no default collation to look up. Callers must treat "" as "unknown" rather than as a value that can differ.

Names are returned in MySQL 8.0's spelling: the legacy utf8/utf8_* forms are folded onto utf8mb3/utf8mb3_*, so the two spellings of the same charset compare equal.

The diff does not use this: it deliberately treats an unwritten collation as a match (see charsetCollationEqual) so it never emits a MODIFY it cannot prove converged. Only utf8mb4 reaches it that way: every other charset's default collation is fixed, and defaultCollationNormalizer writes it in at parse time. A linter has the opposite bias — it reports a difference it can prove, and stays silent otherwise.

func (Column) GetName

func (c Column) GetName() string

type ColumnCheck added in v0.18.0

type ColumnCheck struct {
	Name        string `json:"name,omitempty"` // the CONSTRAINT name, or "" for MySQL to number it
	Expression  string `json:"expression"`
	NotEnforced bool   `json:"not_enforced,omitempty"`
}

ColumnCheck is a column-level CHECK constraint as written in a column definition: `c INT [CONSTRAINT name] CHECK (expr) [NOT ENFORCED]`. A column can carry any number of them. MySQL stores each as a table-level constraint, which is how SHOW CREATE TABLE reports them, so columnCheckNormalizer moves them to CreateTable.Constraints at parse time and the slice is empty on a parsed CreateTable.

type ColumnCollationChange added in v0.18.0

type ColumnCollationChange struct {
	// Before is what the column compares under now, and After what it
	// compares under once the statement applies. Either is the zero value
	// when the column carries no charset at that point (numeric, temporal,
	// binary string, ...), and either can know its charset but not its
	// collation.
	Before, After CharsetCollation

	// DeclaredAs is the column's name as the statement spells it, or empty
	// when the statement changes the column without naming it — a
	// CONVERT TO CHARACTER SET re-collates every character column.
	DeclaredAs string
}

ColumnCollationChange is how an ALTER TABLE changes the collation one existing column compares under.

func (ColumnCollationChange) Changed added in v0.18.0

func (c ColumnCollationChange) Changed() bool

Changed reports whether the column compares under a different collation once the statement applies, so the same values may sort, and compare equal, differently. Gaining or losing a collation counts: a change between a character type and a binary or non-string type is one. When either side's collation is not known, a change of charset still decides it, since no two charsets share a collation.

type Columns

type Columns []Column

func (Columns) ByName

func (columns Columns) ByName(name string) *Column

type Constraint

type Constraint struct {
	Raw         *ast.Constraint      `json:"-"`
	Name        string               `json:"name"`
	Type        string               `json:"type"` // CHECK, FOREIGN KEY, etc.
	Columns     []string             `json:"columns,omitempty"`
	Expression  *string              `json:"expression,omitempty"`
	References  *ForeignKeyReference `json:"references,omitempty"`
	Definition  *string              `json:"definition,omitempty"`   // Generated definition string for compatibility
	NotEnforced bool                 `json:"not_enforced,omitempty"` // CHECK constraints only: true when NOT ENFORCED
	Options     map[string]any       `json:"options,omitempty"`
}

Constraint represents a table constraint

func (Constraint) GetName

func (c Constraint) GetName() string

type Constraints

type Constraints []Constraint

func (Constraints) ByName

func (constraints Constraints) ByName(name string) *Constraint

func (Constraints) HasForeignKeys

func (constraints Constraints) HasForeignKeys() bool

type CreateTable

type CreateTable struct {
	Raw          *ast.CreateTableStmt `json:"-"`
	TableName    string               `json:"table_name"`
	Temporary    bool                 `json:"temporary"`
	IfNotExists  bool                 `json:"if_not_exists"`
	Columns      Columns              `json:"columns"`
	Indexes      Indexes              `json:"indexes"`
	Constraints  Constraints          `json:"constraints"`
	TableOptions *TableOptions        `json:"table_options,omitempty"`
	Partition    *PartitionOptions    `json:"partition,omitempty"`
}

CreateTable represents a parsed CREATE TABLE statement with structured data

func ParseCreateTable

func ParseCreateTable(sql string) (*CreateTable, error)

ParseCreateTable parses a CREATE TABLE statement and returns an analyzer This function is particularly designed to be used with the output of SHOW CREATE TABLE, which we consider to be the "canonical" form of a CREATE TABLE statement.

Because there's so much variation in the ways a human might write a CREATE TABLE statement, from index names being auto-generated to column attributes being turned into table options, you should consider use of this function on non-canonical CREATE statements to be experimental at best.

Note also that this parser does not attempt to validate the SQL beyond what the underlying parser does. For example, it will not check that a PRIMARY KEY column is NOT NULL, or that column names are unique, or that indexed columns exist.

func (*CreateTable) Diff added in v0.11.0

func (ct *CreateTable) Diff(target *CreateTable, opts *DiffOptions) ([]*AbstractStatement, error)

Diff compares this CreateTable (source) with another CreateTable (target) and returns ALTER TABLE statements needed to transform source into target. Most changes produce a single statement, but some require multiple sequential statements: a spatial index dropped before the primary ALTER changes its column's SRID; an option-only index rebuild, a foreign key added back under the name the primary ALTER drops, or a partition clause that cannot share an ALTER after it. See pkg/statement/README.md, "Statement Planning". Returns nil if the tables are identical. Returns an error if either table declares a column, index, or constraint name more than once, if target has a primary key column that declares NULL, or if target changes primary key options MySQL would ignore in a combined DROP and ADD. If opts is nil, NewDiffOptions() defaults are used.

func (*CreateTable) GetColumns

func (ct *CreateTable) GetColumns() Columns

func (*CreateTable) GetConstraints

func (ct *CreateTable) GetConstraints() Constraints

func (*CreateTable) GetCreateTable

func (ct *CreateTable) GetCreateTable() *CreateTable

func (*CreateTable) GetIndexes

func (ct *CreateTable) GetIndexes() Indexes

func (*CreateTable) GetPartition

func (ct *CreateTable) GetPartition() *PartitionOptions

func (*CreateTable) GetTableName

func (ct *CreateTable) GetTableName() string

func (*CreateTable) GetTableOptions

func (ct *CreateTable) GetTableOptions() map[string]any

func (*CreateTable) TableDefault added in v0.18.0

func (ct *CreateTable) TableDefault() CharsetCollation

TableDefault returns the charset and collation a column declared without either takes in this table. The collation is empty when the definition does not determine it — DEFAULT CHARSET=utf8mb4 alone takes the server's default for it — and both are empty when the definition declares no default at all, which leaves it to the schema. SHOW CREATE TABLE always spells both out.

func (*CreateTable) ToTableInfo added in v0.17.0

func (ct *CreateTable) ToTableInfo(schemaName string) (*table.TableInfo, error)

ToTableInfo builds a connection-less table.TableInfo from the parsed CREATE TABLE, carrying the column types, charsets and collations, the table's default charset and collation, and the primary key columns that Spirit's checks read from table metadata. schemaName names the schema the table lives in; it is only used for error messages and by checks that query MySQL, which cannot run against the returned TableInfo anyway (see table.NewTableInfoFromMeta).

This lets a caller holding a table's DDL — typically its SHOW CREATE TABLE — supply check.Resources.Change.Table without opening a connection.

func (*CreateTable) ToTableSchema added in v0.11.3

func (ct *CreateTable) ToTableSchema() (table.TableSchema, error)

ToTableSchema converts a parsed CreateTable back to a table.TableSchema by restoring the AST to SQL. This is useful when callers have already parsed schemas (e.g. for linting) but need to pass them to DeclarativeToImperative.

type DefaultKind added in v0.18.0

type DefaultKind uint8

DefaultKind is the literal form a column DEFAULT was written as, taken from the parsed AST rather than re-derived from the restored text.

The text alone cannot answer the question. `DEFAULT TRUE`, `DEFAULT 1` and `DEFAULT '1'` all restore to a numeric-looking string, and `DEFAULT b'1'` restores to text that reads like a quoted string but must be emitted bare. The AST distinguishes all of them — the boolean keyword carries mysql.IsBooleanFlag, a string literal is ast.KindString, a bit or hex literal is ast.KindBinaryLiteral — so the kind is recorded here at parse time and every later decision reads it instead of guessing from the characters.

Two things depend on it. Emission needs to know whether to quote (see formatColumnDefinition): a bit literal quoted as a string produces DDL MySQL rejects outright. And the keyword fold needs to know a default was written as the keyword rather than as the value MySQL stores, so it can rewrite it to that value in the form the column's type reports (see [booleanKeywordDefaultNormalizer]).

const (
	// DefaultKindUnknown is a default whose literal form is not modelled here
	// — NULL, a function default such as CURRENT_TIMESTAMP, an expression. Emission falls back to the [needsQuotes] text heuristic, as
	// it did for every kind before this classification existed.
	DefaultKindUnknown DefaultKind = iota
	// DefaultKindNumber is a numeric literal: 0, -1, 1.5, a decimal. MySQL
	// reports these quoted in SHOW CREATE TABLE regardless of how they were
	// written, which is why quotedness carries no meaning on numeric types.
	DefaultKindNumber
	// DefaultKindKeywordBool is the bare TRUE or FALSE keyword. MySQL treats it
	// as an alias for 1/0 and never reports it back, so it only ever appears on
	// the declared side of a diff.
	DefaultKindKeywordBool
	// DefaultKindString is a quoted string literal. The stored value is the raw
	// unescaped text, so it is re-quoted and escaped exactly once on emission —
	// even when it looks like a keyword or a number, since 'TRUE' and '1' are
	// string values rather than the keyword or the integer.
	DefaultKindString
	// DefaultKindBitLiteral is a bit literal such as b'101'. MySQL reports it
	// in its minimal form (b'0101' comes back as b'101', independent of the
	// column's width), which is the form the parser restores, so the recorded
	// text is already canonical and must be emitted bare.
	DefaultKindBitLiteral
	// DefaultKindHexLiteral is a hex literal such as 0x1A or x'1a', which the
	// parser restores as x'1a'. MySQL converts it to whatever the column's
	// type stores (an integer column reports 0x1A as 26), and reports it back
	// as hex only on a binary, varbinary, char or varchar column whose value is
	// not valid utf8mb3. The normalization rules convert it to the reported
	// form on those types, on the integer types, on bit and on year (see
	// [binaryDefaultBytesNormalizer], [integerBinaryLiteralDefaultNormalizer],
	// [bitDefaultNormalizer], [charBinaryLiteralDefaultNormalizer],
	// [yearDefaultNormalizer]). On a
	// utf8mb4 char or varchar column the recorded text carries a _utf8mb4
	// introducer, `_utf8mb4 x'f09f9880'`, whether the default was written as a
	// string or as hex (see [charUTF8MB4DefaultNormalizer]). It must be
	// emitted bare: quoted, it is the string "x'1a'" rather than the byte 0x1a.
	DefaultKindHexLiteral
)

type DefaultLiteral added in v0.18.0

type DefaultLiteral struct {
	Text string      `json:"text"`
	Kind DefaultKind `json:"kind"`
}

DefaultLiteral is a column DEFAULT literal as the schema spelled it: the text in the form the parse records for its kind (a string literal's raw value, any other literal's restored text) and the DefaultKind that tells emission how to write it back. Column.DefaultAsWritten keeps one so that an emitted DEFAULT is the literal the author wrote rather than the reading the normalization rules leave in Column.Default.

type DiffOptions added in v0.11.0

type DiffOptions struct {
	// IgnoreAutoIncrement skips diffing the AUTO_INCREMENT table option
	// (the table-level next-value counter, e.g. `AUTO_INCREMENT=100`).
	// Default: true (via NewDiffOptions).
	IgnoreAutoIncrement bool

	// IgnoreColumnAutoIncrement skips diffing the column-level AUTO_INCREMENT
	// attribute (whether a column carries the AUTO_INCREMENT flag). This is
	// distinct from IgnoreAutoIncrement, which only covers the table-option
	// counter. Default: false (via NewDiffOptions) — for general schema diffing
	// a column gaining or losing AUTO_INCREMENT is a real change. It is enabled
	// by consumers like the move-tables target-state check, where an unsharded
	// source legitimately differs from a sharded target that drops
	// AUTO_INCREMENT in favor of a Vitess sequence: the difference does not
	// affect copy correctness and must not block the move.
	IgnoreColumnAutoIncrement bool

	// IgnoreNotNullRelaxation lets the schema being validated be STRICTER than
	// its reference on nullability, and only stricter: a validated column
	// declared NOT NULL where the reference permits NULL is accepted, while one
	// that permits NULL where the reference is NOT NULL remains a real
	// difference. The option therefore can never quietly accept a schema that
	// lost a NOT NULL the reference had.
	//
	// Default: false (via NewDiffOptions) — for general schema diffing a column
	// gaining or losing NOT NULL is a real change.
	//
	// It is enabled by the move-tables target checks (see
	// check.TargetSchemaDiff), where the reference is the move's SOURCE and
	// the validated schema is its physical TARGET. What that permits is a target
	// column declared NOT NULL where the source still permits NULL — an
	// unsharded source moving into a sharded target whose shard key must be
	// NOT NULL, because a Vitess primary vindex cannot map NULL to a keyspace
	// id.
	//
	// In terms of Diff's own arguments the reference is the parameter and the
	// validated schema is the receiver, because DiffCreateTables diffs
	// got->want. Stating the direction that way inverts it, which is why the
	// wording above and TestDiff_IgnoreNotNullRelaxation both name the two
	// schemas by role instead.
	//
	// That is safe for a move because nullability is metadata, not row bytes:
	// the copy and the checksum compare values, and every column's NULL-ness is
	// compared explicitly (see ColumnMapping.ChecksumExprs, which emits an
	// ISNULL() digit per column). A tightened column whose source data holds no
	// NULLs is therefore identical on both sides, and this option hides nothing
	// about the rows themselves. One that does hold a NULL fails the move
	// instead of being accepted, and fails before the checksum ever runs — see
	// check.TargetSchemaDiff for where and why.
	IgnoreNotNullRelaxation bool

	// IgnoreEngine skips diffing the ENGINE table option.
	// Default: true (via NewDiffOptions).
	IgnoreEngine bool

	// IgnoreCharsetCollation skips diffing CHARSET and COLLATION table options.
	// Default: false (via NewDiffOptions).
	IgnoreCharsetCollation bool

	// IgnorePartitioning skips diffing partition options entirely.
	// Default: false (via NewDiffOptions).
	IgnorePartitioning bool

	// IgnoreRowFormat skips diffing the ROW_FORMAT table option, and with it
	// the table-level KEY_BLOCK_SIZE (the compressed page size, which implies
	// ROW_FORMAT=COMPRESSED and is only valid with it).
	// Default: true (via NewDiffOptions).
	// ROW_FORMAT=DYNAMIC is the InnoDB default in MySQL 8.0+, so differences
	// between an unspecified ROW_FORMAT and an explicit DYNAMIC are cosmetic.
	//
	// When false, a target that names no row format (or names
	// ROW_FORMAT=DEFAULT, which MySQL stores as none) clears a row format the
	// source has with ROW_FORMAT=DEFAULT, so the table returns to the engine
	// default. That is a table rebuild, as every row format change is, and
	// it fires once: the rebuilt table reports no row format either.
	//
	// A KEY_BLOCK_SIZE change on a table that stays compressed emits only
	// the new size, and does not converge: MySQL keeps the old size on every
	// existing index and reports it there. Compressed tables are out of
	// scope (Spirit targets Aurora, which does not support them), so the
	// diff does not re-create the indexes for it; re-creating the primary
	// key would need a DROP PRIMARY KEY, which Spirit refuses.
	// If the follow-up diff finds a primary key whose stored size differs
	// from the target, it returns an unsupported-primary-key-options error
	// and no statements, rather than planning a DROP and ADD MySQL ignores.
	IgnoreRowFormat bool

	// LowerCaseTableNames is the target server's @@lower_case_table_names.
	// When it is nonzero the server compares table names case-insensitively,
	// so DeclarativeToImperative does too: `Orders` in the desired schema is
	// the same table as `orders` in the current one (diffed, not created and
	// dropped), and a desired schema declaring both refuses. When it is 0,
	// `orders` and `Orders` are different tables.
	// Default: 0 (via NewDiffOptions), comparing names exactly.
	LowerCaseTableNames int
}

DiffOptions controls the behavior of the Diff operation.

func NewDiffOptions added in v0.11.0

func NewDiffOptions() *DiffOptions

NewDiffOptions returns DiffOptions with sensible defaults. By default, AUTO_INCREMENT, ENGINE, and ROW_FORMAT differences are ignored.

type ForeignKeyReference

type ForeignKeyReference struct {
	Schema   string   `json:"schema,omitempty"`
	Table    string   `json:"table"`
	Columns  []string `json:"columns"`
	OnDelete *string  `json:"on_delete,omitempty"`
	OnUpdate *string  `json:"on_update,omitempty"`
}

ForeignKeyReference represents a foreign key reference.

Schema is the referenced table's schema when the reference is qualified (REFERENCES db.parent) and empty when it is not. SHOW CREATE TABLE qualifies a reference only when the parent is in another schema, and a reference qualified with the table's own schema reads back unqualified, so an empty Schema means the table's own schema — which a parsed CREATE TABLE does not know. Two references therefore compare equal unless both are qualified and name different schemas (see constraintsEqualIgnoreNameAndEnforcement); a desired REFERENCES db2.parent is not told apart from a live REFERENCES parent, whichever schema that is.

type HasName

type HasName interface {
	GetName() string
}

HasName is a type constraint for types that have a Name field

type Index

type Index struct {
	Name       string        `json:"name"`
	Type       string        `json:"type"`                  // PRIMARY KEY, UNIQUE, INDEX, FULLTEXT, SPATIAL
	Columns    []string      `json:"columns"`               // Deprecated: use ColumnList for full details
	ColumnList []IndexColumn `json:"column_list,omitempty"` // Full column specifications including prefix/expression
	Invisible  *bool         `json:"invisible,omitempty"`
	Using      *string       `json:"using,omitempty"` // BTREE, HASH, RTREE
	Comment    *string       `json:"comment,omitempty"`
	// KeyBlockSize is the index's own KEY_BLOCK_SIZE; nil when none is set or
	// it equals the table's, which SHOW CREATE TABLE omits
	// (indexDefaultsNormalizer).
	KeyBlockSize *uint64           `json:"key_block_size,omitempty"`
	ParserName   *string           `json:"parser_name,omitempty"`
	Options      map[string]string `json:"options,omitempty"`
	// SecondaryEngineAttribute is the index's SECONDARY_ENGINE_ATTRIBUTE JSON
	// text as written; compared as JSON (engineAttributeEqual) because MySQL
	// reports it re-serialized, and emitted as written.
	SecondaryEngineAttribute *string `json:"secondary_engine_attribute,omitempty"`

	// InlineDerived marks a UNIQUE index that indexNormalizer synthesized
	// from an inline column-level UNIQUE (`c INT UNIQUE`). Its name is only a
	// guess at the server-assigned one (the column name, suffixed on collision),
	// so diffIndexes pairs it with an equivalent unique index on the other side
	// by column set even when the names differ, and compares the pair under
	// that side's name (see pairInlineUniqueNames) rather than emitting a
	// spurious DROP+ADD. Not serialized: it is a diff-time hint, not part of
	// the logical schema.
	InlineDerived bool `json:"-"`
}

Index represents an index definition

func (Index) GetName

func (i Index) GetName() string

type IndexColumn added in v0.11.0

type IndexColumn struct {
	Name       string  `json:"name,omitempty"`       // Column name (empty for expression indexes)
	Expression *string `json:"expression,omitempty"` // Expression for functional indexes
	Length     *int    `json:"length,omitempty"`     // Prefix length for string columns
	Desc       bool    `json:"desc,omitempty"`       // Descending key part (MySQL 8.0+), e.g. KEY (a DESC)
}

IndexColumn represents a column or expression in an index

type Indexes

type Indexes []Index

func (Indexes) ByName

func (indexes Indexes) ByName(name string) *Index

func (Indexes) HasInvisible

func (indexes Indexes) HasInvisible() bool

type Normalizer added in v0.16.0

type Normalizer interface {
	// Name identifies the rule, for registry determinism and debugging.
	Name() string
	// Normalize returns ct rewritten to MySQL's canonical form for this rule.
	Normalize(ct *CreateTable) *CreateTable
}

Normalizer applies a single MySQL canonicalization to a parsed CreateTable. A rule takes a CreateTable and returns the normalized CreateTable — it is free to mutate and return the same instance, or to return a new one. It is deliberately a standalone type rather than a method on CreateTable so rules live in their own files and compose as a pipeline.

type Options added in v0.12.0

type Options struct {
	// AllowMixedStatementTypes permits multi-statement input containing different
	// DDL types (e.g., CREATE TABLE + ALTER TABLE). By default, multi-statement
	// input must be all ALTER TABLE statements (required for atomic schema changes).
	// Enable this when using New() to split/parse schema files that may contain
	// a mix of DDL statement types.
	AllowMixedStatementTypes bool
}

Options configures the behavior of statement parsing.

type PartitionDefinition

type PartitionDefinition struct {
	Name    string           `json:"name"`
	Values  *PartitionValues `json:"values,omitempty"` // VALUES LESS THAN or VALUES IN
	Comment *string          `json:"comment,omitempty"`
	Engine  *string          `json:"engine,omitempty"`
	PartitionStorage
	Options       map[string]any           `json:"options,omitempty"` // Options MySQL does not accept on a partition
	SubPartitions []SubPartitionDefinition `json:"subpartitions,omitempty"`
}

PartitionDefinition represents a single partition definition

type PartitionOptions

type PartitionOptions struct {
	Type         string                `json:"type"`                    // RANGE, LIST, HASH, KEY
	Expression   *string               `json:"expression,omitempty"`    // For HASH, RANGE and LIST
	Columns      []string              `json:"columns,omitempty"`       // For KEY, RANGE COLUMNS, LIST COLUMNS
	Linear       bool                  `json:"linear,omitempty"`        // For LINEAR HASH/KEY
	KeyAlgorithm uint64                `json:"key_algorithm,omitempty"` // For KEY: ALGORITHM=1; 0 is MySQL's default (2)
	Partitions   uint64                `json:"partitions,omitempty"`    // Number of partitions
	Definitions  []PartitionDefinition `json:"definitions,omitempty"`   // Individual partition definitions
	SubPartition *SubPartitionOptions  `json:"subpartition,omitempty"`  // Subpartitioning options
}

PartitionOptions represents table partitioning configuration

type PartitionStorage added in v0.18.0

type PartitionStorage struct {
	DataDirectory  *string `json:"data_directory,omitempty"`
	IndexDirectory *string `json:"index_directory,omitempty"` // InnoDB rejects it (error 1031)
	MaxRows        *uint64 `json:"max_rows,omitempty"`
	MinRows        *uint64 `json:"min_rows,omitempty"`
	Tablespace     *string `json:"tablespace,omitempty"`
	Nodegroup      *uint64 `json:"nodegroup,omitempty"`
}

PartitionStorage holds the storage options of a partition or subpartition definition. A partition with named subpartitions holds none: MySQL stores them on each subpartition (see partitionOptionsNormalizer).

type PartitionValues

type PartitionValues struct {
	Type   string `json:"type"`   // "LESS_THAN", "IN", "MAXVALUE"
	Values []any  `json:"values"` // The actual values
}

PartitionValues represents the VALUES clause in partition definitions

type StatementType added in v0.11.1

type StatementType int

StatementType represents the type of a SQL statement.

const (
	StatementUnknown       StatementType = iota
	StatementAlterTable                  // ALTER TABLE ...
	StatementCreateTable                 // CREATE TABLE ...
	StatementDropTable                   // DROP TABLE ...
	StatementRenameTable                 // RENAME TABLE ...
	StatementTruncateTable               // TRUNCATE TABLE ...
	StatementCreateIndex                 // CREATE INDEX ...
	StatementDropIndex                   // DROP INDEX ...
	StatementCreateView                  // CREATE VIEW ...
	StatementInsert                      // INSERT ...
	StatementUpdate                      // UPDATE ...
	StatementDelete                      // DELETE ...
)

func (StatementType) IsDDL added in v0.11.1

func (t StatementType) IsDDL() bool

IsDDL returns true if this is a DDL statement type.

func (StatementType) IsDML added in v0.11.1

func (t StatementType) IsDML() bool

IsDML returns true if this is a DML statement type.

func (StatementType) String added in v0.11.1

func (t StatementType) String() string

String returns the human-readable name for a StatementType.

type SubPartitionDefinition

type SubPartitionDefinition struct {
	Name    string  `json:"name"`
	Comment *string `json:"comment,omitempty"`
	Engine  *string `json:"engine,omitempty"`
	PartitionStorage
	Options map[string]any `json:"options,omitempty"` // Options MySQL does not accept on a subpartition
}

SubPartitionDefinition represents a single subpartition definition

type SubPartitionOptions

type SubPartitionOptions struct {
	Type         string   `json:"type"`                    // HASH, KEY
	Expression   *string  `json:"expression,omitempty"`    // For HASH
	Columns      []string `json:"columns,omitempty"`       // For KEY
	Linear       bool     `json:"linear,omitempty"`        // For LINEAR HASH/KEY
	KeyAlgorithm uint64   `json:"key_algorithm,omitempty"` // For KEY: ALGORITHM=1; 0 is MySQL's default (2)
	Count        uint64   `json:"count,omitempty"`         // Number of subpartitions
}

SubPartitionOptions represents subpartitioning configuration

type TableOptions

type TableOptions struct {
	Engine        *string `json:"engine,omitempty"`
	Charset       *string `json:"charset,omitempty"`
	Collation     *string `json:"collation,omitempty"`
	Comment       *string `json:"comment,omitempty"`
	AutoIncrement *uint64 `json:"auto_increment,omitempty"`
	RowFormat     *string `json:"row_format,omitempty"`
	// KeyBlockSize is the table-level KEY_BLOCK_SIZE, the compressed page
	// size in KiB. It belongs with ROW_FORMAT (it implies COMPRESSED and
	// InnoDB rejects it with any other row format), so Diff treats the two
	// together under DiffOptions.IgnoreRowFormat. 0 means unset.
	KeyBlockSize *uint64 `json:"key_block_size,omitempty"`
	// AutoextendSize is AUTOEXTEND_SIZE in bytes. MySQL accepts it with a
	// K/M/G suffix (4M) and reports it in bytes (4194304). 0 means unset.
	AutoextendSize *uint64 `json:"autoextend_size,omitempty"`
	// The following are reported by SHOW CREATE TABLE only when set; each
	// has a value MySQL treats as "unset" that Diff emits to clear it.
	StatsPersistent  *bool   `json:"stats_persistent,omitempty"`   // STATS_PERSISTENT=0|1; DEFAULT is not recorded
	StatsAutoRecalc  *bool   `json:"stats_auto_recalc,omitempty"`  // STATS_AUTO_RECALC=0|1; DEFAULT is not recorded
	StatsSamplePages *uint64 `json:"stats_sample_pages,omitempty"` // STATS_SAMPLE_PAGES=n; 0 and DEFAULT are not recorded
	PackKeys         *bool   `json:"pack_keys,omitempty"`          // PACK_KEYS=0|1; DEFAULT is not recorded
	Checksum         bool    `json:"checksum,omitempty"`           // CHECKSUM=1
	DelayKeyWrite    bool    `json:"delay_key_write,omitempty"`    // DELAY_KEY_WRITE=1
	AvgRowLength     *uint64 `json:"avg_row_length,omitempty"`     // 0 is not recorded
	MinRows          *uint64 `json:"min_rows,omitempty"`           // 0 is not recorded
	MaxRows          *uint64 `json:"max_rows,omitempty"`           // 0 is not recorded
	// SecondaryEngineAttribute is the table's SECONDARY_ENGINE_ATTRIBUTE JSON
	// text as written; compared as JSON (engineAttributeEqual) because MySQL
	// reports it re-serialized, and emitted as written. ” is not recorded.
	SecondaryEngineAttribute *string `json:"secondary_engine_attribute,omitempty"`
}

TableOptions represents table-level options

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL