122 lines
5.2 KiB
SQL
122 lines
5.2 KiB
SQL
-- logbook: per-user QSO logs and import batches.
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- imports
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Every QSO belongs to an import batch, so a bad ADIF file can be rolled back
|
|
-- as a unit rather than picked apart row by row afterwards.
|
|
CREATE TABLE logbook.imports (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
user_id bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
|
|
|
filename text,
|
|
source text, -- 'adif', 'lotw', 'clublog', 'manual'
|
|
|
|
status text NOT NULL DEFAULT 'running'
|
|
CHECK (status IN ('running', 'completed', 'failed', 'rolled_back')),
|
|
|
|
rows_total integer NOT NULL DEFAULT 0,
|
|
rows_accepted integer NOT NULL DEFAULT 0,
|
|
rows_duplicate integer NOT NULL DEFAULT 0,
|
|
rows_rejected integer NOT NULL DEFAULT 0,
|
|
|
|
error text,
|
|
|
|
started_at timestamptz NOT NULL DEFAULT now(),
|
|
finished_at timestamptz
|
|
);
|
|
|
|
CREATE INDEX imports_user_idx ON logbook.imports (user_id, started_at DESC);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- qsos
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
CREATE TABLE logbook.qsos (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
user_id bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
|
|
|
-- SET NULL rather than CASCADE: deleting an import record should not delete
|
|
-- the QSOs it brought in, only the audit trail of how they arrived.
|
|
import_id bigint REFERENCES logbook.imports(id) ON DELETE SET NULL,
|
|
|
|
callsign core.callsign NOT NULL,
|
|
|
|
-- ADIF gives QSO_DATE and TIME_ON separately, both UTC. Combined here.
|
|
qso_at timestamptz NOT NULL,
|
|
|
|
-- Dedup key. Generated columns must be IMMUTABLE, and date_trunc over a
|
|
-- timestamptz is only STABLE because it depends on the TimeZone setting.
|
|
-- timezone('UTC', tstz) -> timestamp IS immutable, and date_trunc over a
|
|
-- plain timestamp is immutable, so the chain works.
|
|
--
|
|
-- Minute granularity because two operators logging the same contact
|
|
-- routinely differ by a few seconds.
|
|
qso_minute timestamp GENERATED ALWAYS AS
|
|
(date_trunc('minute', timezone('UTC', qso_at))) STORED,
|
|
|
|
band text,
|
|
mode text,
|
|
submode text,
|
|
freq_hz bigint,
|
|
|
|
rst_sent text,
|
|
rst_rcvd text,
|
|
|
|
grid core.gridsquare,
|
|
dxcc integer, -- ADIF entity code, resolved at import
|
|
cq_zone smallint,
|
|
itu_zone smallint,
|
|
|
|
tx_power_w numeric(8,2),
|
|
comment text,
|
|
|
|
-- ADIF defines a few hundred fields and every logging program invents more.
|
|
-- Normalising the columns above and keeping the rest here is the main
|
|
-- reason for choosing Postgres: MariaDB's JSON is LONGTEXT with validation,
|
|
-- with no binary storage and no functional index on a path.
|
|
extra jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
|
|
CONSTRAINT qsos_freq_sane CHECK (freq_hz IS NULL OR (freq_hz > 0 AND freq_hz < 300000000000)),
|
|
CONSTRAINT qsos_zones_sane CHECK (
|
|
(cq_zone IS NULL OR cq_zone BETWEEN 1 AND 40)
|
|
AND (itu_zone IS NULL OR itu_zone BETWEEN 1 AND 90)
|
|
)
|
|
);
|
|
|
|
COMMENT ON COLUMN logbook.qsos.qso_minute IS 'Truncated copy of qso_at used only for duplicate detection. Do not query it directly; use qso_at.';
|
|
|
|
-- Duplicate detection. A repeat import of the same ADIF must not double the
|
|
-- log, and this is what makes ON CONFLICT DO NOTHING work on import.
|
|
--
|
|
-- NULLS NOT DISTINCT matters: band and mode are nullable, and in a unique index
|
|
-- NULLs are distinct by default. Without it, two identical QSOs that both have
|
|
-- band IS NULL do not collide, so an ADIF with no BAND field imports twice, and
|
|
-- again on every re-import. Requires PostgreSQL 15+.
|
|
CREATE UNIQUE INDEX qsos_dedup_uniq
|
|
ON logbook.qsos (user_id, callsign, band, mode, qso_minute)
|
|
NULLS NOT DISTINCT;
|
|
|
|
-- The four access patterns that actually happen.
|
|
CREATE INDEX qsos_user_time_idx ON logbook.qsos (user_id, qso_at DESC);
|
|
CREATE INDEX qsos_user_call_idx ON logbook.qsos (user_id, callsign);
|
|
CREATE INDEX qsos_user_band_mode_idx ON logbook.qsos (user_id, band, mode);
|
|
|
|
-- Award progress is DERIVED, never stored. DXCC and WAS counts are aggregates
|
|
-- over this table; a stored counter would need maintaining on every insert,
|
|
-- edit and rollback, and would drift. This index is what makes that cheap.
|
|
CREATE INDEX qsos_user_dxcc_idx ON logbook.qsos (user_id, dxcc) WHERE dxcc IS NOT NULL;
|
|
|
|
CREATE INDEX qsos_extra_idx ON logbook.qsos USING gin (extra);
|
|
|
|
-- Referencing side of the import FK. Without it, rolling back one import
|
|
-- sequentially scans the whole qsos table to apply ON DELETE SET NULL.
|
|
CREATE INDEX qsos_import_idx ON logbook.qsos (import_id) WHERE import_id IS NOT NULL;
|
|
|
|
-- Worth revisiting if a single user's log passes a few million rows: partition
|
|
-- by user_id hash, or by qso_at range. Not now - the indexes above carry a
|
|
-- normal log comfortably, and partitioning early costs flexibility.
|