fix: convert EVERY column in a MySQL baseline, not just the first per line

The quoting fix was necessary but not sufficient: "column 'op_name' can't have a
default value" persisted.

The translation worked line by line and rewrote only the FIRST column on each
line. Our own migrations put one column per line, so nothing showed there — but
sqlite_master stores a table's CREATE statement with every ALTER-added column
appended to the SAME line, and the baseline is built from exactly that. On a
logbook that had been through upgrades, most columns therefore kept their TEXT
type and MySQL rejected the schema.

Two consequences fixed together: every match is now rewritten, and each one is
decided from ITS OWN declaration — the text between the surrounding commas —
rather than from the whole line. Deciding per line would have made every column
sharing a line with a default-bearing one into VARCHAR(255), which breaches the
InnoDB row-size limit the TEXT rule exists to respect.

Tests cover the many-columns-on-one-line shape and pin that a neighbour's DEFAULT
does not leak.
This commit is contained in:
2026-07-29 16:30:44 +02:00
parent ab9d0bfe0f
commit e3aabc06a4
3 changed files with 92 additions and 16 deletions
+45
View File
@@ -205,3 +205,48 @@ func TestMySQLDDL_QuotedTextColumns(t *testing.T) {
}
}
}
// A CREATE TABLE with several columns on ONE line — the shape sqlite_master
// stores once columns have been added by ALTER TABLE.
//
// The old translation converted only the first column per line, so on a real
// logbook the baseline kept most of its columns as TEXT and MySQL refused the
// schema: "column 'op_name' can't have a default value". Our own migration files
// hide this — they put one column per line — which is why it only ever appeared
// against a database that had been through upgrades.
func TestMySQLDDL_ManyColumnsOnOneLine(t *testing.T) {
in := "CREATE TABLE station_profiles (id INTEGER PRIMARY KEY, `name` TEXT, " +
"`op_name` TEXT NOT NULL DEFAULT '', `comment` TEXT, `state` TEXT);"
got := mysqlDDL(in)
// Default-bearing and indexed columns must be VARCHAR…
for _, want := range []string{"`op_name` VARCHAR(255)", "`state` VARCHAR(255)"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n %s", want, got)
}
}
// …while the plain ones stay TEXT, or the row-size limit is breached.
for _, want := range []string{"`name` TEXT", "`comment` TEXT"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n %s", want, got)
}
}
// No column may be left as a bare TEXT that carries a default.
if strings.Contains(got, "TEXT NOT NULL DEFAULT") {
t.Errorf("a TEXT column still carries a DEFAULT — MySQL rejects that:\n %s", got)
}
}
// A neighbour's DEFAULT must not decide this column's type: deciding per LINE
// made every column on a shared line VARCHAR as soon as one of them had a
// default, which is how the row-size limit gets breached quietly.
func TestMySQLDDL_DefaultDoesNotLeakAcrossColumns(t *testing.T) {
in := "CREATE TABLE t (`a` TEXT NOT NULL DEFAULT '', `b` TEXT);"
got := mysqlDDL(in)
if !strings.Contains(got, "`a` VARCHAR(255)") {
t.Errorf("the column WITH the default should be VARCHAR:\n %s", got)
}
if !strings.Contains(got, "`b` TEXT") {
t.Errorf("the column without one should stay TEXT:\n %s", got)
}
}