refactor(tags): shared tag_query for fandom self-join + serialization (DRY sweep)
The fandom self-join (resolve a character's fandom NAME via Tag.fandom_id->Tag)
and the {id,name,kind,fandom_id,fandom_name} dict were hand-written in
TagService.autocomplete/.list_for_image, GalleryService.get_image_with_tags and
the api/tags handlers — the last few grown by this session's fandom-on-chip
feature. Consolidate to services/tag_query: fandom_join_alias() + tag_columns()
build the select; serialize_tag(row) builds the dict. Now a new tag field is
added in one place.
Over-DRY guard: TagDirectoryService selects the full Tag ORM + an image-count
aggregate (a different select shape) — left as its own variant. §8b: the
fandom_lookup alias lives only in tag_query; gallery + both api/tags handlers
serialize via serialize_tag. Test: serialize_tag handles enum + string kind.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-23
@@ -11,6 +11,7 @@ from ..services.bulk_tag_service import BulkTagService
|
||||
from ..services.series_match_service import SeriesMatchService
|
||||
from ..services.series_service import SeriesError, SeriesService
|
||||
from ..services.tag_directory_service import TagDirectoryService
|
||||
from ..services.tag_query import serialize_tag
|
||||
from ..services.tag_service import (
|
||||
TagMergeConflict,
|
||||
TagService,
|
||||
@@ -72,17 +73,7 @@ async def autocomplete():
|
||||
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
||||
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": h.id,
|
||||
"name": h.name,
|
||||
"kind": h.kind,
|
||||
"fandom_id": h.fandom_id,
|
||||
"fandom_name": h.fandom_name,
|
||||
"image_count": h.image_count,
|
||||
}
|
||||
for h in hits
|
||||
]
|
||||
[{**serialize_tag(h), "image_count": h.image_count} for h in hits]
|
||||
)
|
||||
|
||||
|
||||
@@ -165,18 +156,7 @@ async def list_tags_for_image(image_id: int):
|
||||
async with get_session() as session:
|
||||
svc = TagService(session)
|
||||
tags = await svc.list_for_image(image_id)
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"kind": t.kind.value,
|
||||
"fandom_id": t.fandom_id,
|
||||
"fandom_name": t.fandom_name,
|
||||
}
|
||||
for t in tags
|
||||
]
|
||||
)
|
||||
return jsonify([serialize_tag(t) for t in tags])
|
||||
|
||||
|
||||
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
|
||||
|
||||
@@ -24,6 +24,7 @@ from sqlalchemy.orm import aliased
|
||||
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
||||
from ..models.tag import image_tag
|
||||
from .pagination import decode_cursor, encode_cursor
|
||||
from .tag_query import fandom_join_alias, serialize_tag, tag_columns
|
||||
|
||||
# Reserved `platform` filter value selecting images with NO platformed
|
||||
# provenance (filesystem imports). Returned by facets() as a null-valued
|
||||
@@ -543,16 +544,10 @@ class GalleryService:
|
||||
if record is None:
|
||||
return None
|
||||
# Self-join Tag to resolve a character's fandom NAME (not just id) so the
|
||||
# modal chip can label it without an N+1 — mirrors list_for_image.
|
||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
||||
# modal chip can label it without an N+1 (shared tag_query helpers).
|
||||
fandom_alias = fandom_join_alias()
|
||||
tag_stmt = (
|
||||
select(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.kind,
|
||||
Tag.fandom_id,
|
||||
fandom_alias.c.name.label("fandom_name"),
|
||||
)
|
||||
select(*tag_columns(fandom_alias))
|
||||
.select_from(
|
||||
Tag.__table__
|
||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||
@@ -599,16 +594,7 @@ class GalleryService:
|
||||
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||
if artist is not None else None
|
||||
),
|
||||
"tags": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
|
||||
"fandom_id": t.fandom_id,
|
||||
"fandom_name": t.fandom_name,
|
||||
}
|
||||
for t in tags
|
||||
],
|
||||
"tags": [serialize_tag(t) for t in tags],
|
||||
"neighbors": neighbors,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared tag-with-fandom query columns + serialization.
|
||||
|
||||
Resolving a character tag's fandom NAME via a Tag self-join (Tag.fandom_id -> the
|
||||
fandom Tag) and serializing the canonical
|
||||
``{id, name, kind, fandom_id, fandom_name}`` dict were hand-written in
|
||||
TagService.autocomplete / .list_for_image and GalleryService.get_image_with_tags
|
||||
(the last two added by the fandom-on-chip feature, 978f49a). One source now
|
||||
(DRY pattern sweep 2026-06-10) so a new tag field is added in exactly one place.
|
||||
|
||||
TagDirectoryService selects the FULL Tag ORM plus an image-count aggregate (a
|
||||
different select shape), so it keeps its own variant — not folded in here.
|
||||
"""
|
||||
|
||||
from ..models import Tag
|
||||
|
||||
|
||||
def fandom_join_alias():
|
||||
"""A Tag self-join alias for resolving a tag's fandom name. Outerjoin it on
|
||||
``Tag.fandom_id == <alias>.c.id`` and select via `tag_columns(alias)`."""
|
||||
return Tag.__table__.alias("fandom_lookup")
|
||||
|
||||
|
||||
def tag_columns(fandom_alias):
|
||||
"""The canonical (id, name, kind, fandom_id, fandom_name) column set for a
|
||||
tag select that outerjoins `fandom_alias` (from `fandom_join_alias()`)."""
|
||||
return [
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.kind,
|
||||
Tag.fandom_id,
|
||||
fandom_alias.c.name.label("fandom_name"),
|
||||
]
|
||||
|
||||
|
||||
def serialize_tag(row) -> dict:
|
||||
"""Serialize a row carrying .id/.name/.kind/.fandom_id/.fandom_name to the
|
||||
canonical tag dict. `kind` may be a TagKind enum or a plain string."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"kind": row.kind.value if hasattr(row.kind, "value") else row.kind,
|
||||
"fandom_id": row.fandom_id,
|
||||
"fandom_name": row.fandom_name,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from ..models import Tag, TagKind, image_tag
|
||||
from ..models.tag_allowlist import TagAllowlist
|
||||
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
||||
from .db_helpers import get_or_create
|
||||
from .tag_query import fandom_join_alias, tag_columns
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,7 +160,7 @@ class TagService:
|
||||
else_=2,
|
||||
)
|
||||
|
||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
||||
fandom_alias = fandom_join_alias()
|
||||
image_count = (
|
||||
select(func.count(image_tag.c.image_record_id))
|
||||
.where(image_tag.c.tag_id == Tag.id)
|
||||
@@ -169,11 +170,7 @@ class TagService:
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.kind,
|
||||
Tag.fandom_id,
|
||||
fandom_alias.c.name.label("fandom_name"),
|
||||
*tag_columns(fandom_alias),
|
||||
image_count.label("image_count"),
|
||||
)
|
||||
.select_from(Tag.__table__.outerjoin(
|
||||
@@ -224,15 +221,9 @@ class TagService:
|
||||
NAME (not just fandom_id) via a self-join on Tag, so the UI can label a
|
||||
character chip with its fandom without an N+1 (mirrors the
|
||||
autocomplete/directory resolution)."""
|
||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
||||
fandom_alias = fandom_join_alias()
|
||||
stmt = (
|
||||
select(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.kind,
|
||||
Tag.fandom_id,
|
||||
fandom_alias.c.name.label("fandom_name"),
|
||||
)
|
||||
select(*tag_columns(fandom_alias))
|
||||
.select_from(
|
||||
Tag.__table__
|
||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""serialize_tag — the shared canonical tag-dict serializer."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from backend.app.models import TagKind
|
||||
from backend.app.services.tag_query import serialize_tag
|
||||
|
||||
|
||||
def test_serialize_tag_with_enum_kind():
|
||||
# ORM/column rows carry kind as a TagKind enum.
|
||||
row = SimpleNamespace(
|
||||
id=1, name="Sasuke Uchiha", kind=TagKind.character,
|
||||
fandom_id=5, fandom_name="Naruto",
|
||||
)
|
||||
assert serialize_tag(row) == {
|
||||
"id": 1, "name": "Sasuke Uchiha", "kind": "character",
|
||||
"fandom_id": 5, "fandom_name": "Naruto",
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_tag_with_string_kind_and_no_fandom():
|
||||
# autocomplete hits already store kind as a plain string.
|
||||
row = SimpleNamespace(
|
||||
id=2, name="solo", kind="general", fandom_id=None, fandom_name=None,
|
||||
)
|
||||
assert serialize_tag(row) == {
|
||||
"id": 2, "name": "solo", "kind": "general",
|
||||
"fandom_id": None, "fandom_name": None,
|
||||
}
|
||||
Reference in New Issue
Block a user