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