feat(fc2a): add TagService — find-or-create, autocomplete, image association
Validates kind/fandom rules at the service layer: fandom_id only allowed for kind='character', and must reference an existing kind='fandom' tag. Autocomplete ranking: exact match > prefix match > substring, tie-broken by image_count descending. Image-tag association uses INSERT ON CONFLICT DO NOTHING for idempotent re-tagging. conftest.py adds a transactional AsyncSession fixture; each test rolls back so they don't pollute each other. Also includes a sync Session fixture (db_sync) for the Importer tests in Task 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Business-logic services consumed by API blueprints and Celery tasks."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Tag CRUD + autocomplete + image-tag association."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import and_, case, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Tag, TagKind, image_tag
|
||||
|
||||
|
||||
class TagValidationError(ValueError):
|
||||
"""Raised when tag construction breaks the kind/fandom rules."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TagAutocompleteHit:
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
fandom_id: int | None
|
||||
fandom_name: str | None
|
||||
image_count: int
|
||||
|
||||
|
||||
class TagService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def find_or_create(
|
||||
self, name: str, kind: TagKind, fandom_id: int | None = None
|
||||
) -> Tag:
|
||||
"""Upserts (name, kind, fandom_id) into the tag table.
|
||||
|
||||
Validates kind/fandom rules before touching the DB:
|
||||
- fandom_id may only be set when kind == TagKind.character
|
||||
- if fandom_id is set, the referenced tag must exist and have
|
||||
kind == TagKind.fandom
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise TagValidationError("Tag name cannot be empty")
|
||||
|
||||
if fandom_id is not None and kind != TagKind.character:
|
||||
raise TagValidationError(
|
||||
"fandom_id is only valid for character tags"
|
||||
)
|
||||
|
||||
if fandom_id is not None:
|
||||
fandom = await self.session.get(Tag, fandom_id)
|
||||
if fandom is None or fandom.kind != TagKind.fandom:
|
||||
raise TagValidationError(
|
||||
f"fandom_id {fandom_id} does not reference a fandom tag"
|
||||
)
|
||||
|
||||
# Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the
|
||||
# uniqueness index name directly (it's a partial coalesce-based
|
||||
# expression), so we re-select after insert.
|
||||
stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.name == name)
|
||||
.where(Tag.kind == kind)
|
||||
.where(
|
||||
Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id
|
||||
)
|
||||
)
|
||||
existing = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
|
||||
self.session.add(new_tag)
|
||||
await self.session.flush()
|
||||
return new_tag
|
||||
|
||||
async def autocomplete(
|
||||
self,
|
||||
query: str,
|
||||
kind: TagKind | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[TagAutocompleteHit]:
|
||||
"""ILIKE search ranked exact > prefix > substring, then by image count.
|
||||
|
||||
Returns at most `limit` hits.
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
|
||||
match_rank = case(
|
||||
(func.lower(Tag.name) == q.lower(), 0),
|
||||
(func.lower(Tag.name).startswith(q.lower()), 1),
|
||||
else_=2,
|
||||
)
|
||||
|
||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
||||
image_count = (
|
||||
select(func.count(image_tag.c.image_record_id))
|
||||
.where(image_tag.c.tag_id == Tag.id)
|
||||
.correlate(Tag.__table__)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.kind,
|
||||
Tag.fandom_id,
|
||||
fandom_alias.c.name.label("fandom_name"),
|
||||
image_count.label("image_count"),
|
||||
)
|
||||
.select_from(Tag.__table__.outerjoin(
|
||||
fandom_alias, Tag.fandom_id == fandom_alias.c.id
|
||||
))
|
||||
.where(func.lower(Tag.name).like(f"%{q.lower()}%"))
|
||||
.order_by(match_rank.asc(), image_count.desc(), Tag.name.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [
|
||||
TagAutocompleteHit(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
kind=r.kind.value if hasattr(r.kind, "value") else r.kind,
|
||||
fandom_id=r.fandom_id,
|
||||
fandom_name=r.fandom_name,
|
||||
image_count=r.image_count or 0,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def add_to_image(self, image_id: int, tag_id: int, source: str = "manual") -> None:
|
||||
"""Idempotent: re-adding an existing tag does nothing."""
|
||||
stmt = insert(image_tag).values(
|
||||
image_record_id=image_id, tag_id=tag_id, source=source
|
||||
)
|
||||
stmt = stmt.on_conflict_do_nothing(
|
||||
index_elements=["image_record_id", "tag_id"]
|
||||
)
|
||||
await self.session.execute(stmt)
|
||||
|
||||
async def remove_from_image(self, image_id: int, tag_id: int) -> None:
|
||||
await self.session.execute(
|
||||
image_tag.delete().where(
|
||||
and_(
|
||||
image_tag.c.image_record_id == image_id,
|
||||
image_tag.c.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def list_for_image(self, image_id: int) -> Sequence[Tag]:
|
||||
stmt = (
|
||||
select(Tag)
|
||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||
.where(image_tag.c.image_record_id == image_id)
|
||||
.order_by(Tag.kind.asc(), Tag.name.asc())
|
||||
)
|
||||
return (await self.session.execute(stmt)).scalars().all()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
The async db fixture provides an AsyncSession bound to a transaction that
|
||||
gets rolled back after each test. CI provisions a real Postgres + pgvector
|
||||
(see .forgejo/workflows/ci.yml), so tests exercise the actual schema and
|
||||
migration code paths.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _async_database_url() -> str:
|
||||
user = os.environ.get("DB_USER", "fabledcurator")
|
||||
password = os.environ["DB_PASSWORD"]
|
||||
host = os.environ.get("DB_HOST", "postgres")
|
||||
port = os.environ.get("DB_PORT", "5432")
|
||||
name = os.environ.get("DB_NAME", "fabledcurator_test")
|
||||
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
|
||||
|
||||
|
||||
def _sync_database_url() -> str:
|
||||
user = os.environ.get("DB_USER", "fabledcurator")
|
||||
password = os.environ["DB_PASSWORD"]
|
||||
host = os.environ.get("DB_HOST", "postgres")
|
||||
port = os.environ.get("DB_PORT", "5432")
|
||||
name = os.environ.get("DB_NAME", "fabledcurator_test")
|
||||
return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{name}"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def engine():
|
||||
e = create_async_engine(_async_database_url(), future=True)
|
||||
yield e
|
||||
await e.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db(engine) -> AsyncSession:
|
||||
Session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with Session() as session:
|
||||
await session.begin()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_engine():
|
||||
e = create_engine(_sync_database_url(), future=True)
|
||||
yield e
|
||||
e.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_sync(sync_engine):
|
||||
"""Synchronous Session bound to a savepoint — used by Importer tests
|
||||
(the Importer is sync-only by design)."""
|
||||
SyncSession = sessionmaker(sync_engine, expire_on_commit=False)
|
||||
with SyncSession() as session:
|
||||
session.begin()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
|
||||
from backend.app.models import TagKind
|
||||
from backend.app.services.tag_service import TagService, TagValidationError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_or_create_creates(db):
|
||||
svc = TagService(db)
|
||||
t = await svc.find_or_create("Alice", TagKind.character, fandom_id=None)
|
||||
assert t.id is not None
|
||||
assert t.name == "Alice"
|
||||
assert t.kind == TagKind.character
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_or_create_idempotent(db):
|
||||
svc = TagService(db)
|
||||
a = await svc.find_or_create("Bob", TagKind.artist)
|
||||
b = await svc.find_or_create("Bob", TagKind.artist)
|
||||
assert a.id == b.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_name_different_kind_coexists(db):
|
||||
svc = TagService(db)
|
||||
artist = await svc.find_or_create("Forge", TagKind.artist)
|
||||
series = await svc.find_or_create("Forge", TagKind.series)
|
||||
assert artist.id != series.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fandom_id_requires_character(db):
|
||||
svc = TagService(db)
|
||||
fandom = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
with pytest.raises(TagValidationError, match="character"):
|
||||
await svc.find_or_create("Bob", TagKind.artist, fandom_id=fandom.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fandom_id_must_reference_fandom(db):
|
||||
svc = TagService(db)
|
||||
artist = await svc.find_or_create("X", TagKind.artist)
|
||||
with pytest.raises(TagValidationError, match="fandom"):
|
||||
await svc.find_or_create("Y", TagKind.character, fandom_id=artist.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_character_with_fandom(db):
|
||||
svc = TagService(db)
|
||||
fandom = await svc.find_or_create("Naruto", TagKind.fandom)
|
||||
char = await svc.find_or_create("Sasuke", TagKind.character, fandom_id=fandom.id)
|
||||
assert char.fandom_id == fandom.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_character_same_name_different_fandoms(db):
|
||||
svc = TagService(db)
|
||||
f1 = await svc.find_or_create("Series A", TagKind.fandom)
|
||||
f2 = await svc.find_or_create("Series B", TagKind.fandom)
|
||||
c1 = await svc.find_or_create("Alice", TagKind.character, fandom_id=f1.id)
|
||||
c2 = await svc.find_or_create("Alice", TagKind.character, fandom_id=f2.id)
|
||||
assert c1.id != c2.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_name_raises(db):
|
||||
svc = TagService(db)
|
||||
with pytest.raises(TagValidationError):
|
||||
await svc.find_or_create(" ", TagKind.general)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_ranking(db):
|
||||
svc = TagService(db)
|
||||
await svc.find_or_create("alice", TagKind.character)
|
||||
await svc.find_or_create("alicent", TagKind.character)
|
||||
await svc.find_or_create("malice", TagKind.character)
|
||||
hits = await svc.autocomplete("alice")
|
||||
# exact match first, then prefix, then substring
|
||||
names = [h.name for h in hits]
|
||||
assert names[0] == "alice"
|
||||
assert names[1] == "alicent"
|
||||
assert names[2] == "malice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_kind_filter(db):
|
||||
svc = TagService(db)
|
||||
await svc.find_or_create("Foo", TagKind.artist)
|
||||
await svc.find_or_create("Foo", TagKind.character)
|
||||
hits = await svc.autocomplete("Foo", kind=TagKind.artist)
|
||||
assert len(hits) == 1
|
||||
assert hits[0].kind == "artist"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_empty_query_returns_nothing(db):
|
||||
svc = TagService(db)
|
||||
await svc.find_or_create("Foo", TagKind.artist)
|
||||
assert await svc.autocomplete("") == []
|
||||
Reference in New Issue
Block a user