From 003181ed41f4a528de6570e6168768866488b7f4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 13:36:13 -0400 Subject: [PATCH] feat(tags): merge repoints image_tag and tag_suggestion_rejection with dedup Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/tag_service.py | 39 ++++++++++++++- tests/test_tag_merge.py | 77 +++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 794b0f8..e79df6c 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -327,10 +327,45 @@ class TagService: ) async def _repoint_image_tags(self, src: int, tgt: int) -> int: - return 0 + """Drop source links whose image already links target, then move + the rest. Returns the number actually moved.""" + await self.session.execute( + image_tag.delete().where( + and_( + image_tag.c.tag_id == src, + image_tag.c.image_record_id.in_( + select(image_tag.c.image_record_id).where( + image_tag.c.tag_id == tgt + ) + ), + ) + ) + ) + res = await self.session.execute( + image_tag.update() + .where(image_tag.c.tag_id == src) + .values(tag_id=tgt) + ) + return res.rowcount or 0 async def _repoint_rejections(self, src: int, tgt: int) -> None: - pass + from ..models.tag_suggestion_rejection import TagSuggestionRejection + + await self.session.execute( + text( + "DELETE FROM tag_suggestion_rejection r " + "WHERE r.tag_id = :src AND EXISTS (" + " SELECT 1 FROM tag_suggestion_rejection r2 " + " WHERE r2.tag_id = :tgt " + " AND r2.image_record_id = r.image_record_id)" + ), + {"src": src, "tgt": tgt}, + ) + await self.session.execute( + update(TagSuggestionRejection) + .where(TagSuggestionRejection.tag_id == src) + .values(tag_id=tgt) + ) async def _repoint_allowlist(self, src: int, tgt: int) -> None: pass diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py index 6835b12..b9d0c2f 100644 --- a/tests/test_tag_merge.py +++ b/tests/test_tag_merge.py @@ -1,4 +1,5 @@ import pytest +from sqlalchemy import select from backend.app.models import Tag, TagKind, image_tag from backend.app.models.tag_allowlist import TagAllowlist @@ -129,3 +130,79 @@ async def test_merge_empty_tags_returns_result(db): assert result.alias_created is False assert result.source_deleted is True assert await db.get(Tag, a.id) is None + + +@pytest.mark.asyncio +async def test_merge_moves_image_tags_and_counts(db): + svc = TagService(db) + a = await svc.find_or_create("SrcIT", TagKind.general) + b = await svc.find_or_create("TgtIT", TagKind.general) + i1, i2 = await _img(db), await _img(db) + await svc.add_to_image(i1, a.id, source="manual") + await svc.add_to_image(i2, a.id, source="manual") + result = await svc.merge(a.id, b.id) + assert result.merged_count == 2 + rows = ( + await db.execute( + image_tag.select().where(image_tag.c.tag_id == b.id) + ) + ).all() + assert {r.image_record_id for r in rows} == {i1, i2} + assert ( + await db.execute( + image_tag.select().where(image_tag.c.tag_id == a.id) + ) + ).first() is None + + +@pytest.mark.asyncio +async def test_merge_dedups_image_tag_when_image_has_both(db): + svc = TagService(db) + a = await svc.find_or_create("SrcDup", TagKind.general) + b = await svc.find_or_create("TgtDup", TagKind.general) + img = await _img(db) + await svc.add_to_image(img, a.id, source="manual") + await svc.add_to_image(img, b.id, source="manual") + result = await svc.merge(a.id, b.id) + # The image already had the target → that source row is dropped, not moved. + assert result.merged_count == 0 + rows = ( + await db.execute( + image_tag.select().where( + image_tag.c.image_record_id == img + ) + ) + ).all() + assert [r.tag_id for r in rows] == [b.id] + + +@pytest.mark.asyncio +async def test_merge_dedups_suggestion_rejections(db): + from backend.app.models.tag_suggestion_rejection import ( + TagSuggestionRejection, + ) + + svc = TagService(db) + a = await svc.find_or_create("SrcRej", TagKind.general) + b = await svc.find_or_create("TgtRej", TagKind.general) + i1, i2 = await _img(db), await _img(db) + db.add(TagSuggestionRejection(image_record_id=i1, tag_id=a.id)) + db.add(TagSuggestionRejection(image_record_id=i2, tag_id=a.id)) + db.add(TagSuggestionRejection(image_record_id=i1, tag_id=b.id)) + await db.flush() + await svc.merge(a.id, b.id) + rej = ( + await db.execute( + select(TagSuggestionRejection).where( + TagSuggestionRejection.tag_id == b.id + ) + ) + ).scalars().all() + assert {r.image_record_id for r in rej} == {i1, i2} + assert ( + await db.execute( + select(TagSuggestionRejection).where( + TagSuggestionRejection.tag_id == a.id + ) + ) + ).first() is None