feat(tags): TagService.merge guards + MergeResult + repoint stubs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,16 @@ class TagAutocompleteHit:
|
||||
image_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MergeResult:
|
||||
target_id: int
|
||||
target_name: str
|
||||
target_kind: str
|
||||
merged_count: int
|
||||
alias_created: bool
|
||||
source_deleted: bool
|
||||
|
||||
|
||||
class TagService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
@@ -259,3 +269,84 @@ class TagService:
|
||||
tag.name = new_name
|
||||
await self.session.flush()
|
||||
return tag
|
||||
|
||||
async def merge(self, source_id: int, target_id: int) -> MergeResult:
|
||||
"""Transactionally repoint every FK from source→target, optionally
|
||||
keep source's name as a tagger alias, delete source. Atomic: any
|
||||
exception rolls back via the caller's session."""
|
||||
if source_id == target_id:
|
||||
raise TagValidationError("Cannot merge a tag into itself")
|
||||
source = await self.session.get(Tag, source_id)
|
||||
if source is None:
|
||||
raise TagValidationError(f"Tag {source_id} not found")
|
||||
target = await self.session.get(Tag, target_id)
|
||||
if target is None:
|
||||
raise TagValidationError(f"Tag {target_id} not found")
|
||||
if source.kind != target.kind or (source.fandom_id or 0) != (
|
||||
target.fandom_id or 0
|
||||
):
|
||||
raise TagValidationError(
|
||||
"Tags must be the same kind and fandom to merge"
|
||||
)
|
||||
|
||||
keep_as_alias = await self._keep_as_alias(source_id)
|
||||
source_name = source.name
|
||||
source_kind = source.kind
|
||||
target_name = target.name
|
||||
target_kind = (
|
||||
target.kind.value
|
||||
if hasattr(target.kind, "value")
|
||||
else str(target.kind)
|
||||
)
|
||||
|
||||
merged_count = await self._repoint_image_tags(source_id, target_id)
|
||||
await self._repoint_rejections(source_id, target_id)
|
||||
await self._repoint_allowlist(source_id, target_id)
|
||||
await self._repoint_embedding(source_id)
|
||||
await self._repoint_aliases(source_id, target_id)
|
||||
await self._repoint_fandom_children(
|
||||
source_id, target_id, source_kind
|
||||
)
|
||||
|
||||
alias_created = False
|
||||
if keep_as_alias:
|
||||
alias_created = await self._create_protective_aliases(
|
||||
source_name, source_kind, target_id
|
||||
)
|
||||
|
||||
await self.session.delete(source)
|
||||
await self.session.flush()
|
||||
|
||||
return MergeResult(
|
||||
target_id=target_id,
|
||||
target_name=target_name,
|
||||
target_kind=target_kind,
|
||||
merged_count=merged_count,
|
||||
alias_created=alias_created,
|
||||
source_deleted=True,
|
||||
)
|
||||
|
||||
async def _repoint_image_tags(self, src: int, tgt: int) -> int:
|
||||
return 0
|
||||
|
||||
async def _repoint_rejections(self, src: int, tgt: int) -> None:
|
||||
pass
|
||||
|
||||
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
|
||||
pass
|
||||
|
||||
async def _repoint_embedding(self, src: int) -> None:
|
||||
pass
|
||||
|
||||
async def _repoint_aliases(self, src: int, tgt: int) -> None:
|
||||
pass
|
||||
|
||||
async def _repoint_fandom_children(
|
||||
self, src: int, tgt: int, src_kind: TagKind
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def _create_protective_aliases(
|
||||
self, src_name: str, src_kind: TagKind, tgt: int
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from backend.app.models import Tag, TagKind, image_tag
|
||||
from backend.app.models.tag_allowlist import TagAllowlist
|
||||
from backend.app.services.tag_service import (
|
||||
MergeResult,
|
||||
TagMergeConflict,
|
||||
TagService,
|
||||
TagValidationError,
|
||||
@@ -76,3 +77,55 @@ async def test_will_alias_true_when_allowlisted(db):
|
||||
with pytest.raises(TagMergeConflict) as ei:
|
||||
await svc.rename(source.id, "Canon2")
|
||||
assert ei.value.will_alias is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_rejects_self_merge(db):
|
||||
svc = TagService(db)
|
||||
t = await svc.find_or_create("Solo", TagKind.general)
|
||||
with pytest.raises(TagValidationError, match="into itself"):
|
||||
await svc.merge(t.id, t.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_rejects_missing_tag(db):
|
||||
svc = TagService(db)
|
||||
t = await svc.find_or_create("Real", TagKind.general)
|
||||
with pytest.raises(TagValidationError, match="not found"):
|
||||
await svc.merge(t.id, 999999)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_rejects_cross_kind(db):
|
||||
svc = TagService(db)
|
||||
a = await svc.find_or_create("NameA", TagKind.general)
|
||||
b = await svc.find_or_create("NameB", TagKind.artist)
|
||||
with pytest.raises(TagValidationError, match="same kind"):
|
||||
await svc.merge(a.id, b.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_rejects_cross_fandom(db):
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Fandom1", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Fandom2", TagKind.fandom)
|
||||
c1 = await svc.find_or_create("Char", TagKind.character, fandom_id=f1.id)
|
||||
c2 = await svc.find_or_create("Char2", TagKind.character, fandom_id=f2.id)
|
||||
with pytest.raises(TagValidationError, match="same kind and fandom"):
|
||||
await svc.merge(c1.id, c2.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_empty_tags_returns_result(db):
|
||||
svc = TagService(db)
|
||||
a = await svc.find_or_create("EmptyA", TagKind.general)
|
||||
b = await svc.find_or_create("EmptyB", TagKind.general)
|
||||
result = await svc.merge(a.id, b.id)
|
||||
assert isinstance(result, MergeResult)
|
||||
assert result.target_id == b.id
|
||||
assert result.target_name == "EmptyB"
|
||||
assert result.target_kind == "general"
|
||||
assert result.merged_count == 0
|
||||
assert result.alias_created is False
|
||||
assert result.source_deleted is True
|
||||
assert await db.get(Tag, a.id) is None
|
||||
|
||||
Reference in New Issue
Block a user