33These tests verify that:
441. 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.
783. Unknown parameter keys raise ProgrammingError.
89"""
910
1011import pytest
1112from 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
1415from 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+
3087class 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+
117207class 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+ )
0 commit comments