package db // Which tables belong in which database. // // OpsLog keeps settings and contacts apart — a settings database (settings.db / // opslog.db) and a logbook (logbook.db, a per-profile file, or a shared MySQL). // The SEPARATION of the data was real from the start; the SCHEMA was not: every // target got the whole migration set, so a shared MySQL logbook grew a settings // table and a station_profiles table that nothing ever wrote to. An operator // inspecting the server with phpMyAdmin had no way to tell which copy was // authoritative — a fair question, and the reason for this file. // // So a migration statement is now filtered by the ROLE of the database it is // being applied to. Only two rules, and both fail safe: // // - RoleAll (the settings database) applies everything, exactly as before. // It has to: a legacy single-file installation holds its QSOs there, and it // still serves as the logbook when no separate one could be created. // - RoleLogbook applies everything EXCEPT statements aimed at a table on the // settings side. A table this file does not know about is kept, in both — // an unrecognised future table behaves as it does today rather than // silently going missing from one database. import ( "database/sql" "fmt" "strings" ) // Role is what a database is for. type Role int const ( // RoleAll applies every migration statement. The settings database. RoleAll Role = iota // RoleLogbook applies only what the contacts need. RoleLogbook ) // settingsTables are owned by the settings database. Every one of them is // reached through the settings connection in app.go — grep NewRepo/NewStore // there: only qso.NewRepo is given the logbook connection. // // Ordered children-before-parents, because pruneForeignTables drops them in // this order and operating_antennas has a foreign key into operating_stations. var settingsTables = []string{ "operating_antenna_bands", "operating_antennas", "operating_antennas_new", "operating_stations", "operating_stations_new", "award_references", "qsl_templates", "cluster_servers", "integrations_udp", "callsign_cache", "station_profiles", "settings", } // keepForRole reports whether one migration statement applies to this role. func keepForRole(stmt string, role Role) bool { if role == RoleAll { return true } t := stmtTable(stmt) if t == "" { return true // not a table statement (PRAGMA, or a shape we don't parse) } for _, s := range settingsTables { if t == s { return false } } return true } // stmtTable returns the lower-cased table a statement acts on, or "". // // Deliberately literal: it recognises the handful of statement shapes the // migrations actually use, and returns "" for anything else — which keeps the // statement. Guessing would be the only way to drop something by accident. func stmtTable(stmt string) string { f := strings.Fields(strings.ToLower(stmt)) at := func(i int) string { if i < len(f) { return f[i] } return "" } // Skip the leading keywords that carry no table name. switch { case at(0) == "create" || at(0) == "drop": // CREATE [UNIQUE] INDEX ON … // CREATE TABLE [IF NOT EXISTS]
… / DROP TABLE [IF EXISTS] // // The "ON" search is confined to an INDEX statement on purpose: a CREATE // TABLE body is full of "ON DELETE CASCADE", and scanning the whole // statement for "on" once made every foreign-keyed table report itself as // a table named "delete" — unrecognised, therefore kept, therefore created // in a logbook that had no use for it. if at(1) == "index" || (at(1) == "unique" && at(2) == "index") { for i, w := range f { if w == "on" && i+1 < len(f) { return cleanIdent(f[i+1]) } } } i := 1 for at(i) == "unique" || at(i) == "table" || at(i) == "index" || at(i) == "if" || at(i) == "not" || at(i) == "exists" || at(i) == "view" { i++ } return cleanIdent(at(i)) case at(0) == "alter": return cleanIdent(at(2)) // ALTER TABLE
… case at(0) == "insert" || at(0) == "replace": i := 1 for at(i) == "or" || at(i) == "ignore" || at(i) == "into" || at(i) == "replace" { i++ } return cleanIdent(at(i)) case at(0) == "update": return cleanIdent(at(1)) case at(0) == "delete": return cleanIdent(at(2)) // DELETE FROM
} return "" } // cleanIdent strips quoting and anything glued to the name — "qso(id)" from an // index, `settings` from the MySQL translation, "qso;" from a split statement. func cleanIdent(s string) string { s = strings.TrimSpace(s) if i := strings.IndexAny(s, "(;"); i >= 0 { s = s[:i] } return strings.Trim(s, "`\"'[]") } // pruneForeignTables removes settings tables from a logbook database that an // earlier version created there. // // ONLY WHEN EMPTY, without exception. A table with rows in it is data, whatever // this file thinks it is for: a profile pointing at a legacy combined database // as its logbook is a real configuration, and dropping its profiles because the // schema now says they belong elsewhere would be destroying a log-keeping // operator's work on the strength of a tidiness rule. // // Best effort throughout: a logbook the user cannot drop tables in (a restricted // MySQL grant) keeps its empty tables and works exactly as it does today. func pruneForeignTables(conn *sql.DB, role Role, label string) { if role != RoleLogbook || conn == nil { return } // Once per database, recorded like a migration. // // The check itself is a COUNT(*) per settings table — twelve round trips, // which is nothing locally and is paid on EVERY open of a remote MySQL // logbook, including every profile switch. There is nothing to find after the // first pass: the role filter means no settings table is ever created in a // logbook again. if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, prunedMarker); err != nil { return // already recorded (primary key), or the table is not writable } dropped := 0 for _, t := range settingsTables { var n int // A missing table errors here, which is the "nothing to do" answer. if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(t)).Scan(&n); err != nil { continue } if n != 0 { logf("db[%s]: keeping %s — it has %d row(s)", label, t, n) continue } if _, err := conn.Exec(`DROP TABLE ` + quoteIdent(t)); err != nil { logf("db[%s]: could not drop unused %s: %v", label, t, err) continue } dropped++ } if dropped > 0 { logf("db[%s]: dropped %d unused settings table(s) — this database holds contacts only", label, dropped) } } // prunedMarker records, in schema_migrations, that a logbook has had its unused // settings tables removed. Named like a migration and stored beside them because // that is exactly what it is: a one-off schema step, and the applied set is // already read in a single query at every open. const prunedMarker = "_opslog_pruned_settings_tables" // quoteIdent quotes one of OUR OWN table names for either dialect. Backticks // work in MySQL and in SQLite alike, which is why the migrations use them. func quoteIdent(s string) string { return "`" + s + "`" } // DropRedundantSettingsTables removes the settings tables from a logbook // database EVEN IF THEY HAVE ROWS. // // Reserved for the one case where those rows are provably a stale duplicate: // the logbook file was seeded by VACUUM INTO from the old combined database // (see the split at startup), so every profile and every setting in it is a // copy of what the settings database still holds and is authoritative for. // Nothing reads them — the settings connection is a different file entirely — // and leaving them behind is what made the two databases look interchangeable. // // The caller must have established that authority. pruneForeignTables is the // safe default for every other situation. func DropRedundantSettingsTables(conn *sql.DB, label string) { if conn == nil { return } dropped := 0 for _, t := range settingsTables { if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(t)).Scan(new(int)); err != nil { continue // not there: nothing to do } if _, err := conn.Exec(`DROP TABLE ` + quoteIdent(t)); err != nil { logf("db[%s]: could not drop redundant %s: %v", label, t, err) continue } dropped++ } if dropped > 0 { logf("db[%s]: dropped %d settings table(s) copied by the logbook split — the settings database keeps the originals", label, dropped) } } // EnsureQSOTable recreates the contacts table if it is missing. // // It exists so dropping an EMPTY qso table from the settings database is a // reversible act. The settings database can still be pressed into service as the // logbook — that is the fallback when no separate logbook file can be created — // and the migrations that would have built the table are recorded as applied, so // nothing would ever build it again. This does, from the same migrations, on // demand. // // The schema is derived rather than duplicated: the migrations are replayed on a // throwaway in-memory SQLite whose sqlite_master then holds the FINAL shape of // the table with every ALTER-added column folded in. Same trick as the MySQL // baseline, and for the same reason — there is no second schema to drift. // // SQLite only: the shared MySQL logbook is never the settings database. func EnsureQSOTable(conn *sql.DB) error { if conn == nil { return nil } if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(new(int)); err == nil { return nil // already there } stmts, err := qsoSchemaDDL() if err != nil { return err } for _, st := range stmts { if _, err := conn.Exec(st); err != nil { return fmt.Errorf("recreate qso table: %w", err) } } logf("db: recreated the qso table — this database is being used as the logbook") return nil } // qsoSchemaDDL returns the CREATE statements for the qso table and its indexes, // in that order. func qsoSchemaDDL() ([]string, error) { mem, err := sql.Open("sqlite", "file:opslog_qsoschema?mode=memory&cache=shared") if err != nil { return nil, fmt.Errorf("open schema sqlite: %w", err) } defer mem.Close() if err := migrate(mem, nil, "", "qso-schema:memory", RoleLogbook); err != nil { return nil, fmt.Errorf("build qso schema: %w", err) } rows, err := mem.Query(`SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND tbl_name = 'qso'`) if err != nil { return nil, err } defer rows.Close() var tables, indexes []string for rows.Next() { var typ, s string if err := rows.Scan(&typ, &s); err != nil { return nil, err } if typ == "table" { tables = append(tables, s) } else { indexes = append(indexes, s) } } if len(tables) == 0 { return nil, fmt.Errorf("qso table not found in the derived schema") } return append(tables, indexes...), rows.Err() } // DropEmptyQSOTable removes the contacts table from a database that is not the // logbook — the settings database, once its QSOs live in their own file. // // Only when empty, and reversible: EnsureQSOTable builds it again the moment // this database is asked to serve as the logbook. func DropEmptyQSOTable(conn *sql.DB, label string) { if conn == nil { return } var n int if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil { return // not there: nothing to do } if n != 0 { return // contacts: this table is data, whatever the schema says } if _, err := conn.Exec(`DROP TABLE qso`); err != nil { logf("db[%s]: could not drop the unused qso table: %v", label, err) return } logf("db[%s]: dropped the unused qso table — the contacts live in their own database", label) }