SurfSense/surfsense_backend/alembic/versions/134_relax_revision_fks.py
2026-04-29 07:20:31 -07:00

139 lines
4.2 KiB
Python

"""134_relax_revision_fks
Revision ID: 134
Revises: 133
Create Date: 2026-04-29
Relax the parent FKs on ``document_revisions`` and ``folder_revisions`` so
revisions survive the deletes they describe.
Why: the snapshot/revert pipeline writes a ``DocumentRevision`` BEFORE
hard-deleting a document via the ``rm`` tool (and likewise a
``FolderRevision`` before ``rmdir``). If the FK is ``ON DELETE CASCADE``
the snapshot row is wiped at the exact moment we need it most — revert
then has nothing to read and the operation becomes irreversible.
Migration:
* ``document_revisions.document_id``: ``NOT NULL`` -> nullable; FK
``ON DELETE CASCADE`` -> ``ON DELETE SET NULL``.
* ``folder_revisions.folder_id``: same treatment.
The ``search_space_id`` FK on both tables is left unchanged (still
``ON DELETE CASCADE``). When a search space is deleted, all documents,
folders, AND their revisions go together — that's the correct teardown
story.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "134"
down_revision: str | None = "133"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _fk_name(bind, table: str, column: str) -> str | None:
"""Return the (single) FK constraint name on ``table.column``, if any."""
inspector = inspect(bind)
for fk in inspector.get_foreign_keys(table):
cols = fk.get("constrained_columns") or []
if cols == [column]:
return fk.get("name")
return None
def upgrade() -> None:
bind = op.get_bind()
# --- document_revisions.document_id -> nullable + SET NULL ---------------
fk_name = _fk_name(bind, "document_revisions", "document_id")
if fk_name:
op.drop_constraint(fk_name, "document_revisions", type_="foreignkey")
op.alter_column(
"document_revisions",
"document_id",
existing_type=sa.Integer(),
nullable=True,
)
op.create_foreign_key(
"document_revisions_document_id_fkey",
"document_revisions",
"documents",
["document_id"],
["id"],
ondelete="SET NULL",
)
# --- folder_revisions.folder_id -> nullable + SET NULL -------------------
fk_name = _fk_name(bind, "folder_revisions", "folder_id")
if fk_name:
op.drop_constraint(fk_name, "folder_revisions", type_="foreignkey")
op.alter_column(
"folder_revisions",
"folder_id",
existing_type=sa.Integer(),
nullable=True,
)
op.create_foreign_key(
"folder_revisions_folder_id_fkey",
"folder_revisions",
"folders",
["folder_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
# Reinstating NOT NULL + CASCADE requires draining orphan rows first
# (any revision whose parent doc/folder has already been deleted).
op.execute("DELETE FROM document_revisions WHERE document_id IS NULL")
op.execute("DELETE FROM folder_revisions WHERE folder_id IS NULL")
# --- document_revisions.document_id -> NOT NULL + CASCADE ---------------
fk_name = _fk_name(bind, "document_revisions", "document_id")
if fk_name:
op.drop_constraint(fk_name, "document_revisions", type_="foreignkey")
op.alter_column(
"document_revisions",
"document_id",
existing_type=sa.Integer(),
nullable=False,
)
op.create_foreign_key(
"document_revisions_document_id_fkey",
"document_revisions",
"documents",
["document_id"],
["id"],
ondelete="CASCADE",
)
# --- folder_revisions.folder_id -> NOT NULL + CASCADE -------------------
fk_name = _fk_name(bind, "folder_revisions", "folder_id")
if fk_name:
op.drop_constraint(fk_name, "folder_revisions", type_="foreignkey")
op.alter_column(
"folder_revisions",
"folder_id",
existing_type=sa.Integer(),
nullable=False,
)
op.create_foreign_key(
"folder_revisions_folder_id_fkey",
"folder_revisions",
"folders",
["folder_id"],
["id"],
ondelete="CASCADE",
)