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>
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
"""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'],
|
||||||
|
)
|
||||||
@@ -13,7 +13,8 @@ class VisionResult(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
task_id: Mapped[int] = mapped_column(
|
task_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
)
|
)
|
||||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
@@ -48,7 +49,8 @@ class VisionReferenceImage(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
task_id: Mapped[int] = mapped_column(
|
task_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
)
|
)
|
||||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
station_id: Mapped[Optional[int]] = mapped_column(
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.tests.conftest import create_test_recipe
|
from src.backend.tests.conftest import create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
@@ -69,3 +70,11 @@ def test_the_typo_is_gone():
|
|||||||
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
|
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
|
||||||
assert "dxf_compare" in TASK_TYPES
|
assert "dxf_compare" in TASK_TYPES
|
||||||
assert "xf_compare" not in TASK_TYPES
|
assert "xf_compare" not in TASK_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", [VisionResult, VisionReferenceImage])
|
||||||
|
def test_task_id_cascades_when_the_task_is_deleted(model):
|
||||||
|
"""#38.6: cancellare un task con esecuzioni/immagini non deve fallire su
|
||||||
|
un vincolo FK MySQL - la riga di visione deve seguirlo."""
|
||||||
|
(fk,) = model.__table__.columns["task_id"].foreign_keys
|
||||||
|
assert fk.ondelete == "CASCADE"
|
||||||
|
|||||||
Reference in New Issue
Block a user