fix(search): gallery library per-word search with LIKE escaping (#6584)

Gallery search built a single `%{search}%` LIKE, so multi-word queries only matched the exact adjacent phrase ("red car" missed "a car that is red") and literal % / _ acted as SQL wildcards. Split on whitespace and require each token to match (prompt OR tags OR ai_tags), escaping LIKE wildcards per token; same escaping for the tag filter. Promote _escape_like to public escape_like_pattern() shared helper.

Part of #2123.

Tests: tests/test_gallery_search_multiword.py (5 tests); 4 fail pre-fix, 5 pass post-fix. Neighbors (session_search, gallery null-user, shims): 25 passed.
This commit is contained in:
Merajul Arefin
2026-10-31 13:43:20 +00:00
committed by GitHub
parent 9835c1a7f9
commit 04f8546d9d
3 changed files with 159 additions and 10 deletions
+14 -9
View File
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession
from src.auth_helpers import get_current_user, owner_filter, require_privilege
from src.session_search import escape_like_pattern
from src.upload_limits import (
read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES,
@@ -1184,15 +1185,18 @@ def setup_gallery_routes() -> APIRouter:
)
q = _owner_filter(q, user)
# Search filter (prompt + tags + ai_tags)
# Per-term AND search: a single `%foo bar%` LIKE only matched the
# exact phrase, so "red car" missed "a car that is red".
# LIKE wildcards are escaped per token.
if search:
term = f"%{search}%"
from sqlalchemy import or_
q = q.filter(or_(
GalleryImage.prompt.ilike(term),
GalleryImage.tags.ilike(term),
GalleryImage.ai_tags.ilike(term),
))
for tok in search.split():
term = f"%{escape_like_pattern(tok)}%"
q = q.filter(or_(
GalleryImage.prompt.ilike(term, escape="\\"),
GalleryImage.tags.ilike(term, escape="\\"),
GalleryImage.ai_tags.ilike(term, escape="\\"),
))
# Tag filter. The UI stacks multiple tag pills by passing them
# comma-separated — each tag adds a separate AND-filter so the
@@ -1203,9 +1207,10 @@ def setup_gallery_routes() -> APIRouter:
for one in (t.strip() for t in tag.split(",")):
if not one:
continue
safe_tag = f"%{escape_like_pattern(one)}%"
q = q.filter(_or(
GalleryImage.tags.ilike(f"%{one}%"),
GalleryImage.ai_tags.ilike(f"%{one}%"),
GalleryImage.tags.ilike(safe_tag, escape="\\"),
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
))
# Model filter