Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions pkg/ddl/differ_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,183 @@ func TestDiffStatements_ParseError(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "SQL syntax error")
}

// mysqlExpressionTable is what MySQL 8.0 returns from SHOW CREATE TABLE for a
// table declaring a generated column and a range of DEFAULT expressions. It is
// the source side of a plan: whatever a user writes, this is what the differ
// compares against.
const mysqlExpressionTable = "CREATE TABLE `p1` (\n" +
" `id` int NOT NULL,\n" +
" `c` timestamp NULL DEFAULT (now()),\n" +
" `d` timestamp NULL DEFAULT CURRENT_TIMESTAMP,\n" +
" `u` char(36) DEFAULT (uuid()),\n" +
" `j` json DEFAULT (json_array()),\n" +
" `n` int DEFAULT ((1 + 2)),\n" +
" `price` int DEFAULT NULL,\n" +
" `qty` int DEFAULT NULL,\n" +
" `total` int GENERATED ALWAYS AS ((`price` * `qty`)) STORED,\n" +
" PRIMARY KEY (`id`)\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci"

// A converged table plans no work. MySQL preserves the exact parenthesization
// a user wrote around generated column and DEFAULT expressions, and it renders
// nullability and DEFAULT NULL that a hand-written schema file leaves implicit,
// so the differ has to compare the parsed expressions rather than their text.
// Otherwise every plan against such a table would propose an ALTER that changes
// nothing.
func TestDiffTable_ExpressionColumnsConvergedPlanNoChange(t *testing.T) {
d := NewDiffer()

declared := "CREATE TABLE `p1` (\n" +
" `id` int NOT NULL,\n" +
" `c` timestamp DEFAULT (now()),\n" +
" `d` timestamp DEFAULT CURRENT_TIMESTAMP,\n" +
" `u` char(36) DEFAULT (uuid()),\n" +
" `j` json DEFAULT (json_array()),\n" +
" `n` int DEFAULT ((1 + 2)),\n" +
" `price` int,\n" +
" `qty` int,\n" +
" `total` int GENERATED ALWAYS AS ((`price` * `qty`)) STORED,\n" +
" PRIMARY KEY (`id`)\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci"

alters, err := d.DiffTable(mysqlExpressionTable, declared)
require.NoError(t, err)
assert.Empty(t, alters, "converged expression columns must not plan an ALTER")
}

// Redundant parentheses are formatting, not schema. A user who writes the
// expression with or without an outer pair gets the same table, so neither
// spelling may plan an ALTER against the other.
func TestDiffTable_RedundantParenthesesAreNotAChange(t *testing.T) {
d := NewDiffer()

tests := []struct {
name string
source string
target string
}{
{
name: "generated column",
source: "CREATE TABLE t1 (id INT PRIMARY KEY, price INT, qty INT, total INT GENERATED ALWAYS AS ((price * qty)) STORED)",
target: "CREATE TABLE t1 (id INT PRIMARY KEY, price INT, qty INT, total INT GENERATED ALWAYS AS (price * qty) STORED)",
},
{
name: "DEFAULT expression",
source: "CREATE TABLE t1 (id INT PRIMARY KEY, n INT DEFAULT ((1 + 2)))",
target: "CREATE TABLE t1 (id INT PRIMARY KEY, n INT DEFAULT (1 + 2))",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
alters, err := d.DiffTable(tt.source, tt.target)
require.NoError(t, err)
assert.Empty(t, alters)

alters, err = d.DiffTable(tt.target, tt.source)
require.NoError(t, err)
assert.Empty(t, alters)
})
}
}

// A generated column's storage kind is part of its definition: switching
// between STORED and VIRTUAL rewrites the table, so the differ must report it
// rather than treat the two as the same expression.
func TestDiffTable_GeneratedColumnStorageChange(t *testing.T) {
d := NewDiffer()

source := "CREATE TABLE t1 (id INT PRIMARY KEY, a INT, b INT GENERATED ALWAYS AS (a * 2) VIRTUAL)"
target := "CREATE TABLE t1 (id INT PRIMARY KEY, a INT, b INT GENERATED ALWAYS AS (a * 2) STORED)"

alters, err := d.DiffTable(source, target)
require.NoError(t, err)
require.Len(t, alters, 1)
assert.Contains(t, alters[0], "`b`")
assert.Contains(t, alters[0], "STORED")
}

// Changing a generated column's expression is a real schema change.
func TestDiffTable_GeneratedColumnExpressionChange(t *testing.T) {
d := NewDiffer()

source := "CREATE TABLE t1 (id INT PRIMARY KEY, a INT, b INT GENERATED ALWAYS AS (a * 2) STORED)"
target := "CREATE TABLE t1 (id INT PRIMARY KEY, a INT, b INT GENERATED ALWAYS AS (a * 3) STORED)"

alters, err := d.DiffTable(source, target)
require.NoError(t, err)
require.Len(t, alters, 1)
assert.Contains(t, alters[0], "`a`*3")
}

// DEFAULT CURRENT_TIMESTAMP and a DEFAULT expression are different column
// definitions in MySQL, which reports each back exactly as it was declared.
// Treating them as interchangeable would let a schema file drift from the
// table it describes without any plan ever showing it.
func TestDiffTable_ExpressionDefaultDiffersFromCurrentTimestamp(t *testing.T) {
d := NewDiffer()

source := "CREATE TABLE t1 (id INT PRIMARY KEY, c TIMESTAMP NULL DEFAULT (now()))"
target := "CREATE TABLE t1 (id INT PRIMARY KEY, c TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP)"

alters, err := d.DiffTable(source, target)
require.NoError(t, err)
require.Len(t, alters, 1)
assert.Equal(t, "ALTER TABLE `t1` MODIFY COLUMN `c` timestamp NULL DEFAULT current_timestamp", alters[0])
}

// MySQL emits nested parentheses of its own accord for a parenthesized DEFAULT
// expression, so a schema pulled straight from SHOW CREATE TABLE contains them
// and must parse.
func TestDiffTable_NestedParenthesizedDefaultParses(t *testing.T) {
d := NewDiffer()

source := "CREATE TABLE t1 (id INT PRIMARY KEY)"
target := "CREATE TABLE t1 (id INT PRIMARY KEY, n INT DEFAULT ((1 + 2)))"

alters, err := d.DiffTable(source, target)
require.NoError(t, err)
require.Len(t, alters, 1)
assert.Equal(t, "ALTER TABLE `t1` ADD COLUMN `n` int NULL DEFAULT (1+2)", alters[0])
}

// Expression defaults that call a function keep the call syntax when the
// differ renders them, so the ALTER it plans is valid DDL.
func TestDiffTable_FunctionCallDefaultRendersAsACall(t *testing.T) {
d := NewDiffer()

tests := []struct {
name string
column string
expected string
}{
{
name: "uuid",
column: "CHAR(36) DEFAULT (uuid())",
expected: "ALTER TABLE `t1` ADD COLUMN `c` char(36) NULL DEFAULT (uuid())",
},
{
name: "json_array",
column: "JSON DEFAULT (json_array())",
expected: "ALTER TABLE `t1` ADD COLUMN `c` json NULL DEFAULT (json_array())",
},
{
name: "concat",
column: "VARCHAR(10) DEFAULT (concat('a', 'b'))",
expected: "ALTER TABLE `t1` ADD COLUMN `c` varchar(10) NULL DEFAULT (concat('a', 'b'))",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
alters, err := d.DiffTable(
"CREATE TABLE t1 (id INT PRIMARY KEY)",
"CREATE TABLE t1 (id INT PRIMARY KEY, c "+tt.column+")",
)
require.NoError(t, err)
require.Len(t, alters, 1)
assert.Equal(t, tt.expected, alters[0])
})
}
}
25 changes: 25 additions & 0 deletions pkg/ddl/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,31 @@ func TestCanonicalize(t *testing.T) {
input: "DROP TABLE users",
expected: "DROP TABLE `users`",
},
{
name: "generated column keeps its expression",
input: "CREATE TABLE t (a INT, b INT AS (a * 2) STORED)",
expected: "CREATE TABLE `t` (`a` INT,`b` INT GENERATED ALWAYS AS(`a`*2) STORED)",
},
{
name: "generated column keeps redundant parentheses",
input: "CREATE TABLE t (a INT, b INT GENERATED ALWAYS AS ((a * 2)) VIRTUAL)",
expected: "CREATE TABLE `t` (`a` INT,`b` INT GENERATED ALWAYS AS((`a`*2)) VIRTUAL)",
},
{
name: "parenthesized DEFAULT expression stays an expression",
input: "CREATE TABLE t (a INT DEFAULT (1 + 2))",
expected: "CREATE TABLE `t` (`a` INT DEFAULT (1+2))",
},
{
name: "nested parentheses in a DEFAULT expression are preserved",
input: "CREATE TABLE t (a INT DEFAULT ((1 + 2)))",
expected: "CREATE TABLE `t` (`a` INT DEFAULT ((1+2)))",
},
{
name: "function call in a DEFAULT expression keeps its call syntax",
input: "CREATE TABLE t (a CHAR(36) DEFAULT (uuid()))",
expected: "CREATE TABLE `t` (`a` CHAR(36) DEFAULT (UUID()))",
},
{
name: "invalid SQL returns original",
input: "not valid sql",
Expand Down
Loading