Files
TieMeasureFlow/src/backend/migrations/versions/014_vision_fk_ondelete.py
T
Adriano 54bc384650 fix(vision): task_id di vision_results/vision_reference_images in CASCADE (#38.6)
Migrazione 014 (batch_alter_table, nome del vincolo esistente letto con
sa.inspect invece di indovinato) e stessi ondelete nei modelli ORM.
Cancellare un task con esecuzioni o immagini di riferimento non fallisce
più su un vincolo FK MySQL.

MANCA: i file delle immagini di riferimento su disco non vengono
rimossi alla cancellazione del task - tasks.py::delete_task (NON
TOCCARE da questo lotto) cancella con `db.delete(task)` diretto, senza
un punto unico in vision_service da agganciare. `_copy_tasks_to_version`
(recipe_service.py) non copia le immagini di riferimento nel
copy-on-write: nemmeno questo si tocca qui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:44:55 +02:00

77 lines
3.0 KiB
Python

"""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 `<table>_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'],
)