Files
OpsLog/internal/db/mysql_test.go
T
rouggy ab9d0bfe0f fix: MySQL schema build failed on quoted column declarations
Two errors from a shared MySQL logbook, both fatal to the schema and neither
naming its 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

One root cause. TEXT columns are rewritten to VARCHAR(255) when they are indexed
or carry a default — that is exactly what those two MySQL rules require — but the
pattern only matched a BARE column name. The SQLite baseline dump quotes its
identifiers, and mysqlDDL turns those quotes into backticks before the TEXT rules
run, so every column coming from the baseline kept its TEXT type and MySQL
rejected the schema.

The operator saw none of this: just "MySQL is enabled but the connection failed
at startup" and a silent fallback to local SQLite — which is the worst shape for
a shared logbook, because each operator then logs into their own copy.

The pattern now accepts a quoted or bare name and puts the quoting back. A test
covers both forms, plus the case that must NOT change: an unindexed column stays
TEXT, since VARCHAR everywhere would break MySQL's row-size limit. Reverting the
one-line change makes it fail.
2026-07-29 16:26:04 +02:00

208 lines
7.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)
}
}
}