refactor(tags): shared tag_query for fandom self-join + serialization (DRY sweep)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m17s

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:
2026-06-10 00:19:17 -04:00
parent 074c5868fb
commit 14c244bd3d
5 changed files with 86 additions and 56 deletions
+3 -23
View File
@@ -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"])
+5 -19
View File
@@ -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,
}
+44
View File
@@ -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,
}
+5 -14
View File
@@ -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)
+29
View File
@@ -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,
}