Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

228 lines
8.5 KiB

"""Generate the Swedish -> English migration and its rollback from rename_map.py.
Writing these by hand invites a typo in one direction that the other does not
mirror. Generating both from one map means the rollback is correct by construction.
python scripts/generate_rename_migration.py
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from _postgres.rename_map import COLUMNS, INDEXES, TABLES # noqa: E402
OUT = ROOT / "_postgres" / "migrations"
STAMP = "20260803_01"
HEADER = """\
-- {title}
--
-- Generated by scripts/generate_rename_migration.py from _postgres/rename_map.py.
-- Do not edit by hand; edit the map and regenerate.
--
-- ALTER TABLE ... RENAME is catalog-only in PostgreSQL: no table rewrite, no data
-- movement, no index rebuild. Wall time is milliseconds regardless of row count,
-- and the HNSW vector indexes are not touched.
--
-- Guarded by an existence check, so this is a no-op on a database created from the
-- current schema.sql and does the work on one created before the rename. One file
-- serves both a fresh install and an existing deployment.
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = 0;
DO $rename$
BEGIN
IF {guard} THEN
"""
FOOTER = """\
END IF;
END
$rename$;
{triggers}
COMMIT;
"""
# Trigger function bodies are stored as opaque text and do NOT follow renames.
# They would compile fine and then fail at the next INSERT with
# 'record "new" has no field "anforandetext"'. Recreated unconditionally.
TRIGGERS_FORWARD = """\
-- Recreate the search-vector triggers against the new column names.
--
-- The text-search configuration is read from a database setting rather than
-- hardcoded, so a deployment in another language does not have to patch DDL:
-- ALTER DATABASE <db> SET app.fts_config = 'swedish';
-- It only takes effect for new sessions, so restart the API afterwards.
DROP TRIGGER IF EXISTS talks_search_vector_trigger ON speeches;
DROP TRIGGER IF EXISTS speeches_search_vector_trigger ON speeches;
DROP FUNCTION IF EXISTS talks_search_vector_update();
CREATE OR REPLACE FUNCTION speeches_search_vector_update() RETURNS TRIGGER AS $fn$
BEGIN
NEW.search_vector := to_tsvector(
COALESCE(current_setting('app.fts_config', true), 'simple')::regconfig,
coalesce(NEW.text, ''));
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
CREATE TRIGGER speeches_search_vector_trigger
BEFORE INSERT OR UPDATE OF text ON speeches
FOR EACH ROW EXECUTE FUNCTION speeches_search_vector_update();
DROP TRIGGER IF EXISTS motions_search_vector_trigger ON documents;
DROP TRIGGER IF EXISTS documents_search_vector_trigger ON documents;
DROP FUNCTION IF EXISTS motions_search_vector_update();
CREATE OR REPLACE FUNCTION documents_search_vector_update() RETURNS TRIGGER AS $fn$
DECLARE cfg regconfig := COALESCE(current_setting('app.fts_config', true), 'simple')::regconfig;
BEGIN
NEW.search_vector :=
setweight(to_tsvector(cfg, coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector(cfg, coalesce(NEW.proposals_text, '')), 'B') ||
setweight(to_tsvector(cfg, coalesce(NEW.subtitle, '')), 'C') ||
setweight(to_tsvector(cfg, coalesce(NEW.text, '')), 'D');
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
CREATE TRIGGER documents_search_vector_trigger
BEFORE INSERT OR UPDATE OF title, subtitle, text, proposals_text ON documents
FOR EACH ROW EXECUTE FUNCTION documents_search_vector_update();
"""
TRIGGERS_ROLLBACK = """\
-- Restore the original trigger functions.
DROP TRIGGER IF EXISTS speeches_search_vector_trigger ON talks;
DROP FUNCTION IF EXISTS speeches_search_vector_update();
CREATE OR REPLACE FUNCTION talks_search_vector_update() RETURNS TRIGGER AS $fn$
BEGIN
NEW.search_vector := to_tsvector('swedish', coalesce(NEW.anforandetext, ''));
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS talks_search_vector_trigger ON talks;
CREATE TRIGGER talks_search_vector_trigger
BEFORE INSERT OR UPDATE OF anforandetext ON talks
FOR EACH ROW EXECUTE FUNCTION talks_search_vector_update();
DROP TRIGGER IF EXISTS documents_search_vector_trigger ON motions;
DROP FUNCTION IF EXISTS documents_search_vector_update();
CREATE OR REPLACE FUNCTION motions_search_vector_update() RETURNS TRIGGER AS $fn$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('swedish', coalesce(NEW.titel, '')), 'A') ||
setweight(to_tsvector('swedish', coalesce(NEW.forslag_text, '')), 'B') ||
setweight(to_tsvector('swedish', coalesce(NEW.undertitel, '')), 'C') ||
setweight(to_tsvector('swedish', coalesce(NEW.text, '')), 'D');
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS motions_search_vector_trigger ON motions;
CREATE TRIGGER motions_search_vector_trigger
BEFORE INSERT OR UPDATE OF titel, undertitel, text, forslag_text ON motions
FOR EACH ROW EXECUTE FUNCTION motions_search_vector_update();
"""
def build(forward: bool) -> str:
"""Emit the migration in one direction.
Column renames run before table renames going forward (the columns are still on
the old table), and after them going back.
"""
lines: list[str] = []
ind = " "
def col_statements() -> list[str]:
# Both directions address the table by its ORIGINAL name. Going forward,
# columns are renamed before the table is; going back, the table has already
# been renamed to its original name by the time these run. Getting this
# wrong produces a rollback that fails halfway, mid-transaction.
out = []
for table, cols in COLUMNS.items():
for old, new in cols.items():
a, b = (old, new) if forward else (new, old)
out.append(f"{ind}ALTER TABLE {table} RENAME COLUMN {a} TO {b};")
return out
def table_statements() -> list[str]:
out = []
for old, new in TABLES.items():
a, b = (old, new) if forward else (new, old)
out.append(f"{ind}ALTER TABLE {a} RENAME TO {b};")
return out
if forward:
lines += [" -- columns (still on their original tables)"]
lines += col_statements()
lines += ["", " -- tables"]
lines += table_statements()
lines += [
"",
" -- New: lets bills, written questions and committee reports share this",
" -- table instead of needing one table per document type. ADD COLUMN with a",
" -- constant default is metadata-only in PostgreSQL 11+.",
f"{ind}ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type TEXT NOT NULL DEFAULT 'motion';",
]
lines += ["", " -- indexes (cosmetic, but keeps pg_dump diffs readable)"]
for old, new in INDEXES.items():
lines.append(f"{ind}ALTER INDEX IF EXISTS {old} RENAME TO {new};")
else:
lines += [" -- indexes"]
for old, new in INDEXES.items():
lines.append(f"{ind}ALTER INDEX IF EXISTS {new} RENAME TO {old};")
lines += ["", " -- tables", f"{ind}ALTER TABLE documents DROP COLUMN IF EXISTS doc_type;"]
lines += table_statements()
lines += ["", " -- columns"]
lines += col_statements()
guard = (
"EXISTS (SELECT 1 FROM information_schema.columns\n"
" WHERE table_schema = 'public' AND table_name = 'talks'\n"
" AND column_name = 'anforandetext')"
if forward
else "EXISTS (SELECT 1 FROM information_schema.columns\n"
" WHERE table_schema = 'public' AND table_name = 'speeches'\n"
" AND column_name = 'text')"
)
title = (
"Rename the Swedish schema to neutral English"
if forward
else "ROLLBACK: restore the Swedish schema"
)
return (
HEADER.format(title=title, guard=guard)
+ "\n".join(lines)
+ "\n"
+ FOOTER.format(triggers=TRIGGERS_FORWARD if forward else TRIGGERS_ROLLBACK)
)
def main() -> int:
OUT.mkdir(parents=True, exist_ok=True)
for forward, name in ((True, f"{STAMP}_rename_to_english.sql"),
(False, f"{STAMP}_rename_to_english_ROLLBACK.sql")):
path = OUT / name
path.write_text(build(forward), encoding="utf-8")
print(f" wrote {path.relative_to(ROOT)} ({len(path.read_text().splitlines())} lines)")
total = sum(len(c) for c in COLUMNS.values())
print(f"\n {total} column renames, {len(TABLES)} table renames, {len(INDEXES)} index renames")
return 0
if __name__ == "__main__":
sys.exit(main())