243 lines
9.4 KiB
Go
243 lines
9.4 KiB
Go
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 "<col> 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
|
|
}
|