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_match_service import SeriesMatchService
|
||||||
from ..services.series_service import SeriesError, SeriesService
|
from ..services.series_service import SeriesError, SeriesService
|
||||||
from ..services.tag_directory_service import TagDirectoryService
|
from ..services.tag_directory_service import TagDirectoryService
|
||||||
|
from ..services.tag_query import serialize_tag
|
||||||
from ..services.tag_service import (
|
from ..services.tag_service import (
|
||||||
TagMergeConflict,
|
TagMergeConflict,
|
||||||
TagService,
|
TagService,
|
||||||
@@ -72,17 +73,7 @@ async def autocomplete():
|
|||||||
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
[
|
[{**serialize_tag(h), "image_count": h.image_count} for h in hits]
|
||||||
{
|
|
||||||
"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
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,18 +156,7 @@ async def list_tags_for_image(image_id: int):
|
|||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
tags = await svc.list_for_image(image_id)
|
tags = await svc.list_for_image(image_id)
|
||||||
return jsonify(
|
return jsonify([serialize_tag(t) for t in tags])
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"name": t.name,
|
|
||||||
"kind": t.kind.value,
|
|
||||||
"fandom_id": t.fandom_id,
|
|
||||||
"fandom_name": t.fandom_name,
|
|
||||||
}
|
|
||||||
for t in tags
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
|
@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 import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
from .pagination import decode_cursor, encode_cursor
|
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
|
# Reserved `platform` filter value selecting images with NO platformed
|
||||||
# provenance (filesystem imports). Returned by facets() as a null-valued
|
# provenance (filesystem imports). Returned by facets() as a null-valued
|
||||||
@@ -543,16 +544,10 @@ class GalleryService:
|
|||||||
if record is None:
|
if record is None:
|
||||||
return None
|
return None
|
||||||
# Self-join Tag to resolve a character's fandom NAME (not just id) so the
|
# 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.
|
# modal chip can label it without an N+1 (shared tag_query helpers).
|
||||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
fandom_alias = fandom_join_alias()
|
||||||
tag_stmt = (
|
tag_stmt = (
|
||||||
select(
|
select(*tag_columns(fandom_alias))
|
||||||
Tag.id,
|
|
||||||
Tag.name,
|
|
||||||
Tag.kind,
|
|
||||||
Tag.fandom_id,
|
|
||||||
fandom_alias.c.name.label("fandom_name"),
|
|
||||||
)
|
|
||||||
.select_from(
|
.select_from(
|
||||||
Tag.__table__
|
Tag.__table__
|
||||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
.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}
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||||
if artist is not None else None
|
if artist is not None else None
|
||||||
),
|
),
|
||||||
"tags": [
|
"tags": [serialize_tag(t) for t in 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
|
|
||||||
],
|
|
||||||
"neighbors": neighbors,
|
"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_allowlist import TagAllowlist
|
||||||
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
||||||
from .db_helpers import get_or_create
|
from .db_helpers import get_or_create
|
||||||
|
from .tag_query import fandom_join_alias, tag_columns
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -159,7 +160,7 @@ class TagService:
|
|||||||
else_=2,
|
else_=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
fandom_alias = fandom_join_alias()
|
||||||
image_count = (
|
image_count = (
|
||||||
select(func.count(image_tag.c.image_record_id))
|
select(func.count(image_tag.c.image_record_id))
|
||||||
.where(image_tag.c.tag_id == Tag.id)
|
.where(image_tag.c.tag_id == Tag.id)
|
||||||
@@ -169,11 +170,7 @@ class TagService:
|
|||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
Tag.id,
|
*tag_columns(fandom_alias),
|
||||||
Tag.name,
|
|
||||||
Tag.kind,
|
|
||||||
Tag.fandom_id,
|
|
||||||
fandom_alias.c.name.label("fandom_name"),
|
|
||||||
image_count.label("image_count"),
|
image_count.label("image_count"),
|
||||||
)
|
)
|
||||||
.select_from(Tag.__table__.outerjoin(
|
.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
|
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
|
character chip with its fandom without an N+1 (mirrors the
|
||||||
autocomplete/directory resolution)."""
|
autocomplete/directory resolution)."""
|
||||||
fandom_alias = Tag.__table__.alias("fandom_lookup")
|
fandom_alias = fandom_join_alias()
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(*tag_columns(fandom_alias))
|
||||||
Tag.id,
|
|
||||||
Tag.name,
|
|
||||||
Tag.kind,
|
|
||||||
Tag.fandom_id,
|
|
||||||
fandom_alias.c.name.label("fandom_name"),
|
|
||||||
)
|
|
||||||
.select_from(
|
.select_from(
|
||||||
Tag.__table__
|
Tag.__table__
|
||||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
.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