"""vision results and reference images cascade with their task
Both `vision_results.task_id` and `vision_reference_images.task_id` were
created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
so deleting a task with any camera history or reference image attached fails
on a strict FK check (MySQL) instead of taking the vision rows with it, the
way `production_events.run_id` already does for a closed production run.
The original constraint has no name to drop by (SQLAlchemy leaves inline
`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
own `
_ibfk_N`, which is not portable to reflect on). `sa.inspect`
looks it up at migration time instead of guessing it - the same way on
MySQL and on SQLite (batch mode), where an anonymous FK reflects with
`name=None` and there is nothing to drop before adding the new one.
Reference images on disk are not touched here - deleting a task's row does
not, on its own, know to also delete the file next to it; that gap is
declared open in issue #38 point 6's write-up (`vision_service` has no
single point today that intercepts a task deletion).
Revision ID: 014_vision_fk_ondelete
Revises: 013_reference_images
Create Date: 2026-09-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '014_vision_fk_ondelete'
down_revision: Union[str, None] = '013_reference_images'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NEW_FK_NAMES = {
'vision_results': 'fk_vision_results_task_id',
'vision_reference_images': 'fk_vision_reference_images_task_id',
}
def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
"""The name reflection gives the pre-existing `task_id` FK, if any.
None on SQLite for a constraint that was never named - batch mode then
has nothing to drop, and just adds the new named one alongside it.
"""
inspector = sa.inspect(bind)
for fk in inspector.get_foreign_keys(table_name):
if fk.get("constrained_columns") == ["task_id"]:
return fk.get("name")
return None
def upgrade() -> None:
bind = op.get_bind()
for table_name, new_name in _NEW_FK_NAMES.items():
existing_name = _existing_task_id_fk_name(bind, table_name)
with op.batch_alter_table(table_name) as batch:
if existing_name:
batch.drop_constraint(existing_name, type_='foreignkey')
batch.create_foreign_key(
new_name, 'recipe_tasks', ['task_id'], ['id'],
ondelete='CASCADE',
)
def downgrade() -> None:
# Reverse order: reference_images before results, mirroring upgrade().
for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
with op.batch_alter_table(table_name) as batch:
batch.drop_constraint(name, type_='foreignkey')
batch.create_foreign_key(
name, 'recipe_tasks', ['task_id'], ['id'],
)