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 SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user, owner_filter, require_privilege 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 ( from src.upload_limits import (
read_upload_limited, read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES, GALLERY_UPLOAD_MAX_BYTES,
@@ -1184,15 +1185,18 @@ def setup_gallery_routes() -> APIRouter:
) )
q = _owner_filter(q, user) 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: if search:
term = f"%{search}%"
from sqlalchemy import or_ from sqlalchemy import or_
q = q.filter(or_( for tok in search.split():
GalleryImage.prompt.ilike(term), term = f"%{escape_like_pattern(tok)}%"
GalleryImage.tags.ilike(term), q = q.filter(or_(
GalleryImage.ai_tags.ilike(term), 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 # Tag filter. The UI stacks multiple tag pills by passing them
# comma-separated — each tag adds a separate AND-filter so the # 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(",")): for one in (t.strip() for t in tag.split(",")):
if not one: if not one:
continue continue
safe_tag = f"%{escape_like_pattern(one)}%"
q = q.filter(_or( q = q.filter(_or(
GalleryImage.tags.ilike(f"%{one}%"), GalleryImage.tags.ilike(safe_tag, escape="\\"),
GalleryImage.ai_tags.ilike(f"%{one}%"), GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
)) ))
# Model filter # Model filter
+10 -1
View File
@@ -57,10 +57,19 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
} }
def _escape_like(value: str) -> str: def escape_like_pattern(value: str) -> str:
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
patterns from user input. Must escape backslash first.
"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _escape_like(value: str) -> str:
return escape_like_pattern(value)
def _snippet(content: str, query: str, radius: int = 60) -> str: def _snippet(content: str, query: str, radius: int = 60) -> str:
content = content or "" content = content or ""
query = query or "" query = query or ""
+135
View File
@@ -0,0 +1,135 @@
"""Regression for #2123 (gallery part): library search must match multi-word
queries in any order and treat % / _ literally.
Old code built a single ``%{search}%`` LIKE over prompt/tags/ai_tags, so
"red car" missed "a car that is red", and a literal "%" matched every row.
"""
import uuid
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
from core.database import GalleryImage
import routes.gallery_routes as gallery_routes
def _client_with_gallery(monkeypatch, tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'gallery_search.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
monkeypatch.setattr(gallery_routes, "SessionLocal", session_factory)
db = session_factory()
try:
db.add_all(
[
GalleryImage(
id="img-red-car",
filename=f"{uuid.uuid4().hex}.png",
prompt="a car that is red",
model="model-a",
tags="",
ai_tags="",
is_active=True,
file_size=10,
),
GalleryImage(
id="img-boat",
filename=f"{uuid.uuid4().hex}.png",
prompt="blue boat on the lake",
model="model-a",
tags="",
ai_tags="",
is_active=True,
file_size=10,
),
GalleryImage(
id="img-percent",
filename=f"{uuid.uuid4().hex}.png",
prompt="100% cotton shirt",
model="model-a",
tags="sale_2024",
ai_tags="",
is_active=True,
file_size=10,
),
GalleryImage(
id="img-backslash",
filename=f"{uuid.uuid4().hex}.png",
prompt=r"C:\photos\sunset",
model="model-a",
tags="",
ai_tags="",
is_active=True,
file_size=10,
),
]
)
db.commit()
finally:
db.close()
app = FastAPI()
app.include_router(gallery_routes.setup_gallery_routes())
return TestClient(app)
def test_gallery_search_matches_words_in_any_order(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
body = client.get("/api/gallery/library", params={"search": "red car"}).json()
ids = {item["id"] for item in body["items"]}
assert "img-red-car" in ids
assert "img-boat" not in ids
def test_gallery_search_escapes_percent_wildcard(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
body = client.get("/api/gallery/library", params={"search": "%"}).json()
ids = {item["id"] for item in body["items"]}
assert ids == {"img-percent"}
def test_gallery_search_escapes_underscore_wildcard(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
# Only img-percent has a literal underscore (in tags "sale_2024").
# Unescaped, "_" matches any single char so every row matches.
body = client.get("/api/gallery/library", params={"search": "_"}).json()
ids = {item["id"] for item in body["items"]}
assert ids == {"img-percent"}
def test_gallery_search_escapes_backslash(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
# Only img-backslash has a literal backslash. The escape char itself
# must survive escaping instead of escaping the surrounding wildcards.
body = client.get("/api/gallery/library", params={"search": "\\"}).json()
ids = {item["id"] for item in body["items"]}
assert ids == {"img-backslash"}
def test_gallery_tag_filter_escapes_wildcards(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
# Unescaped, "%" / "_" match every row; escaped, only literal matches.
assert client.get("/api/gallery/library", params={"tag": "%"}).json()["items"] == []
body = client.get("/api/gallery/library", params={"tag": "_"}).json()
assert {item["id"] for item in body["items"]} == {"img-percent"}