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.
253 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|