"""Replace historical semantic IDs with stable opaque UUID strings. Revision ID: 0022_opaque_ids Revises: 0021_detail_records New records already use ``uuid4`` in the ORM. This migration only repairs historical rows created by seed migrations and keeps all IDs stable after the conversion. Generating a new ID during serialization would break detail URLs, reorder requests and foreign-key relationships, so this migration is intentionally data-oriented and cannot be reversed automatically. """ from collections.abc import Iterable from uuid import UUID, uuid4 from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect revision = "0022_opaque_ids" down_revision = "0021_detail_records" branch_labels = None depends_on = None AUDIT_ENTITY_TABLES = { "hero_slide": "HeroSlide", "destination_hero": "DestinationHero", "demand_hero": "DemandHero", "demand_feature_card": "DemandFeatureCard", "vehicle_option": "VehicleOption", "home_experience": "HomeExperience", "home_team_building": "HomeTeamBuilding", "home_wild_archive": "HomeWildArchive", "wanfa_category": "WanfaCategory", "wanfa_route": "WanfaRoute", "detail": "DetailRecord", "concierge_advisor": "ConciergeAdvisor", "lead": "Lead", } def is_opaque_id(value: object) -> bool: """Return whether a persisted ID is already a UUID-like opaque value.""" if not isinstance(value, str) or not value.strip(): return False try: UUID(value.strip()) except ValueError: return False return True def build_id_mapping(ids: Iterable[object]) -> dict[str, str]: """Build a one-time semantic-to-UUID mapping without touching UUID IDs.""" mapping: dict[str, str] = {} for raw_id in ids: if raw_id is None: continue old_id = str(raw_id).strip() if old_id and not is_opaque_id(old_id) and old_id not in mapping: mapping[old_id] = str(uuid4()) return mapping def _quote_identifier(identifier: str) -> str: return '"' + identifier.replace('"', '""') + '"' def _replace_values(conn, table_name: str, column_name: str, mapping: dict[str, str]) -> None: if not mapping: return table = _quote_identifier(table_name) column = _quote_identifier(column_name) statement = sa.text(f"UPDATE {table} SET {column} = :new_id WHERE {column} = :old_id") for old_id, new_id in mapping.items(): conn.execute(statement, {"old_id": old_id, "new_id": new_id}) def _id_tables(conn) -> list[str]: inspector = inspect(conn) return [ table_name for table_name in inspector.get_table_names() if table_name != "alembic_version" and any(column["name"] == "id" for column in inspector.get_columns(table_name)) ] def _id_mappings(conn, table_names: list[str]) -> dict[str, dict[str, str]]: mappings: dict[str, dict[str, str]] = {} for table_name in table_names: rows = conn.execute( sa.text(f"SELECT {_quote_identifier('id')} FROM {_quote_identifier(table_name)}") ).scalars() mapping = build_id_mapping(rows) if mapping: mappings[table_name] = mapping return mappings def collect_fk_specs(foreign_keys, mappings: dict[str, dict[str, str]]) -> list[dict]: """Keep enough FK metadata to drop, rewrite and recreate each constraint.""" specs: list[dict] = [] for source_table, foreign_key in foreign_keys: target_table = foreign_key.get("referred_table") target_columns = foreign_key.get("referred_columns") or [] source_columns = foreign_key.get("constrained_columns") or [] if not target_table or target_table not in mappings or "id" not in target_columns: continue options = foreign_key.get("options") or {} specs.append( { "name": foreign_key.get("name"), "source_table": source_table, "source_columns": source_columns, "target_table": target_table, "target_columns": target_columns, "ondelete": options.get("ondelete"), "onupdate": options.get("onupdate"), } ) return specs def _foreign_key_specs(conn, table_names: list[str], mappings: dict[str, dict[str, str]]) -> list[dict]: inspector = inspect(conn) foreign_keys = [ (table_name, foreign_key) for table_name in table_names for foreign_key in inspector.get_foreign_keys(table_name) ] return collect_fk_specs(foreign_keys, mappings) def _drop_foreign_keys(specs: list[dict]) -> None: for spec in specs: if spec["name"]: op.drop_constraint(spec["name"], spec["source_table"], type_="foreignkey") def _replace_foreign_keys(conn, specs: list[dict], mappings: dict[str, dict[str, str]]) -> None: for spec in specs: target_mapping = mappings[spec["target_table"]] for source_column, target_column in zip(spec["source_columns"], spec["target_columns"]): if target_column == "id": _replace_values(conn, spec["source_table"], source_column, target_mapping) def _restore_foreign_keys(specs: list[dict]) -> None: for spec in specs: if not spec["name"]: continue op.create_foreign_key( spec["name"], spec["source_table"], spec["target_table"], spec["source_columns"], spec["target_columns"], ondelete=spec["ondelete"], onupdate=spec["onupdate"], ) def _replace_detail_route_keys(conn, table_names: list[str], mappings: dict[str, dict[str, str]]) -> None: if "DetailRecord" not in table_names: return _replace_values(conn, "DetailRecord", "key", mappings.get("WanfaRoute", {})) def _replace_audit_entity_ids(conn, mappings: dict[str, dict[str, str]]) -> None: if not mappings.get("AuditLog") and "AuditLog" not in _id_tables(conn): return for entity, table_name in AUDIT_ENTITY_TABLES.items(): _replace_values(conn, "AuditLog", "entityId", mappings.get(table_name, {})) def upgrade() -> None: if context.is_offline_mode(): return conn = op.get_bind() table_names = _id_tables(conn) mappings = _id_mappings(conn, table_names) if not mappings: return # PostgreSQL checks existing foreign keys immediately. Temporarily remove # affected constraints, rewrite all IDs and references in one transaction, # then recreate the same constraints with their original actions. foreign_key_specs = _foreign_key_specs(conn, table_names, mappings) _drop_foreign_keys(foreign_key_specs) _replace_foreign_keys(conn, foreign_key_specs, mappings) _replace_detail_route_keys(conn, table_names, mappings) _replace_audit_entity_ids(conn, mappings) for table_name, mapping in mappings.items(): _replace_values(conn, table_name, "id", mapping) _restore_foreign_keys(foreign_key_specs) def downgrade() -> None: # UUID replacement is intentionally one-way: the original semantic IDs # are not retained in the database and cannot be reconstructed safely. pass