Skip to content

Commit 73cdb5b

Browse files
Merge pull request #57 from NirrWorks/fix/leaderboard-search-wildcards
2 parents 8bd2229 + b00c36e commit 73cdb5b

2 files changed

Lines changed: 17 additions & 2 deletions

File tree

osmsg/query.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ def _rows(result) -> list[dict[str, Any]]:
4848
return [dict(zip(cols, r, strict=True)) for r in result.fetchall()]
4949

5050

51+
def _like_escape(value: str) -> str:
52+
"""Escape LIKE metacharacters so a contributor search matches them literally. Without this `_`
53+
matches any character and `%` any sequence, so searching `a_il` returns `Anil` and `%` returns
54+
everyone. Paired with `ESCAPE '\\'` at the call site."""
55+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
56+
57+
5158
def _prefixes(hashtag: str | list[str]) -> list[tuple[str, str]]:
5259
"""Normalize one hashtag or many into deduped `(lo, hi)` prefix-range pairs, case-insensitive and
5360
order-preserving. Each hashtag matches as a prefix; the scope is the union across them."""
@@ -412,8 +419,8 @@ def leaderboard(
412419
rrel, rp = _recent_leaderboard(s, prefixes, start, end)
413420
search_pred, search_params = "", []
414421
if q:
415-
search_pred = " WHERE lower(name) LIKE ?"
416-
search_params = [f"%{q.strip().lower()}%"]
422+
search_pred = " WHERE lower(name) LIKE ? ESCAPE '\\'"
423+
search_params = [f"%{_like_escape(q.strip().lower())}%"]
417424
con.execute(
418425
f"""
419426
CREATE OR REPLACE TEMP TABLE _lb_agg AS

tests/test_query.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ def test_leaderboard_page_size_and_sort(con, sources):
8787
assert found["total"] == 1 and found["items"][0]["name"] == "alice"
8888

8989

90+
def test_leaderboard_search_escapes_like_wildcards(con, sources):
91+
# `_` and `%` are LIKE metacharacters: unescaped, `a_ice` matched `alice` and a bare `%` returned
92+
# every contributor. They must match literally.
93+
assert query.leaderboard(con, "hotosm", sources, q="ali")["total"] == 1
94+
assert query.leaderboard(con, "hotosm", sources, q="a_ice")["total"] == 0
95+
assert query.leaderboard(con, "hotosm", sources, q="%")["total"] == 0
96+
97+
9098
def test_leaderboard_includes_per_user_tag_stats(con, sources):
9199
# The frontend reads per-user `tag_stats` (nested {key: {value: {c, m}}}) to show building/highway
92100
# per contributor; regression guard that leaderboard rows carry it across the history+recent seam.

0 commit comments

Comments
 (0)