From 14c244bd3d92adf3d2f107708799d64bb23bdaf6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 10 Jun 2026 00:19:17 -0400 Subject: [PATCH] refactor(tags): shared tag_query for fandom self-join + serialization (DRY sweep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/tags.py | 26 ++------------- backend/app/services/gallery_service.py | 24 +++----------- backend/app/services/tag_query.py | 44 +++++++++++++++++++++++++ backend/app/services/tag_service.py | 19 +++-------- tests/test_tag_query.py | 29 ++++++++++++++++ 5 files changed, 86 insertions(+), 56 deletions(-) create mode 100644 backend/app/services/tag_query.py create mode 100644 tests/test_tag_query.py diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index b189c1f..4fbe0a0 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -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//tags", methods=["POST"]) diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index cd5cc2e..b8a0721 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -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, } diff --git a/backend/app/services/tag_query.py b/backend/app/services/tag_query.py new file mode 100644 index 0000000..2dfbfbd --- /dev/null +++ b/backend/app/services/tag_query.py @@ -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 == .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, + } diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 91dd7cb..3de2115 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -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) diff --git a/tests/test_tag_query.py b/tests/test_tag_query.py new file mode 100644 index 0000000..c5ed4f7 --- /dev/null +++ b/tests/test_tag_query.py @@ -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, + }