Skip to content

Commit ab5eb59

Browse files
committed
fix: skip %-formatting in cursor.execute() when no parameters are provided
Queries containing literal percent signs (e.g. LIKE '%good') would raise TypeError/ValueError because Python's % operator tried to interpret them as format specifiers even when no parameters were passed. Only apply parameter substitution when parameters is non-empty.
1 parent 88f7474 commit ab5eb59

File tree

2 files changed

+89
-1
lines changed

2 files changed

+89
-1
lines changed

tests/test_cursor.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 when no parameters are provided.
6+
2. Parameter substitution still works when parameters are provided.
7+
"""
8+
9+
from unittest.mock import MagicMock
10+
11+
from wherobots.db.cursor import Cursor
12+
13+
14+
def _make_cursor():
15+
"""Create a Cursor with a mock exec_fn that captures the SQL sent."""
16+
captured = {}
17+
18+
def mock_exec_fn(sql, handler, store):
19+
captured["sql"] = sql
20+
return "exec-1"
21+
22+
mock_cancel_fn = MagicMock()
23+
cursor = Cursor(mock_exec_fn, mock_cancel_fn)
24+
return cursor, captured
25+
26+
27+
class TestCursorExecuteParameterSubstitution:
28+
"""Tests for parameter substitution in cursor.execute()."""
29+
30+
def test_like_percent_without_parameters(self):
31+
"""A query with a LIKE '%...' pattern and no parameters should not
32+
raise a ValueError from Python's % string formatting."""
33+
cursor, captured = _make_cursor()
34+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
35+
cursor.execute(sql)
36+
assert captured["sql"] == sql
37+
38+
def test_like_percent_at_end_without_parameters(self):
39+
"""A query with a trailing percent in LIKE should work without parameters."""
40+
cursor, captured = _make_cursor()
41+
sql = "SELECT * FROM table WHERE name LIKE 'good%'"
42+
cursor.execute(sql)
43+
assert captured["sql"] == sql
44+
45+
def test_like_double_percent_without_parameters(self):
46+
"""A query with percent on both sides in LIKE should work without parameters."""
47+
cursor, captured = _make_cursor()
48+
sql = "SELECT * FROM table WHERE name LIKE '%good%'"
49+
cursor.execute(sql)
50+
assert captured["sql"] == sql
51+
52+
def test_multiple_percent_patterns_without_parameters(self):
53+
"""A query with multiple LIKE clauses containing percents should work."""
54+
cursor, captured = _make_cursor()
55+
sql = "SELECT * FROM t WHERE a LIKE '%foo%' AND b LIKE '%bar'"
56+
cursor.execute(sql)
57+
assert captured["sql"] == sql
58+
59+
def test_parameters_none_with_percent_in_query(self):
60+
"""Explicitly passing parameters=None with a percent-containing query
61+
should not raise."""
62+
cursor, captured = _make_cursor()
63+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
64+
cursor.execute(sql, parameters=None)
65+
assert captured["sql"] == sql
66+
67+
def test_empty_parameters_with_percent_in_query(self):
68+
"""Passing an empty dict as parameters with a percent-containing query
69+
should not raise."""
70+
cursor, captured = _make_cursor()
71+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
72+
cursor.execute(sql, parameters={})
73+
assert captured["sql"] == sql
74+
75+
def test_parameter_substitution_works(self):
76+
"""Named parameter substitution should still work correctly."""
77+
cursor, captured = _make_cursor()
78+
sql = "SELECT * FROM table WHERE id = %(id)s"
79+
cursor.execute(sql, parameters={"id": 42})
80+
assert captured["sql"] == "SELECT * FROM table WHERE id = 42"
81+
82+
def test_plain_query_without_parameters(self):
83+
"""A simple query with no percent signs and no parameters should work."""
84+
cursor, captured = _make_cursor()
85+
sql = "SELECT * FROM table"
86+
cursor.execute(sql)
87+
assert captured["sql"] == sql

wherobots/db/cursor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ def execute(
9898
self.__rowcount = -1
9999
self.__description = None
100100

101+
sql = operation % parameters if parameters else operation
101102
self.__current_execution_id = self.__exec_fn(
102-
operation % (parameters or {}), self.__on_execution_result, store
103+
sql, self.__on_execution_result, store
103104
)
104105

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

0 commit comments

Comments
 (0)