feat(tags): merge repoints image_tag and tag_suggestion_rejection with dedup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -327,10 +327,45 @@ class TagService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _repoint_image_tags(self, src: int, tgt: int) -> int:
|
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:
|
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:
|
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from backend.app.models import Tag, TagKind, image_tag
|
from backend.app.models import Tag, TagKind, image_tag
|
||||||
from backend.app.models.tag_allowlist import TagAllowlist
|
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.alias_created is False
|
||||||
assert result.source_deleted is True
|
assert result.source_deleted is True
|
||||||
assert await db.get(Tag, a.id) is None
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user