Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions osmsg/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ def _rows(result) -> list[dict[str, Any]]:
return [dict(zip(cols, r, strict=True)) for r in result.fetchall()]


def _like_escape(value: str) -> str:
"""Escape LIKE metacharacters so a contributor search matches them literally. Without this `_`
matches any character and `%` any sequence, so searching `a_il` returns `Anil` and `%` returns
everyone. Paired with `ESCAPE '\\'` at the call site."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


def _prefixes(hashtag: str | list[str]) -> list[tuple[str, str]]:
"""Normalize one hashtag or many into deduped `(lo, hi)` prefix-range pairs, case-insensitive and
order-preserving. Each hashtag matches as a prefix; the scope is the union across them."""
Expand Down Expand Up @@ -412,8 +419,8 @@ def leaderboard(
rrel, rp = _recent_leaderboard(s, prefixes, start, end)
search_pred, search_params = "", []
if q:
search_pred = " WHERE lower(name) LIKE ?"
search_params = [f"%{q.strip().lower()}%"]
search_pred = " WHERE lower(name) LIKE ? ESCAPE '\\'"
search_params = [f"%{_like_escape(q.strip().lower())}%"]
con.execute(
f"""
CREATE OR REPLACE TEMP TABLE _lb_agg AS
Expand Down
8 changes: 8 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ def test_leaderboard_page_size_and_sort(con, sources):
assert found["total"] == 1 and found["items"][0]["name"] == "alice"


def test_leaderboard_search_escapes_like_wildcards(con, sources):
# `_` and `%` are LIKE metacharacters: unescaped, `a_ice` matched `alice` and a bare `%` returned
# every contributor. They must match literally.
assert query.leaderboard(con, "hotosm", sources, q="ali")["total"] == 1
assert query.leaderboard(con, "hotosm", sources, q="a_ice")["total"] == 0
assert query.leaderboard(con, "hotosm", sources, q="%")["total"] == 0


def test_leaderboard_includes_per_user_tag_stats(con, sources):
# The frontend reads per-user `tag_stats` (nested {key: {value: {c, m}}}) to show building/highway
# per contributor; regression guard that leaderboard rows carry it across the history+recent seam.
Expand Down
Loading