Files
OpsLog/internal/db/mysql_test.go
T
rouggy e3aabc06a4 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.
2026-07-29 16:30:44 +02:00

253 lines
9.5 KiB
Go

package db
import (
"regexp"
"strings"
"testing"
)
func TestMySQLDDL_Translations(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
// The column-name/type whitespace collapses to a single space — harmless.
{"text default N", "qsl_sent TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
{"indexed col → varchar", "callsign TEXT NOT NULL,", "callsign VARCHAR(255) NOT NULL,"},
{"plain non-indexed col → text", "name TEXT,", "name TEXT,"},
{"plain text stays text", "comment TEXT,", "comment TEXT,"},
{"json longtext", " json TEXT NOT NULL,", " json LONGTEXT NOT NULL,"},
{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
}
for _, c := range cases {
if got := mysqlDDL(c.in); got != c.want {
t.Errorf("%s:\n in %q\n got %q\n want %q", c.name, c.in, got, c.want)
}
}
}
func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
in := "CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);"
got := mysqlDDL(in)
if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
t.Errorf("key column not backticked/translated:\n%s", got)
}
}
func TestSplitStatements(t *testing.T) {
in := "-- a comment\n" +
"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
"ALTER TABLE qso ADD COLUMN b TEXT; -- inline note\n" +
"\n" +
"CREATE INDEX idx ON qso(a);\n"
got := splitStatements(in)
if len(got) != 3 {
t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
}
if !strings.Contains(got[0], "ADD COLUMN a") ||
!strings.Contains(got[1], "ADD COLUMN b") ||
!strings.Contains(got[2], "CREATE INDEX") {
t.Errorf("unexpected split: %#v", got)
}
// No fragment should be a comment-only or blank statement.
for _, s := range got {
if strings.TrimSpace(s) == "" {
t.Errorf("empty statement in result: %#v", got)
}
}
}
// Every embedded migration must split into at least one runnable statement
// and never produce an empty fragment.
func TestSplitStatements_AllMigrations(t *testing.T) {
entries, _ := migrationsFS.ReadDir("migrations")
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
stmts := splitStatements(mysqlDDL(string(raw)))
if len(stmts) == 0 {
t.Errorf("%s: produced no statements", e.Name())
}
for _, s := range stmts {
if strings.TrimSpace(s) == "" {
t.Errorf("%s: empty statement fragment", e.Name())
}
}
}
}
// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
// clear of it. TEXT columns are off-page and don't count here.
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
varchars := 0
for _, name := range qsoMigrations {
raw, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
t.Fatal(err)
}
varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
}
const bytesPerVarchar = 255 * 4 // utf8mb4
rowBytes := varchars * bytesPerVarchar
t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
if rowBytes > 60000 { // leave headroom under the 65535 hard limit
t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
}
}
// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
// fails if any SQLite-only construct survives — a fast guard against a new
// migration sneaking in a dialect-ism the translator doesn't cover.
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
t.Fatal(err)
}
reInteger := regexp.MustCompile(`\bINTEGER\b`)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
t.Fatal(err)
}
out := mysqlDDL(string(raw))
if strings.Contains(out, "AUTOINCREMENT") {
t.Errorf("%s: AUTOINCREMENT survived", e.Name())
}
if strings.Contains(out, "strftime") {
t.Errorf("%s: strftime survived", e.Name())
}
if strings.Contains(out, "IF NOT EXISTS idx") {
t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
}
// Strip comment lines before checking for bare INTEGER (comments are
// prose and may legitimately mention the word).
var code strings.Builder
for _, ln := range strings.Split(out, "\n") {
if strings.HasPrefix(strings.TrimSpace(ln), "--") {
continue
}
code.WriteString(ln)
code.WriteByte('\n')
}
if reInteger.MatchString(code.String()) {
t.Errorf("%s: bare INTEGER survived in code", e.Name())
}
}
}
// A QUOTED column declaration must be translated like a bare one.
//
// The SQLite baseline dump quotes its identifiers, and mysqlDDL turns those
// double quotes into backticks before the TEXT rules run. The pattern only
// matched a bare name, so every column in the baseline stayed TEXT — and MySQL
// then refused the schema in two different ways, neither naming the real cause:
//
// CREATE INDEX … ON qso(state) → 1170 BLOB/TEXT column used in key
// specification without a key length
// op_name TEXT … DEFAULT '' → 1101 TEXT column can't have a default value
//
// The operator saw only "MySQL is enabled but the connection failed at startup",
// and fell back to the local SQLite database.
func TestMySQLDDL_QuotedTextColumns(t *testing.T) {
cases := []struct {
name, in, wantHas string
}{
{
// An indexed column must become VARCHAR, quoted or not.
name: "backticked indexed column",
in: "CREATE TABLE qso (`state` TEXT);",
wantHas: "`state` VARCHAR(255)",
},
{
name: "bare indexed column still works",
in: "ALTER TABLE qso ADD COLUMN state TEXT;",
wantHas: "state VARCHAR(255)",
},
{
// A default-bearing column must become VARCHAR, or MySQL rejects it.
name: "double-quoted column with a default",
in: `CREATE TABLE station_profiles ("op_name" TEXT NOT NULL DEFAULT '');`,
wantHas: "`op_name` VARCHAR(255)",
},
{
// And a plain unindexed column stays TEXT: VARCHAR everywhere would
// blow past MySQL's row-size limit, which is why the rule exists.
name: "unindexed column stays TEXT",
in: "CREATE TABLE qso (`comment` TEXT);",
wantHas: "`comment` TEXT",
},
}
for _, c := range cases {
got := mysqlDDL(c.in)
if !strings.Contains(got, c.wantHas) {
t.Errorf("%s:\n in %s\n got %s\n want it to contain %q", c.name, c.in, got, c.wantHas)
}
// The quoting must survive: a lost backtick is a syntax error further on.
if strings.Count(got, "`")%2 != 0 {
t.Errorf("%s: unbalanced backticks in %s", c.name, got)
}
}
}
// 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)
}
}