Skip to content

Commit e23f553

Browse files
committed
Add type-aware SQL quoting for pyformat parameter substitution
String values are now single-quoted with internal quotes escaped, None becomes NULL, booleans become TRUE/FALSE, bytes become X'hex', and numeric types pass through unquoted. This complies with the PEP 249 requirement that the driver handle proper quoting for client-side parameter interpolation.
1 parent 2f09228 commit e23f553

File tree

3 files changed

+140
-7
lines changed

3 files changed

+140
-7
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ module-level globals:
1919
### Parameterized queries
2020

2121
Use `%(name)s` markers in your SQL and pass a dictionary of parameter
22-
values:
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):
2326

2427
```python
2528
curr.execute(
2629
"SELECT * FROM places WHERE id = %(id)s AND category = %(cat)s",
2730
parameters={"id": 42, "cat": "restaurant"},
2831
)
32+
# Produces: ... WHERE id = 42 AND category = 'restaurant'
2933
```
3034

3135
Literal `%` characters in SQL (e.g. `LIKE` wildcards) do not need

tests/test_cursor.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
These tests verify that:
44
1. SQL queries containing literal percent signs (e.g., LIKE '%good') work
55
correctly regardless of whether parameters are provided.
6-
2. Pyformat parameter substitution (%(name)s) works correctly.
6+
2. Pyformat parameter substitution (%(name)s) works correctly with
7+
type-aware SQL quoting.
78
3. Unknown parameter keys raise ProgrammingError.
89
"""
910

1011
import pytest
1112
from unittest.mock import MagicMock
1213

13-
from wherobots.db.cursor import Cursor, _substitute_parameters
14+
from wherobots.db.cursor import Cursor, _substitute_parameters, _quote_value
1415
from wherobots.db.errors import ProgrammingError
1516

1617

@@ -27,6 +28,62 @@ def mock_exec_fn(sql, handler, store):
2728
return cursor, captured
2829

2930

31+
# ---------------------------------------------------------------------------
32+
# _quote_value unit tests
33+
# ---------------------------------------------------------------------------
34+
35+
36+
class TestQuoteValue:
37+
"""Unit tests for the _quote_value helper."""
38+
39+
def test_none(self):
40+
assert _quote_value(None) == "NULL"
41+
42+
def test_bool_true(self):
43+
assert _quote_value(True) == "TRUE"
44+
45+
def test_bool_false(self):
46+
assert _quote_value(False) == "FALSE"
47+
48+
def test_int(self):
49+
assert _quote_value(42) == "42"
50+
51+
def test_negative_int(self):
52+
assert _quote_value(-7) == "-7"
53+
54+
def test_float(self):
55+
assert _quote_value(3.14) == "3.14"
56+
57+
def test_string(self):
58+
assert _quote_value("hello") == "'hello'"
59+
60+
def test_string_with_single_quote(self):
61+
assert _quote_value("it's") == "'it''s'"
62+
63+
def test_string_with_multiple_quotes(self):
64+
assert _quote_value("a'b'c") == "'a''b''c'"
65+
66+
def test_empty_string(self):
67+
assert _quote_value("") == "''"
68+
69+
def test_bytes(self):
70+
assert _quote_value(b"\xde\xad") == "X'dead'"
71+
72+
def test_empty_bytes(self):
73+
assert _quote_value(b"") == "X''"
74+
75+
def test_non_primitive_uses_str(self):
76+
"""Non-primitive types fall through to str() and get quoted as strings."""
77+
from datetime import date
78+
79+
assert _quote_value(date(2024, 1, 15)) == "'2024-01-15'"
80+
81+
82+
# ---------------------------------------------------------------------------
83+
# cursor.execute() end-to-end tests
84+
# ---------------------------------------------------------------------------
85+
86+
3087
class TestCursorExecuteParameterSubstitution:
3188
"""Tests for pyformat parameter substitution in cursor.execute()."""
3289

@@ -83,11 +140,11 @@ def test_parameter_substitution_works(self):
83140
assert captured["sql"] == "SELECT * FROM table WHERE id = 42"
84141

85142
def test_multiple_parameters(self):
86-
"""Multiple named parameters should all be substituted."""
143+
"""Multiple named parameters should all be substituted with proper quoting."""
87144
cursor, captured = _make_cursor()
88145
sql = "SELECT * FROM t WHERE id = %(id)s AND name = %(name)s"
89146
cursor.execute(sql, parameters={"id": 1, "name": "alice"})
90-
assert captured["sql"] == "SELECT * FROM t WHERE id = 1 AND name = alice"
147+
assert captured["sql"] == "SELECT * FROM t WHERE id = 1 AND name = 'alice'"
91148

92149
def test_like_with_parameters(self):
93150
"""A LIKE expression with literal percent signs should work alongside
@@ -99,6 +156,34 @@ def test_like_with_parameters(self):
99156
"SELECT * FROM table WHERE name LIKE '%good%' AND id = 42"
100157
)
101158

159+
def test_string_parameter_is_quoted(self):
160+
"""String parameters should be single-quoted in the output SQL."""
161+
cursor, captured = _make_cursor()
162+
sql = "SELECT * FROM t WHERE category = %(cat)s"
163+
cursor.execute(sql, parameters={"cat": "restaurant"})
164+
assert captured["sql"] == "SELECT * FROM t WHERE category = 'restaurant'"
165+
166+
def test_none_parameter_becomes_null(self):
167+
"""None parameters should become SQL NULL."""
168+
cursor, captured = _make_cursor()
169+
sql = "SELECT * FROM t WHERE deleted_at = %(val)s"
170+
cursor.execute(sql, parameters={"val": None})
171+
assert captured["sql"] == "SELECT * FROM t WHERE deleted_at = NULL"
172+
173+
def test_bool_parameter(self):
174+
"""Boolean parameters should become TRUE/FALSE."""
175+
cursor, captured = _make_cursor()
176+
sql = "SELECT * FROM t WHERE active = %(flag)s"
177+
cursor.execute(sql, parameters={"flag": True})
178+
assert captured["sql"] == "SELECT * FROM t WHERE active = TRUE"
179+
180+
def test_string_with_quote_is_escaped(self):
181+
"""Single quotes in string parameters should be escaped."""
182+
cursor, captured = _make_cursor()
183+
sql = "SELECT * FROM t WHERE name = %(name)s"
184+
cursor.execute(sql, parameters={"name": "O'Brien"})
185+
assert captured["sql"] == "SELECT * FROM t WHERE name = 'O''Brien'"
186+
102187
def test_plain_query_without_parameters(self):
103188
"""A simple query with no percent signs and no parameters should work."""
104189
cursor, captured = _make_cursor()
@@ -114,6 +199,11 @@ def test_unknown_parameter_raises(self):
114199
cursor.execute(sql, parameters={"id": 42})
115200

116201

202+
# ---------------------------------------------------------------------------
203+
# _substitute_parameters unit tests
204+
# ---------------------------------------------------------------------------
205+
206+
117207
class TestSubstituteParameters:
118208
"""Unit tests for the _substitute_parameters helper directly."""
119209

@@ -152,3 +242,21 @@ def test_bare_percent_s_not_treated_as_param(self):
152242
"""A bare %s (format-style, not pyformat) should be left untouched."""
153243
sql = "SELECT * FROM t WHERE id = %s"
154244
assert _substitute_parameters(sql, {"id": 1}) == sql
245+
246+
def test_string_param_is_quoted(self):
247+
sql = "SELECT * FROM t WHERE name = %(name)s"
248+
assert _substitute_parameters(sql, {"name": "alice"}) == (
249+
"SELECT * FROM t WHERE name = 'alice'"
250+
)
251+
252+
def test_string_param_escapes_quotes(self):
253+
sql = "SELECT * FROM t WHERE name = %(name)s"
254+
assert _substitute_parameters(sql, {"name": "it's"}) == (
255+
"SELECT * FROM t WHERE name = 'it''s'"
256+
)
257+
258+
def test_none_param_becomes_null(self):
259+
sql = "SELECT * FROM t WHERE val = %(v)s"
260+
assert _substitute_parameters(sql, {"v": None}) == (
261+
"SELECT * FROM t WHERE val = NULL"
262+
)

wherobots/db/cursor.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,32 @@
99
_PYFORMAT_RE = re.compile(r"%\(([^)]+)\)s")
1010

1111

12+
def _quote_value(value: Any) -> str:
13+
"""Convert a Python value to a SQL literal string.
14+
15+
Handles quoting and escaping so that the interpolated SQL is syntactically
16+
correct and safe from trivial injection.
17+
"""
18+
if value is None:
19+
return "NULL"
20+
# bool must be checked before int because bool is a subclass of int
21+
if isinstance(value, bool):
22+
return "TRUE" if value else "FALSE"
23+
if isinstance(value, (int, float)):
24+
return str(value)
25+
if isinstance(value, bytes):
26+
return "X'" + value.hex() + "'"
27+
# Everything else (str, date, datetime, etc.) is treated as a string literal
28+
return "'" + str(value).replace("'", "''") + "'"
29+
30+
1231
def _substitute_parameters(operation: str, parameters: Dict[str, Any] | None) -> str:
1332
"""Substitute pyformat parameters into a SQL operation string.
1433
1534
Uses regex to match only %(name)s tokens, leaving literal percent
16-
characters (e.g. SQL LIKE wildcards) untouched.
35+
characters (e.g. SQL LIKE wildcards) untouched. Values are quoted
36+
according to their Python type so the resulting SQL is syntactically
37+
correct (see :func:`_quote_value`).
1738
"""
1839
if not parameters:
1940
return operation
@@ -24,7 +45,7 @@ def replacer(match: re.Match) -> str:
2445
raise ProgrammingError(
2546
f"Parameter '{key}' not found in provided parameters"
2647
)
27-
return str(parameters[key])
48+
return _quote_value(parameters[key])
2849

2950
return _PYFORMAT_RE.sub(replacer, operation)
3051

0 commit comments

Comments
 (0)