diff --git a/app/api/scenes.py b/app/api/scenes.py index 7bf1ad9..6c025fb 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -8,7 +8,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel -from sqlalchemy import distinct, exists, func, literal_column, select +from sqlalchemy import distinct, exists, false, func, literal_column, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -182,15 +182,31 @@ def list_scenes( tag_slug_list = _split_csv(tags) # AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny # exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika. - for slug in tag_slug_list: - base = base.where( - exists( - select(1) - .select_from(SceneTag) - .join(Tag, Tag.id == SceneTag.tag_id) - .where(SceneTag.scene_id == Scene.id, Tag.slug == slug) - ) + # + # PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM + # tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze + # statystyk (MCV) → dla popularnych tagów (blowjob ~273k scen) wybiera index-walk po + # ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN + # ukrywał tag_id przed plannerem → używał średniej (8.4M/11541≈726) → zły plan + # (4-12s). Z literałem: ~20ms. Zob. też _build... light mode. + if tag_slug_list: + id_by_slug = dict( + session.execute( + select(Tag.slug, Tag.id).where(Tag.slug.in_(tag_slug_list)) + ).all() ) + for slug in tag_slug_list: + tag_id = id_by_slug.get(slug) + if tag_id is None: + base = base.where(false()) # nieznany slug → brak wyników + break + base = base.where( + exists( + select(1) + .select_from(SceneTag) + .where(SceneTag.scene_id == Scene.id, SceneTag.tag_id == tag_id) + ) + ) perf_id_strings = _split_csv(performer_ids) if perf_id_strings: