refactor(state): consolidate SQL LIKE escaping onto one shared helper
Follow-up to #79722, which introduced _escape_like in hermes_state.py for the prune/archive filter fix. The same three-replace escape chain existed as five more inline copies in hermes_state.py and two in hermes_state_search.py (which must not import hermes_state — cycle). Move the helper to hermes_state_common.escape_like (the module that exists for exactly this) and route every copy through it: - hermes_state.py: session-ID prefix resolution, find_session_by_title, get_next_title_in_lineage, the _like_pattern closure in list projection, and the kanban cwd retag - hermes_state_search.py: the two LIKE-fallback token escapes hermes_state re-imports it as _escape_like for back-compat. No behavior change: every site produces byte-identical SQL patterns.
This commit is contained in:
+6
-24
@@ -56,6 +56,7 @@ from hermes_state_common import ( # noqa: F401 (re-exported for back-compat)
|
||||
_shape_preview,
|
||||
_sql_session_last_active,
|
||||
_sql_session_last_active_by_id,
|
||||
escape_like as _escape_like,
|
||||
DEFERRED_INDEX_SQL,
|
||||
FTS_CJK_STALE_KEY,
|
||||
FTS_SQL,
|
||||
@@ -176,17 +177,6 @@ def _delegate_from_json(col: str = "model_config") -> str:
|
||||
_MODEL_CONFIG_ROW_MISSING = object()
|
||||
|
||||
|
||||
def _escape_like(text: str) -> str:
|
||||
"""Escape SQL LIKE wildcards so an operator-supplied filter matches
|
||||
literally. Pair with ``ESCAPE '\\'`` in the clause.
|
||||
|
||||
``%`` and ``_`` are wildcards to LIKE, and ``_`` in particular is common
|
||||
in the values these filters run against (branch names, session titles).
|
||||
A filter documented as a substring match must not silently widen.
|
||||
"""
|
||||
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
|
||||
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
|
||||
# ``_`` and ``%`` are LIKE wildcards but ordinary characters in a path
|
||||
@@ -5286,12 +5276,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
if exact:
|
||||
return exact["id"]
|
||||
|
||||
escaped = (
|
||||
session_id_or_prefix
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
escaped = _escape_like(session_id_or_prefix)
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
|
||||
@@ -5680,7 +5665,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
|
||||
# Also search for numbered variants: "title #2", "title #3", etc.
|
||||
# Escape SQL LIKE wildcards (%, _) in the title to prevent false matches
|
||||
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
escaped = _escape_like(title)
|
||||
with self._read_ctx() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, title, started_at FROM sessions "
|
||||
@@ -5711,7 +5696,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
|
||||
# Find all existing numbered variants
|
||||
# Escape SQL LIKE wildcards (%, _) in the base to prevent false matches
|
||||
escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
escaped = _escape_like(base)
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
|
||||
@@ -5964,10 +5949,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
filter_clauses: List[str] = []
|
||||
|
||||
def _like_pattern(needle: str) -> str:
|
||||
escaped = (
|
||||
needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
)
|
||||
return f"%{escaped}%"
|
||||
return f"%{_escape_like(needle)}%"
|
||||
|
||||
if id_needle:
|
||||
# Admit a surfaced row if its own id or any id in its forward
|
||||
@@ -8856,7 +8838,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET source = 'kanban' "
|
||||
"WHERE source = 'cli' AND (cwd = ? OR cwd LIKE ? ESCAPE '\\')",
|
||||
(prefix, prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "/%"),
|
||||
(prefix, _escape_like(prefix) + "/%"),
|
||||
)
|
||||
# Read rowcount before set_meta reuses this cursor for its INSERT,
|
||||
# which would otherwise overwrite it with the meta write's count.
|
||||
|
||||
@@ -34,6 +34,18 @@ _PREVIEW_SCAFFOLD_WINDOW = 400
|
||||
_PREVIEW_MAX_CHARS = 60
|
||||
|
||||
|
||||
def escape_like(text: str) -> str:
|
||||
"""Escape SQL LIKE wildcards so operator/session-derived text matches
|
||||
literally. Pair with ``ESCAPE '\\'`` in the clause.
|
||||
|
||||
``%`` and ``_`` are wildcards to LIKE, and ``_`` in particular is common
|
||||
in the values these patterns run against (branch names, session titles,
|
||||
filesystem paths). A match documented as substring/prefix must not
|
||||
silently widen.
|
||||
"""
|
||||
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
_PREVIEW_CONTENT_SQL = "REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' ')"
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from hermes_state_common import (
|
||||
MAX_FTS5_QUERY_CHARS,
|
||||
SCHEMA_VERSION,
|
||||
_FTS_CJK_TRIGGERS,
|
||||
escape_like as _escape_like,
|
||||
)
|
||||
|
||||
# Moved methods logged under the "hermes_state" logger before the split;
|
||||
@@ -1730,7 +1731,7 @@ class SessionSearchMixin:
|
||||
token_clauses = []
|
||||
like_params: list = []
|
||||
for tok in non_op_tokens:
|
||||
esc = tok.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
esc = _escape_like(tok)
|
||||
token_clauses.append(
|
||||
"(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' OR m.tool_calls LIKE ? ESCAPE '\\')"
|
||||
)
|
||||
@@ -1986,7 +1987,7 @@ class SessionSearchMixin:
|
||||
where = ["m.id > ? AND m.id <= ?"]
|
||||
params: list = [progress, high_water]
|
||||
for term in terms:
|
||||
esc = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
esc = _escape_like(term)
|
||||
where.append(
|
||||
"(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' "
|
||||
"OR m.tool_calls LIKE ? ESCAPE '\\')"
|
||||
|
||||
Reference in New Issue
Block a user