package db import ( "context" "database/sql" "errors" "fmt" "regexp" "strings" "time" mysqldriver "github.com/go-sql-driver/mysql" ) // ── SQLite → MySQL schema translation ────────────────────────────────── // // OpsLog's migrations are authored in SQLite dialect (the default backend). // For the shared-MySQL backend we translate that DDL on the fly rather than // maintaining a second set of migration files, so the two backends can never // drift apart. The translation is deliberately narrow — it only rewrites the // SQLite-isms that actually appear in our migrations: // // - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY // - bare INTEGER → BIGINT (so FK child/parent types match) // - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go) // - TEXT → VARCHAR(255), except a few free-text // columns that stay TEXT/LONGTEXT // - reserved column `key` → backticked // - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS) // // REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP // TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys // are all valid MySQL as written. var ( reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`) reBareInteger = regexp.MustCompile(`\bINTEGER\b`) reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`) reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`) ) // MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB). // The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes // each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page, // ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column // actually needs in-row storage: it's indexed, a primary key, or carries a // DEFAULT (TEXT can't hold a literal default in MySQL). // varcharColumns must be VARCHAR because they're indexed or part of a primary // key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic // below can't catch them). MySQL can't index a TEXT column without a prefix // length. Keyed by column name (a name may repeat across tables — harmless). var varcharColumns = map[string]bool{ // qso indexes (0001, 0003, 0019) "callsign": true, "qso_date": true, "band": true, "mode": true, "grid": true, "station_callsign": true, "state": true, "contest_id": true, "sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true, // integrations_udp index (0011) "direction": true, // award_references composite primary key (0017) "award_code": true, "ref_code": true, } // longTextColumns override the default TEXT with a bigger type where the // content can be large (a full QSL-template document). var longTextColumns = map[string]string{ "json": "LONGTEXT", } // mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration // file) into the MySQL dialect. See the package note above for the rules. func mysqlDDL(stmt string) string { s := stmt // MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway). s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX") // Auto-increment primary keys. Both the AUTOINCREMENT form and the bare // "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so // inserts that omit the id still get one. s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY") s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY") // Timestamp defaults: Go supplies ISO strings, so drop the SQLite function // default (TEXT/VARCHAR can't carry it in MySQL anyway). s = reStrftimeDefault.ReplaceAllString(s, "''") // Remaining INTEGER columns → BIGINT so foreign-key parent/child types match. s = reBareInteger.ReplaceAllString(s, "BIGINT") // TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed, // a primary key, or default-bearing — keeping the row under MySQL's limit. s = translateTextColumns(s) // `key` is a MySQL reserved word — backtick the settings column declaration. s = reKeyColumn.ReplaceAllString(s, "$1`key`") return s } // translateTextColumns rewrites each " TEXT" column declaration to the // right MySQL type, deciding per line so it can see whether the line carries a // DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns. func translateTextColumns(s string) string { lines := strings.Split(s, "\n") for i, line := range lines { m := reColText.FindStringSubmatchIndex(line) if m == nil { continue } col := line[m[2]:m[3]] var typ string switch { case longTextColumns[col] != "": typ = longTextColumns[col] case varcharColumns[col], strings.Contains(line, "DEFAULT"), strings.Contains(line, "PRIMARY KEY"): typ = "VARCHAR(255)" default: typ = "TEXT" } lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:] } return strings.Join(lines, "\n") } // OpenMySQL opens the shared MySQL logbook, creating the database if needed, // then applies the (translated) embedded migrations. multiStatements is enabled // so multi-statement migration files run in a single Exec. func OpenMySQL(c MySQLConfig) (*sql.DB, error) { if strings.TrimSpace(c.Host) == "" { return nil, fmt.Errorf("host is required") } name := strings.TrimSpace(c.Database) if !validDBIdent(name) { return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) } // Ensure the database exists (connect server-level first). if err := PingMySQL(c); err != nil { return nil, err } conn, err := sql.Open("mysql", c.dsn()) if err != nil { return nil, fmt.Errorf("open mysql: %w", err) } // The UI fires bursts of concurrent queries (the Preferences dialog alone // loads ~8 settings in parallel, plus the grid and live cluster status). A // low cap turns such a burst into a deadlock-like stall against a remote // server, so keep a generous pool with idle reaping. SQLite ran uncapped. conn.SetMaxOpenConns(50) conn.SetMaxIdleConns(10) conn.SetConnMaxIdleTime(90 * time.Second) conn.SetConnMaxLifetime(5 * time.Minute) if err := conn.Ping(); err != nil { _ = conn.Close() return nil, fmt.Errorf("connect to %s: %w", name, err) } // Set the dialect before migrating so the runner takes the MySQL path // (per-statement, idempotent) rather than the SQLite transaction path. Dialect = "mysql" if err := migrate(conn, mysqlDDL); err != nil { _ = conn.Close() Dialect = "sqlite" // migration failed; we'll fall back to SQLite return nil, err } return conn, nil } // applyMySQLMigration executes a translated migration one statement at a time. // MySQL has no multi-statement Exec without a special flag and auto-commits DDL, // so running statements individually (and tolerating "already exists" errors) // keeps migrations idempotent and lets a half-applied database self-heal. // // It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a // few migrations recreate tables by DROP/RENAME in an order that trips MySQL's // foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the // connection returns to the pool, so runtime queries keep FK enforcement. func applyMySQLMigration(conn *sql.DB, sqlText string) error { ctx := context.Background() c, err := conn.Conn(ctx) if err != nil { return fmt.Errorf("acquire conn: %w", err) } defer func() { _, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1") _ = c.Close() }() if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil { return fmt.Errorf("disable fk checks: %w", err) } for _, stmt := range splitStatements(sqlText) { if _, err := c.ExecContext(ctx, stmt); err != nil { if isIgnorableDDLError(err) { continue } return fmt.Errorf("statement %q: %w", firstLine(stmt), err) } } return nil } // splitStatements breaks a migration file into individual SQL statements, // dropping full-line comments and blank fragments. Our migrations never embed // a ';' inside a string literal, so a simple split is safe. func splitStatements(sqlText string) []string { var b strings.Builder for _, line := range strings.Split(sqlText, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "--") { continue } b.WriteString(line) b.WriteByte('\n') } var out []string for _, part := range strings.Split(b.String(), ";") { if strings.TrimSpace(part) != "" { out = append(out, part) } } return out } func firstLine(s string) string { s = strings.TrimSpace(s) if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } // isIgnorableDDLError reports whether a MySQL error means the object the // statement creates/drops is already in the intended state — safe to skip when // re-applying a forward-only migration. func isIgnorableDDLError(err error) bool { var me *mysqldriver.MySQLError if !errors.As(err, &me) { return false } switch me.Number { case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table 1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table 1060, // ER_DUP_FIELDNAME — ADD COLUMN already present 1061, // ER_DUP_KEYNAME — CREATE INDEX already present 1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist return true } return false }