mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 02:32:21 +01:00
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:
@@ -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
@@ -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 ""
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user