"""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 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())