Skip to content

Commit 93f496d

Browse files
authored
Merge pull request #61 from james-willis/fix/cursor-percent-in-like-queries
fix: use regex-based pyformat substitution in cursor.execute()
2 parents 99b12f5 + 9c25300 commit 93f496d

File tree

3 files changed

+366
-1
lines changed

3 files changed

+366
-1
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ Python DB-API implementation for Wherobots DB. This package implements a
44
PEP-0249 compatible driver to programmatically connect to a Wherobots DB
55
runtime and execute Spatial SQL queries.
66

7+
## PEP 249 DB-API 2.0 Compliance
8+
9+
This driver implements the [PEP 249](https://peps.python.org/pep-0249/)
10+
Python Database API Specification v2.0 and exposes the following
11+
module-level globals:
12+
13+
| Global | Value | Meaning |
14+
|---|---|---|
15+
| `apilevel` | `"2.0"` | Supports DB-API 2.0 |
16+
| `threadsafety` | `1` | Threads may share the module, but not connections |
17+
| `paramstyle` | `"pyformat"` | Uses `%(name)s` named parameter markers |
18+
19+
### Parameterized queries
20+
21+
Use `%(name)s` markers in your SQL and pass a dictionary of parameter
22+
values. The driver automatically quotes and escapes values based on
23+
their Python type (strings are single-quoted, `None` becomes `NULL`,
24+
booleans become `TRUE`/`FALSE`, and numeric types are passed through
25+
unquoted):
26+
27+
```python
28+
curr.execute(
29+
"SELECT * FROM places WHERE id = %(id)s AND category = %(cat)s",
30+
parameters={"id": 42, "cat": "restaurant"},
31+
)
32+
# Produces: ... WHERE id = 42 AND category = 'restaurant'
33+
```
34+
35+
Literal `%` characters in SQL (e.g. `LIKE` wildcards) do not need
36+
escaping and work alongside parameters:
37+
38+
```python
39+
curr.execute(
40+
"SELECT * FROM places WHERE name LIKE '%coffee%' AND city = %(city)s",
41+
parameters={"city": "Seattle"},
42+
)
43+
```
44+
745
## Installation
846

947
To add this library as a dependency in your Python project, use `uv add`

tests/test_cursor.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Tests for Cursor class behavior.
2+
3+
These tests verify that:
4+
1. SQL queries containing literal percent signs (e.g., LIKE '%good') work
5+
correctly regardless of whether parameters are provided.
6+
2. Pyformat parameter substitution (%(name)s) works correctly with
7+
type-aware SQL quoting.
8+
3. Unknown parameter keys raise ProgrammingError.
9+
"""
10+
11+
from datetime import date
12+
13+
import pytest
14+
from unittest.mock import MagicMock
15+
16+
from wherobots.db.cursor import Cursor, _substitute_parameters, _quote_value
17+
from wherobots.db.errors import ProgrammingError
18+
19+
20+
def _make_cursor():
21+
"""Create a Cursor with a mock exec_fn that captures the SQL sent."""
22+
captured = {}
23+
24+
def mock_exec_fn(sql, handler, store):
25+
captured["sql"] = sql
26+
return "exec-1"
27+
28+
mock_cancel_fn = MagicMock()
29+
cursor = Cursor(mock_exec_fn, mock_cancel_fn)
30+
return cursor, captured
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# _quote_value unit tests
35+
# ---------------------------------------------------------------------------
36+
37+
38+
class TestQuoteValue:
39+
"""Unit tests for the _quote_value helper."""
40+
41+
def test_none(self):
42+
assert _quote_value(None) == "NULL"
43+
44+
def test_bool_true(self):
45+
assert _quote_value(True) == "TRUE"
46+
47+
def test_bool_false(self):
48+
assert _quote_value(False) == "FALSE"
49+
50+
def test_int(self):
51+
assert _quote_value(42) == "42"
52+
53+
def test_negative_int(self):
54+
assert _quote_value(-7) == "-7"
55+
56+
def test_float(self):
57+
assert _quote_value(3.14) == "3.14"
58+
59+
def test_string(self):
60+
assert _quote_value("hello") == "'hello'"
61+
62+
def test_string_with_single_quote(self):
63+
assert _quote_value("it's") == "'it''s'"
64+
65+
def test_string_with_multiple_quotes(self):
66+
assert _quote_value("a'b'c") == "'a''b''c'"
67+
68+
def test_empty_string(self):
69+
assert _quote_value("") == "''"
70+
71+
def test_bytes(self):
72+
assert _quote_value(b"\xde\xad") == "X'dead'"
73+
74+
def test_empty_bytes(self):
75+
assert _quote_value(b"") == "X''"
76+
77+
def test_non_primitive_uses_str(self):
78+
"""Non-primitive types fall through to str() and get quoted as strings."""
79+
assert _quote_value(date(2024, 1, 15)) == "'2024-01-15'"
80+
81+
def test_nan_raises(self):
82+
with pytest.raises(ProgrammingError, match="Cannot convert float"):
83+
_quote_value(float("nan"))
84+
85+
def test_inf_raises(self):
86+
with pytest.raises(ProgrammingError, match="Cannot convert float"):
87+
_quote_value(float("inf"))
88+
89+
def test_negative_inf_raises(self):
90+
with pytest.raises(ProgrammingError, match="Cannot convert float"):
91+
_quote_value(float("-inf"))
92+
93+
94+
# ---------------------------------------------------------------------------
95+
# cursor.execute() end-to-end tests
96+
# ---------------------------------------------------------------------------
97+
98+
99+
class TestCursorExecuteParameterSubstitution:
100+
"""Tests for pyformat parameter substitution in cursor.execute()."""
101+
102+
def test_like_percent_without_parameters(self):
103+
"""A query with a LIKE '%...' pattern and no parameters should not
104+
raise from Python's % string formatting."""
105+
cursor, captured = _make_cursor()
106+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
107+
cursor.execute(sql)
108+
assert captured["sql"] == sql
109+
110+
def test_like_percent_at_end_without_parameters(self):
111+
"""A query with a trailing percent in LIKE should work without parameters."""
112+
cursor, captured = _make_cursor()
113+
sql = "SELECT * FROM table WHERE name LIKE 'good%'"
114+
cursor.execute(sql)
115+
assert captured["sql"] == sql
116+
117+
def test_like_double_percent_without_parameters(self):
118+
"""A query with percent on both sides in LIKE should work without parameters."""
119+
cursor, captured = _make_cursor()
120+
sql = "SELECT * FROM table WHERE name LIKE '%good%'"
121+
cursor.execute(sql)
122+
assert captured["sql"] == sql
123+
124+
def test_multiple_percent_patterns_without_parameters(self):
125+
"""A query with multiple LIKE clauses containing percents should work."""
126+
cursor, captured = _make_cursor()
127+
sql = "SELECT * FROM t WHERE a LIKE '%foo%' AND b LIKE '%bar'"
128+
cursor.execute(sql)
129+
assert captured["sql"] == sql
130+
131+
def test_parameters_none_with_percent_in_query(self):
132+
"""Explicitly passing parameters=None with a percent-containing query
133+
should not raise."""
134+
cursor, captured = _make_cursor()
135+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
136+
cursor.execute(sql, parameters=None)
137+
assert captured["sql"] == sql
138+
139+
def test_empty_parameters_with_percent_in_query(self):
140+
"""Passing an empty dict as parameters with a percent-containing query
141+
should not raise."""
142+
cursor, captured = _make_cursor()
143+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
144+
cursor.execute(sql, parameters={})
145+
assert captured["sql"] == sql
146+
147+
def test_parameter_substitution_works(self):
148+
"""Named pyformat parameter substitution should work correctly."""
149+
cursor, captured = _make_cursor()
150+
sql = "SELECT * FROM table WHERE id = %(id)s"
151+
cursor.execute(sql, parameters={"id": 42})
152+
assert captured["sql"] == "SELECT * FROM table WHERE id = 42"
153+
154+
def test_multiple_parameters(self):
155+
"""Multiple named parameters should all be substituted with proper quoting."""
156+
cursor, captured = _make_cursor()
157+
sql = "SELECT * FROM t WHERE id = %(id)s AND name = %(name)s"
158+
cursor.execute(sql, parameters={"id": 1, "name": "alice"})
159+
assert captured["sql"] == "SELECT * FROM t WHERE id = 1 AND name = 'alice'"
160+
161+
def test_like_with_parameters(self):
162+
"""A LIKE expression with literal percent signs should work alongside
163+
named parameters without requiring %% escaping."""
164+
cursor, captured = _make_cursor()
165+
sql = "SELECT * FROM table WHERE name LIKE '%good%' AND id = %(id)s"
166+
cursor.execute(sql, parameters={"id": 42})
167+
assert captured["sql"] == (
168+
"SELECT * FROM table WHERE name LIKE '%good%' AND id = 42"
169+
)
170+
171+
def test_string_parameter_is_quoted(self):
172+
"""String parameters should be single-quoted in the output SQL."""
173+
cursor, captured = _make_cursor()
174+
sql = "SELECT * FROM t WHERE category = %(cat)s"
175+
cursor.execute(sql, parameters={"cat": "restaurant"})
176+
assert captured["sql"] == "SELECT * FROM t WHERE category = 'restaurant'"
177+
178+
def test_none_parameter_becomes_null(self):
179+
"""None parameters should become SQL NULL."""
180+
cursor, captured = _make_cursor()
181+
sql = "SELECT * FROM t WHERE deleted_at = %(val)s"
182+
cursor.execute(sql, parameters={"val": None})
183+
assert captured["sql"] == "SELECT * FROM t WHERE deleted_at = NULL"
184+
185+
def test_bool_parameter(self):
186+
"""Boolean parameters should become TRUE/FALSE."""
187+
cursor, captured = _make_cursor()
188+
sql = "SELECT * FROM t WHERE active = %(flag)s"
189+
cursor.execute(sql, parameters={"flag": True})
190+
assert captured["sql"] == "SELECT * FROM t WHERE active = TRUE"
191+
192+
def test_string_with_quote_is_escaped(self):
193+
"""Single quotes in string parameters should be escaped."""
194+
cursor, captured = _make_cursor()
195+
sql = "SELECT * FROM t WHERE name = %(name)s"
196+
cursor.execute(sql, parameters={"name": "O'Brien"})
197+
assert captured["sql"] == "SELECT * FROM t WHERE name = 'O''Brien'"
198+
199+
def test_plain_query_without_parameters(self):
200+
"""A simple query with no percent signs and no parameters should work."""
201+
cursor, captured = _make_cursor()
202+
sql = "SELECT * FROM table"
203+
cursor.execute(sql)
204+
assert captured["sql"] == sql
205+
206+
def test_unknown_parameter_raises(self):
207+
"""Referencing a parameter key not in the dict should raise ProgrammingError."""
208+
cursor, _ = _make_cursor()
209+
sql = "SELECT * FROM table WHERE id = %(missing)s"
210+
with pytest.raises(ProgrammingError, match="missing"):
211+
cursor.execute(sql, parameters={"id": 42})
212+
213+
214+
# ---------------------------------------------------------------------------
215+
# _substitute_parameters unit tests
216+
# ---------------------------------------------------------------------------
217+
218+
219+
class TestSubstituteParameters:
220+
"""Unit tests for the _substitute_parameters helper directly."""
221+
222+
def test_no_parameters_returns_operation_unchanged(self):
223+
sql = "SELECT * FROM t WHERE name LIKE '%test%'"
224+
assert _substitute_parameters(sql, None) == sql
225+
226+
def test_empty_dict_returns_operation_unchanged(self):
227+
sql = "SELECT * FROM t WHERE name LIKE '%test%'"
228+
assert _substitute_parameters(sql, {}) == sql
229+
230+
def test_substitutes_named_param(self):
231+
sql = "SELECT * FROM t WHERE id = %(id)s"
232+
assert _substitute_parameters(sql, {"id": 99}) == (
233+
"SELECT * FROM t WHERE id = 99"
234+
)
235+
236+
def test_preserves_literal_percent_with_params(self):
237+
sql = "SELECT * FROM t WHERE name LIKE '%foo%' AND id = %(id)s"
238+
assert _substitute_parameters(sql, {"id": 1}) == (
239+
"SELECT * FROM t WHERE name LIKE '%foo%' AND id = 1"
240+
)
241+
242+
def test_unknown_key_raises_programming_error(self):
243+
sql = "SELECT * FROM t WHERE id = %(nope)s"
244+
with pytest.raises(ProgrammingError, match="nope"):
245+
_substitute_parameters(sql, {"id": 1})
246+
247+
def test_repeated_param_substituted_everywhere(self):
248+
sql = "SELECT * FROM t WHERE a = %(v)s OR b = %(v)s"
249+
assert _substitute_parameters(sql, {"v": 7}) == (
250+
"SELECT * FROM t WHERE a = 7 OR b = 7"
251+
)
252+
253+
def test_bare_percent_s_not_treated_as_param(self):
254+
"""A bare %s (format-style, not pyformat) should be left untouched."""
255+
sql = "SELECT * FROM t WHERE id = %s"
256+
assert _substitute_parameters(sql, {"id": 1}) == sql
257+
258+
def test_string_param_is_quoted(self):
259+
sql = "SELECT * FROM t WHERE name = %(name)s"
260+
assert _substitute_parameters(sql, {"name": "alice"}) == (
261+
"SELECT * FROM t WHERE name = 'alice'"
262+
)
263+
264+
def test_string_param_escapes_quotes(self):
265+
sql = "SELECT * FROM t WHERE name = %(name)s"
266+
assert _substitute_parameters(sql, {"name": "it's"}) == (
267+
"SELECT * FROM t WHERE name = 'it''s'"
268+
)
269+
270+
def test_none_param_becomes_null(self):
271+
sql = "SELECT * FROM t WHERE val = %(v)s"
272+
assert _substitute_parameters(sql, {"v": None}) == (
273+
"SELECT * FROM t WHERE val = NULL"
274+
)

wherobots/db/cursor.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,60 @@
1+
import math
12
import queue
3+
import re
24
from typing import Any, List, Tuple, Dict
35

46
from .errors import ProgrammingError
57
from .models import ExecutionResult, Store, StoreResult
68

9+
# Matches pyformat parameter markers: %(name)s
10+
_PYFORMAT_RE = re.compile(r"%\(([^)]+)\)s")
11+
12+
13+
def _quote_value(value: Any) -> str:
14+
"""Convert a Python value to a SQL literal string.
15+
16+
Handles quoting and escaping so that the interpolated SQL is syntactically
17+
correct and safe from trivial injection.
18+
"""
19+
if value is None:
20+
return "NULL"
21+
# bool must be checked before int because bool is a subclass of int
22+
if isinstance(value, bool):
23+
return "TRUE" if value else "FALSE"
24+
if isinstance(value, (int, float)):
25+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
26+
raise ProgrammingError(
27+
f"Cannot convert float value {value!r} to SQL literal"
28+
)
29+
return str(value)
30+
if isinstance(value, bytes):
31+
return "X'" + value.hex() + "'"
32+
# Everything else (str, date, datetime, etc.) is treated as a string literal
33+
return "'" + str(value).replace("'", "''") + "'"
34+
35+
36+
def _substitute_parameters(operation: str, parameters: Dict[str, Any] | None) -> str:
37+
"""Substitute pyformat parameters into a SQL operation string.
38+
39+
Uses regex to match only %(name)s tokens, leaving literal percent
40+
characters (e.g. SQL LIKE wildcards) untouched. Values are quoted
41+
according to their Python type so the resulting SQL is syntactically
42+
correct (see :func:`_quote_value`).
43+
"""
44+
if not parameters:
45+
return operation
46+
47+
def replacer(match: re.Match) -> str:
48+
key = match.group(1)
49+
if key not in parameters:
50+
raise ProgrammingError(
51+
f"Parameter '{key}' not found in provided parameters"
52+
)
53+
return _quote_value(parameters[key])
54+
55+
return _PYFORMAT_RE.sub(replacer, operation)
56+
57+
758
_TYPE_MAP = {
859
"object": "STRING",
960
"int64": "NUMBER",
@@ -99,7 +150,9 @@ def execute(
99150
self.__description = None
100151

101152
self.__current_execution_id = self.__exec_fn(
102-
operation % (parameters or {}), self.__on_execution_result, store
153+
_substitute_parameters(operation, parameters),
154+
self.__on_execution_result,
155+
store,
103156
)
104157

105158
def get_store_result(self) -> StoreResult | None:

0 commit comments

Comments
 (0)