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) } }