feat(provenance): post_id/artist_id gallery filters via EXISTS (mutually exclusive)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:08:54 -04:00
parent bb3fb5145a
commit 1590447301
3 changed files with 211 additions and 8 deletions
+32 -4
View File
@@ -17,11 +17,18 @@ async def scroll():
return jsonify({"error": "limit must be an integer"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
try:
page = await svc.scroll(cursor=cursor, limit=limit, tag_id=tag_id)
page = await svc.scroll(
cursor=cursor, limit=limit, tag_id=tag_id,
post_id=post_id, artist_id=artist_id,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@@ -51,10 +58,21 @@ async def scroll():
async def timeline():
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
buckets = await svc.timeline(tag_id=tag_id)
return jsonify([{"year": b.year, "month": b.month, "count": b.count} for b in buckets])
try:
buckets = await svc.timeline(
tag_id=tag_id, post_id=post_id, artist_id=artist_id
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
[{"year": b.year, "month": b.month, "count": b.count} for b in buckets]
)
@gallery_bp.route("/jump", methods=["GET"])
@@ -66,9 +84,19 @@ async def jump():
return jsonify({"error": "year and month query params required"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
cursor = await svc.jump_cursor(year=year, month=month, tag_id=tag_id)
try:
cursor = await svc.jump_cursor(
year=year, month=month, tag_id=tag_id,
post_id=post_id, artist_id=artist_id,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"cursor": cursor})
+49 -4
View File
@@ -10,10 +10,10 @@ import base64
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import and_, func, or_, select
from sqlalchemy import and_, exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Tag
from ..models import ImageProvenance, ImageRecord, Source, Tag
from ..models.tag import image_tag
CURSOR_SEPARATOR = "|"
@@ -67,6 +67,31 @@ def thumbnail_url(sha256_hex: str, mime: str) -> str:
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
def _require_single_filter(tag_id, post_id, artist_id) -> None:
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
raise ValueError(
"tag_id, post_id, artist_id are mutually exclusive"
)
def _provenance_clause(post_id, artist_id):
"""Correlated EXISTS clause (NOT a join) so an image with multiple
matching provenance rows is returned exactly once and the
(created_at DESC, id DESC) cursor ordering is unaffected."""
if post_id is not None:
return exists().where(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.post_id == post_id,
)
if artist_id is not None:
return exists().where(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.source_id == Source.id,
Source.artist_id == artist_id,
)
return None
class GalleryService:
def __init__(self, session: AsyncSession):
self.session = session
@@ -76,15 +101,21 @@ class GalleryService:
cursor: str | None,
limit: int = 50,
tag_id: int | None = None,
post_id: int | None = None,
artist_id: int | None = None,
) -> GalleryPage:
if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200")
_require_single_filter(tag_id, post_id, artist_id)
stmt = select(ImageRecord)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
@@ -123,22 +154,32 @@ class GalleryService:
date_groups=_group_by_year_month(images),
)
async def timeline(self, tag_id: int | None = None) -> list[TimelineBucket]:
async def timeline(
self,
tag_id: int | None = None,
post_id: int | None = None,
artist_id: int | None = None,
) -> list[TimelineBucket]:
year_col = func.date_part("year", ImageRecord.created_at).label("yr")
month_col = func.date_part("month", ImageRecord.created_at).label("mo")
stmt = select(
year_col, month_col, func.count(ImageRecord.id).label("cnt")
)
_require_single_filter(tag_id, post_id, artist_id)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
rows = (await self.session.execute(stmt)).all()
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
async def jump_cursor(
self, year: int, month: int, tag_id: int | None = None
self, year: int, month: int, tag_id: int | None = None,
post_id: int | None = None, artist_id: int | None = None,
) -> str | None:
"""Returns a cursor that, when passed to scroll(), positions at the
first image of the given year-month. None if the bucket is empty.
@@ -149,10 +190,14 @@ class GalleryService:
extract("year", ImageRecord.created_at) == year,
extract("month", ImageRecord.created_at) == month,
)
_require_single_filter(tag_id, post_id, artist_id)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
stmt = stmt.order_by(ImageRecord.created_at.desc(), ImageRecord.id.desc()).limit(1)
first = (await self.session.execute(stmt)).scalar_one_or_none()
if first is None: