"""Cross-tenant referential integrity: composite FKs on (tenant_id, contact_id). Revision ID: 0036_cross_tenant_fk Revises: 0035_comm_search_index Create Date: 2026-07-25 Changes: 1. Add UNIQUE (tenant_id, id) on contacts — prerequisite for composite FK. 2. Replace contactpersons.contact_id FK with composite (tenant_id, contact_id) → contacts(tenant_id, id). 3. Replace contact_merge_history.source_contact_id FK with composite (tenant_id, source_contact_id) → contacts(tenant_id, id). 4. Replace contact_merge_history.target_contact_id FK with composite (tenant_id, target_contact_id) → contacts(tenant_id, id). """ from __future__ import annotations from typing import Union import sqlalchemy as sa from alembic import op # revision identifiers revision: str = "0036_cross_tenant_fk" down_revision: Union[str, None] = "0035_comm_search_index" branch_labels: Union[str, None] = None depends_on: Union[str, None] = None def _constraint_exists(name: str) -> str: """Return SQL that checks if a constraint exists.""" return ( f"SELECT 1 FROM information_schema.table_constraints " f"WHERE constraint_name = '{name}'" ) def _fk_exists(name: str) -> str: """Return SQL that checks if a foreign key constraint exists.""" return ( f"SELECT 1 FROM information_schema.table_constraints " f"WHERE constraint_name = '{name}' AND constraint_type = 'FOREIGN KEY'" ) def upgrade() -> None: conn = op.get_bind() # ── 1. Add UNIQUE (tenant_id, id) on contacts ────────────────────────── unique_name = "uq_contacts_tenant_id" result = conn.execute(sa.text(_constraint_exists(unique_name))).fetchone() if result is None: op.execute( f"ALTER TABLE contacts ADD CONSTRAINT {unique_name} " f"UNIQUE (tenant_id, id)" ) # ── 2. contactpersons: replace single-column FK with composite FK ────── # Find and drop the existing FK on contactpersons.contact_id old_cp_fk_result = conn.execute( sa.text( "SELECT conname FROM pg_constraint c " "JOIN pg_class cls ON c.conrelid = cls.oid " "JOIN pg_namespace nsp ON c.connamespace = nsp.oid " "WHERE cls.relname = 'contactpersons' " "AND nsp.nspname = 'public' " "AND c.contype = 'f' " "AND EXISTS (" " SELECT 1 FROM pg_attribute a " " WHERE a.attrelid = c.conrelid AND a.attname = 'contact_id' " " AND a.attnum = ANY(c.conkey)" ")" ) ).fetchone() if old_cp_fk_result is not None: old_cp_fk_name = old_cp_fk_result[0] op.execute(f"ALTER TABLE contactpersons DROP CONSTRAINT IF EXISTS {old_cp_fk_name}") # Add composite FK on contactpersons (tenant_id, contact_id) → contacts(tenant_id, id) cp_composite_fk = "fk_contactpersons_tenant_contact" result = conn.execute(sa.text(_fk_exists(cp_composite_fk))).fetchone() if result is None: op.execute( f"ALTER TABLE contactpersons ADD CONSTRAINT {cp_composite_fk} " f"FOREIGN KEY (tenant_id, contact_id) " f"REFERENCES contacts (tenant_id, id) ON DELETE CASCADE" ) # ── 3. contact_merge_history: replace source_contact_id FK ───────────── old_src_fk_result = conn.execute( sa.text( "SELECT conname FROM pg_constraint c " "JOIN pg_class cls ON c.conrelid = cls.oid " "JOIN pg_namespace nsp ON c.connamespace = nsp.oid " "WHERE cls.relname = 'contact_merge_history' " "AND nsp.nspname = 'public' " "AND c.contype = 'f' " "AND EXISTS (" " SELECT 1 FROM pg_attribute a " " WHERE a.attrelid = c.conrelid AND a.attname = 'source_contact_id' " " AND a.attnum = ANY(c.conkey)" ")" ) ).fetchone() if old_src_fk_result is not None: old_src_fk_name = old_src_fk_result[0] op.execute(f"ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS {old_src_fk_name}") src_composite_fk = "fk_merge_history_tenant_source" result = conn.execute(sa.text(_fk_exists(src_composite_fk))).fetchone() if result is None: op.execute( f"ALTER TABLE contact_merge_history ADD CONSTRAINT {src_composite_fk} " f"FOREIGN KEY (tenant_id, source_contact_id) " f"REFERENCES contacts (tenant_id, id) ON DELETE SET NULL" ) # ── 4. contact_merge_history: replace target_contact_id FK ────────────── old_tgt_fk_result = conn.execute( sa.text( "SELECT conname FROM pg_constraint c " "JOIN pg_class cls ON c.conrelid = cls.oid " "JOIN pg_namespace nsp ON c.connamespace = nsp.oid " "WHERE cls.relname = 'contact_merge_history' " "AND nsp.nspname = 'public' " "AND c.contype = 'f' " "AND EXISTS (" " SELECT 1 FROM pg_attribute a " " WHERE a.attrelid = c.conrelid AND a.attname = 'target_contact_id' " " AND a.attnum = ANY(c.conkey)" ")" ) ).fetchone() if old_tgt_fk_result is not None: old_tgt_fk_name = old_tgt_fk_result[0] op.execute(f"ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS {old_tgt_fk_name}") tgt_composite_fk = "fk_merge_history_tenant_target" result = conn.execute(sa.text(_fk_exists(tgt_composite_fk))).fetchone() if result is None: op.execute( f"ALTER TABLE contact_merge_history ADD CONSTRAINT {tgt_composite_fk} " f"FOREIGN KEY (tenant_id, target_contact_id) " f"REFERENCES contacts (tenant_id, id) ON DELETE CASCADE" ) def downgrade() -> None: conn = op.get_bind() # Restore single-column FKs and remove composite FKs # ── contact_merge_history: target ── op.execute("ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS fk_merge_history_tenant_target") op.execute( "ALTER TABLE contact_merge_history ADD CONSTRAINT " "contact_merge_history_target_contact_id_fkey " "FOREIGN KEY (target_contact_id) REFERENCES contacts (id) ON DELETE CASCADE" ) # ── contact_merge_history: source ── op.execute("ALTER TABLE contact_merge_history DROP CONSTRAINT IF EXISTS fk_merge_history_tenant_source") op.execute( "ALTER TABLE contact_merge_history ADD CONSTRAINT " "contact_merge_history_source_contact_id_fkey " "FOREIGN KEY (source_contact_id) REFERENCES contacts (id) ON DELETE SET NULL" ) # ── contactpersons ── op.execute("ALTER TABLE contactpersons DROP CONSTRAINT IF EXISTS fk_contactpersons_tenant_contact") op.execute( "ALTER TABLE contactpersons ADD CONSTRAINT " "contactpersons_contact_id_fkey " "FOREIGN KEY (contact_id) REFERENCES contacts (id) ON DELETE CASCADE" ) # ── Remove unique (tenant_id, id) on contacts ── op.execute("ALTER TABLE contacts DROP CONSTRAINT IF EXISTS uq_contacts_tenant_id")