306 lines
11 KiB
Go
306 lines
11 KiB
Go
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 <name> ON <table> …
|
|
// CREATE TABLE [IF NOT EXISTS] <table> … / DROP TABLE [IF EXISTS] <t>
|
|
//
|
|
// 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 <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 <table>
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|