chore: release v0.26.4
This commit is contained in:
+27
-6
@@ -147,7 +147,17 @@ func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z"
|
||||
|
||||
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||
// enables performance PRAGMAs, and applies embedded migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
//
|
||||
// This is the SETTINGS database, which gets the full schema — see roles.go for
|
||||
// why. Use OpenLogbook for a database that holds contacts only.
|
||||
func Open(path string) (*sql.DB, error) { return open(path, RoleAll) }
|
||||
|
||||
// OpenLogbook opens a SQLite logbook: the qso table and nothing from the
|
||||
// settings side, and any unused settings tables an earlier version left in it
|
||||
// are dropped if they are empty.
|
||||
func OpenLogbook(path string) (*sql.DB, error) { return open(path, RoleLogbook) }
|
||||
|
||||
func open(path string, role Role) (*sql.DB, error) {
|
||||
// Escape only the two characters a path could contain that the DSN would
|
||||
// otherwise read as its query/fragment delimiters. Windows separators
|
||||
// (\\ and the drive ':') are left intact — url.PathEscape would mangle them.
|
||||
@@ -162,10 +172,12 @@ func Open(path string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
Dialect = "sqlite"
|
||||
if err := migrate(conn, nil, path, filepath.Base(path)); err != nil {
|
||||
label := filepath.Base(path)
|
||||
if err := migrate(conn, nil, path, label, role); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
pruneForeignTables(conn, role, label)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -236,7 +248,7 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
// interleaved migration runs in one log were indistinguishable — an operator
|
||||
// reported "migrations are very slow" and the lines gave no way to tell one
|
||||
// database migrated three times from three databases migrated once.
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) error {
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string, role Role) error {
|
||||
// A non-nil translator means this is the MySQL connection (use the
|
||||
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||
// determined by the caller's argument, NOT the global Dialect, so the
|
||||
@@ -331,9 +343,18 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(sqlText); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
// Statement by statement rather than one Exec of the whole file: the role
|
||||
// filter works per statement, and a settings-table statement must not
|
||||
// reach a logbook database (see roles.go). Still one transaction, so the
|
||||
// file remains atomic.
|
||||
for _, stmt := range splitStatements(sqlText) {
|
||||
if !keepForRole(stmt, role) {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||
_ = tx.Rollback()
|
||||
|
||||
@@ -252,7 +252,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
return nil, rerr
|
||||
}
|
||||
// Then apply only the migrations it's missing.
|
||||
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
|
||||
err = migrate(conn, mysqlDDL, "", "mysql:"+name, RoleLogbook)
|
||||
}
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -343,7 +343,9 @@ func applyMySQLBaseline(conn *sql.DB) error {
|
||||
// database. Labelled so its (fast) migration lines are not mistaken for a
|
||||
// real database being migrated — in one operator's log this pass sat between
|
||||
// two slow MySQL runs and looked like a third database.
|
||||
if err := migrate(mem, nil, "", "baseline:memory"); err != nil {
|
||||
// RoleLogbook: the baseline defines what a FRESH shared logbook gets, and a
|
||||
// logbook has no business holding settings or station profiles.
|
||||
if err := migrate(mem, nil, "", "baseline:memory", RoleLogbook); err != nil {
|
||||
return fmt.Errorf("build baseline schema: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStmtTable(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"CREATE TABLE station_profiles (\n id INTEGER PRIMARY KEY)": "station_profiles",
|
||||
"CREATE TABLE IF NOT EXISTS settings (`key` TEXT PRIMARY KEY)": "settings",
|
||||
"CREATE UNIQUE INDEX idx_qso_uid ON qso(sync_uid)": "qso",
|
||||
"CREATE INDEX idx_ref ON award_references (award_code)": "award_references",
|
||||
"ALTER TABLE qso ADD COLUMN ant_path TEXT NOT NULL DEFAULT ''": "qso",
|
||||
"ALTER TABLE `station_profiles` ADD COLUMN my_cq_zone TEXT": "station_profiles",
|
||||
"INSERT INTO settings(`key`, value) VALUES('x','y')": "settings",
|
||||
"INSERT OR IGNORE INTO cluster_servers(name) VALUES('dxc')": "cluster_servers",
|
||||
"UPDATE qso SET callsign = UPPER(callsign)": "qso",
|
||||
"DELETE FROM operating_antennas WHERE station_id IS NULL": "operating_antennas",
|
||||
"DROP TABLE IF EXISTS operating_stations_new": "operating_stations_new",
|
||||
"PRAGMA foreign_keys = off": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := stmtTable(in); got != want {
|
||||
t.Errorf("stmtTable(%.40q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepForRole(t *testing.T) {
|
||||
// The settings database takes everything, exactly as before this existed.
|
||||
for _, s := range []string{"CREATE TABLE settings (a TEXT)", "CREATE TABLE qso (a TEXT)"} {
|
||||
if !keepForRole(s, RoleAll) {
|
||||
t.Fatalf("RoleAll dropped %q", s)
|
||||
}
|
||||
}
|
||||
// A logbook takes the contacts and refuses the settings side.
|
||||
if !keepForRole("CREATE INDEX i ON qso(callsign)", RoleLogbook) {
|
||||
t.Fatal("logbook dropped a qso statement")
|
||||
}
|
||||
if keepForRole("CREATE TABLE station_profiles (id INTEGER)", RoleLogbook) {
|
||||
t.Fatal("logbook accepted station_profiles")
|
||||
}
|
||||
// An unrecognised statement — a future table, a PRAGMA — is kept, so a new
|
||||
// migration behaves as it does today rather than vanishing from one database.
|
||||
if !keepForRole("CREATE TABLE something_new (id INTEGER)", RoleLogbook) {
|
||||
t.Fatal("logbook dropped an unknown table")
|
||||
}
|
||||
if !keepForRole("PRAGMA foreign_keys = off", RoleLogbook) {
|
||||
t.Fatal("logbook dropped a PRAGMA")
|
||||
}
|
||||
}
|
||||
|
||||
// tablesIn lists the tables of an open database.
|
||||
func tablesIn(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
conn, err := Open(path) // RoleAll: opening must not change what is there
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rows, err := conn.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// A logbook opened through OpenLogbook holds the contacts and nothing else.
|
||||
func TestOpenLogbookSchema(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "logbook.db")
|
||||
conn, err := OpenLogbook(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
// The one table that matters has to be there and has to be usable.
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
|
||||
t.Fatalf("qso table unusable: %v", err)
|
||||
}
|
||||
for _, forbidden := range settingsTables {
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(forbidden)).Scan(&n); err == nil {
|
||||
t.Errorf("%s was created in a logbook database", forbidden)
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Reopening as a logbook is idempotent, and the migrations already recorded
|
||||
// as applied must not be re-run into a half-schema.
|
||||
conn2, err := OpenLogbook(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if err := conn2.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
|
||||
t.Fatalf("qso lost on reopen: %v", err)
|
||||
}
|
||||
conn2.Close()
|
||||
}
|
||||
|
||||
// An existing logbook that an older version filled with the whole schema loses
|
||||
// the unused tables — and keeps any that hold rows.
|
||||
func TestPruneKeepsNonEmptyTables(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.db")
|
||||
conn, err := Open(path) // the old behaviour: every table everywhere
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(`INSERT INTO station_profiles(name) VALUES('Home')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
if got := tablesIn(t, path); len(got) < 10 {
|
||||
t.Fatalf("expected a full legacy schema, got %v", got)
|
||||
}
|
||||
|
||||
conn, err = OpenLogbook(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
var n int
|
||||
// Rows are data: this one stays, whatever the schema says it is for.
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM station_profiles`).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("station_profiles dropped with a row in it (err=%v n=%d)", err, n)
|
||||
}
|
||||
// The empty ones go.
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err == nil {
|
||||
t.Error("empty settings table survived in a logbook")
|
||||
}
|
||||
// And the contacts are untouched throughout.
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
|
||||
t.Fatalf("qso table lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A CREATE TABLE body is full of "ON DELETE CASCADE": the index rule must not
|
||||
// read it as a table name, or the table goes unrecognised and gets created in
|
||||
// every database.
|
||||
func TestStmtTableForeignKeyBody(t *testing.T) {
|
||||
stmt := `CREATE TABLE operating_stations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
|
||||
)`
|
||||
if got := stmtTable(stmt); got != "operating_stations" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if keepForRole(stmt, RoleLogbook) {
|
||||
t.Fatal("a settings table reached a logbook database")
|
||||
}
|
||||
}
|
||||
|
||||
// The settings database loses its unused qso table — and gets it back, in full,
|
||||
// the moment it has to serve as the logbook again.
|
||||
func TestDropAndRecreateQSOTable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.db")
|
||||
conn, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// A contact in it is data: the table must survive.
|
||||
if _, err := conn.Exec(`INSERT INTO qso(callsign, qso_date, band, mode)
|
||||
VALUES('F4BPO','2026-01-01T12:00:00Z','20m','SSB')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
DropEmptyQSOTable(conn, "settings")
|
||||
var n int
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("dropped a qso table holding a contact (err=%v n=%d)", err, n)
|
||||
}
|
||||
|
||||
// Empty, so it goes.
|
||||
if _, err := conn.Exec(`DELETE FROM qso`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
DropEmptyQSOTable(conn, "settings")
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err == nil {
|
||||
t.Fatal("empty qso table survived")
|
||||
}
|
||||
|
||||
// And comes back complete when this database is pressed into service as the
|
||||
// logbook — a late column and an index included, not just a bare table.
|
||||
if err := EnsureQSOTable(conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(`INSERT INTO qso(callsign, qso_date, band, mode, ant_path, sync_uid)
|
||||
VALUES('F1TRF','2026-01-02T13:00:00Z','40m','CW','S','uid-1')`); err != nil {
|
||||
t.Fatalf("recreated qso table is incomplete: %v", err)
|
||||
}
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("recreated table unusable (err=%v n=%d)", err, n)
|
||||
}
|
||||
// Idempotent: a second call on a live table must not touch it.
|
||||
if err := EnsureQSOTable(conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("EnsureQSOTable disturbed an existing table (err=%v n=%d)", err, n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user