diff --git a/backend/app/services/tag_directory_service.py b/backend/app/services/tag_directory_service.py index 9d3bd58..49c55cc 100644 --- a/backend/app/services/tag_directory_service.py +++ b/backend/app/services/tag_directory_service.py @@ -55,21 +55,20 @@ class TagDirectoryService: fandom = aliased(Tag) member = aliased(Tag) # image_count aggregates the tag's images INCLUDING, for a fandom, every - # image carrying one of its characters (member.fandom_id == Tag.id) — the - # member subquery is empty for non-fandom tags, so they stay direct-only. - # DISTINCT so an image with both the fandom and ≥1 of its characters - # counts once; a correlated scalar subquery keeps it 1 row per tag with - # no group_by (the fandom self-join below is 1:1). + # image carrying one of its characters. `member` is the tag actually on + # the image; an image counts for the outer Tag if that applied tag IS the + # Tag (direct) OR is a character whose fandom_id is the Tag (the fandom + # leg). Both legs correlate the OUTER Tag.id at a SINGLE level — a nested + # `tag_id IN (SELECT ... WHERE member.fandom_id == Tag.id)` does NOT + # correlate and silently counts every fandom-character globally (~every + # tag collapses to the same inflated number). DISTINCT so an image with + # the fandom AND ≥1 of its characters counts once; a correlated scalar + # subquery keeps it 1 row per tag with no group_by. count_col = ( select(func.count(image_tag.c.image_record_id.distinct())) - .where( - or_( - image_tag.c.tag_id == Tag.id, - image_tag.c.tag_id.in_( - select(member.id).where(member.fandom_id == Tag.id) - ), - ) - ) + .select_from(image_tag) + .join(member, member.id == image_tag.c.tag_id) + .where(or_(image_tag.c.tag_id == Tag.id, member.fandom_id == Tag.id)) .correlate(Tag) .scalar_subquery() .label("image_count") diff --git a/tests/test_tag_directory_service.py b/tests/test_tag_directory_service.py index 13662bd..22d5a7d 100644 --- a/tests/test_tag_directory_service.py +++ b/tests/test_tag_directory_service.py @@ -47,20 +47,35 @@ async def test_directory_fandom_counts_and_previews_via_characters(db): char = Tag(name="Ichigo", kind=TagKind.character, fandom_id=fandom.id) db.add(char) await db.flush() + # A SECOND, unrelated fandom + character + image. The count for "Bleach" + # must NOT pick this up — guards against the count's fandom leg failing to + # correlate the outer tag and counting EVERY fandom-character globally + # (which inflates every card to the same number). + other_fandom = Tag(name="Naruto", kind=TagKind.fandom) + db.add(other_fandom) + await db.flush() + other_char = Tag(name="Sasuke", kind=TagKind.character, fandom_id=other_fandom.id) + db.add(other_char) + await db.flush() # img0: character only; img1: fandom direct; img2: BOTH (dedup → counts once) img0, img1, img2 = await _img(db, 0), await _img(db, 1), await _img(db, 2) + other_img = await _img(db, 3) await db.execute(image_tag.insert().values([ {"image_record_id": img0.id, "tag_id": char.id, "source": "manual"}, {"image_record_id": img1.id, "tag_id": fandom.id, "source": "manual"}, {"image_record_id": img2.id, "tag_id": char.id, "source": "manual"}, {"image_record_id": img2.id, "tag_id": fandom.id, "source": "manual"}, + {"image_record_id": other_img.id, "tag_id": other_char.id, "source": "manual"}, ])) await db.flush() svc = TagDirectoryService(db) page = await svc.list_tags(kind="fandom", q=None, cursor=None, limit=10) card = next(c for c in page.cards if c["name"] == "Bleach") - assert card["image_count"] == 3 + assert card["image_count"] == 3 # NOT 4 — Naruto's image must be excluded assert len(card["preview_thumbnails"]) == 3 + # The unrelated fandom counts only its own one image. + other = next(c for c in page.cards if c["name"] == "Naruto") + assert other["image_count"] == 1 @pytest.mark.asyncio