"""Alias resolution + CRUD. A tag_alias maps (model_name, model_category) -> canonical Tag. Resolution happens at suggestion-read time so raw tagger_predictions stay unmolested. """ from collections.abc import Sequence from dataclasses import dataclass from sqlalchemy import delete, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from ...models import Tag, TagAlias @dataclass(frozen=True) class AliasRow: alias_string: str alias_category: str canonical_tag_id: int canonical_tag_name: str class AliasService: def __init__(self, session: AsyncSession): self.session = session async def resolve(self, name: str, category: str) -> Tag | None: """Return the canonical Tag for (name, category), or None if no alias.""" stmt = ( select(Tag) .join(TagAlias, TagAlias.canonical_tag_id == Tag.id) .where(TagAlias.alias_string == name) .where(TagAlias.alias_category == category) ) return (await self.session.execute(stmt)).scalar_one_or_none() async def resolve_many( self, pairs: list[tuple[str, str]] ) -> dict[tuple[str, str], Tag]: """Batch-resolve. Returns only the pairs that have an alias. Used by SuggestionService so it does one query instead of N. """ if not pairs: return {} strings = {p[0] for p in pairs} stmt = ( select(TagAlias, Tag) .join(Tag, Tag.id == TagAlias.canonical_tag_id) .where(TagAlias.alias_string.in_(strings)) ) rows = (await self.session.execute(stmt)).all() wanted = set(pairs) out: dict[tuple[str, str], Tag] = {} for alias, tag in rows: key = (alias.alias_string, alias.alias_category) if key in wanted: out[key] = tag return out async def create( self, alias_string: str, alias_category: str, canonical_tag_id: int ) -> None: """Idempotent create (ON CONFLICT DO NOTHING).""" stmt = insert(TagAlias).values( alias_string=alias_string, alias_category=alias_category, canonical_tag_id=canonical_tag_id, ) stmt = stmt.on_conflict_do_nothing( index_elements=["alias_string", "alias_category"] ) await self.session.execute(stmt) async def remove(self, alias_string: str, alias_category: str) -> None: await self.session.execute( delete(TagAlias) .where(TagAlias.alias_string == alias_string) .where(TagAlias.alias_category == alias_category) ) async def list_all(self) -> Sequence[AliasRow]: stmt = ( select( TagAlias.alias_string, TagAlias.alias_category, TagAlias.canonical_tag_id, Tag.name, ) .join(Tag, Tag.id == TagAlias.canonical_tag_id) .order_by(TagAlias.alias_string.asc()) ) rows = (await self.session.execute(stmt)).all() return [ AliasRow( alias_string=r[0], alias_category=r[1], canonical_tag_id=r[2], canonical_tag_name=r[3], ) for r in rows ]