From 0cf1fcb0e039fe790847e039ef73be45d3dac5a8 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Mon, 17 Aug 2026 11:30:40 +0800 Subject: [PATCH] test(ddl): cover generated columns and expression defaults The differ had no coverage for the two column shapes MySQL reports back with parentheses of its own: generated columns and parenthesized DEFAULT expressions. Both are compared as parsed expressions rather than text, so a converged table plans no work whichever spelling the schema file uses, while a changed expression, a STORED/VIRTUAL switch, and an expression default that is not DEFAULT CURRENT_TIMESTAMP each still plan an ALTER. Co-Authored-By: Claude Opus 5 --- pkg/ddl/differ_test.go | 180 +++++++++++++++++++++++++++++++++++++++++ pkg/ddl/format_test.go | 25 ++++++ 2 files changed, 205 insertions(+) diff --git a/pkg/ddl/differ_test.go b/pkg/ddl/differ_test.go index 4500d277c..0b9507d51 100644 --- a/pkg/ddl/differ_test.go +++ b/pkg/ddl/differ_test.go @@ -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]) + }) + } +} diff --git a/pkg/ddl/format_test.go b/pkg/ddl/format_test.go index fc996a3e2..c99034d18 100644 --- a/pkg/ddl/format_test.go +++ b/pkg/ddl/format_test.go @@ -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",