Skip to content

Commit 73599bd

Browse files
committed
fix: use regex-based pyformat substitution in cursor.execute()
Replace Python's % string formatting with a regex that only matches %(name)s tokens, leaving literal percent characters (e.g. LIKE '%good') untouched. This fixes TypeError/ValueError when SQL contains % wildcards. Also adds a PEP 249 compliance section to the README documenting the module-level globals (apilevel, threadsafety, paramstyle) and parameterized query usage.
1 parent 99b12f5 commit 73599bd

File tree

3 files changed

+218
-1
lines changed

3 files changed

+218
-1
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@ 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:
23+
24+
```python
25+
curr.execute(
26+
"SELECT * FROM places WHERE id = %(id)s AND category = %(cat)s",
27+
parameters={"id": 42, "cat": "restaurant"},
28+
)
29+
```
30+
31+
Literal `%` characters in SQL (e.g. `LIKE` wildcards) do not need
32+
escaping and work alongside parameters:
33+
34+
```python
35+
curr.execute(
36+
"SELECT * FROM places WHERE name LIKE '%coffee%' AND city = %(city)s",
37+
parameters={"city": "Seattle"},
38+
)
39+
```
40+
741
## Installation
842

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

tests/test_cursor.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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.
7+
3. Unknown parameter keys raise ProgrammingError.
8+
"""
9+
10+
import pytest
11+
from unittest.mock import MagicMock
12+
13+
from wherobots.db.cursor import Cursor, _substitute_parameters
14+
from wherobots.db.errors import ProgrammingError
15+
16+
17+
def _make_cursor():
18+
"""Create a Cursor with a mock exec_fn that captures the SQL sent."""
19+
captured = {}
20+
21+
def mock_exec_fn(sql, handler, store):
22+
captured["sql"] = sql
23+
return "exec-1"
24+
25+
mock_cancel_fn = MagicMock()
26+
cursor = Cursor(mock_exec_fn, mock_cancel_fn)
27+
return cursor, captured
28+
29+
30+
class TestCursorExecuteParameterSubstitution:
31+
"""Tests for pyformat parameter substitution in cursor.execute()."""
32+
33+
def test_like_percent_without_parameters(self):
34+
"""A query with a LIKE '%...' pattern and no parameters should not
35+
raise from Python's % string formatting."""
36+
cursor, captured = _make_cursor()
37+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
38+
cursor.execute(sql)
39+
assert captured["sql"] == sql
40+
41+
def test_like_percent_at_end_without_parameters(self):
42+
"""A query with a trailing percent in LIKE should work without parameters."""
43+
cursor, captured = _make_cursor()
44+
sql = "SELECT * FROM table WHERE name LIKE 'good%'"
45+
cursor.execute(sql)
46+
assert captured["sql"] == sql
47+
48+
def test_like_double_percent_without_parameters(self):
49+
"""A query with percent on both sides in LIKE should work without parameters."""
50+
cursor, captured = _make_cursor()
51+
sql = "SELECT * FROM table WHERE name LIKE '%good%'"
52+
cursor.execute(sql)
53+
assert captured["sql"] == sql
54+
55+
def test_multiple_percent_patterns_without_parameters(self):
56+
"""A query with multiple LIKE clauses containing percents should work."""
57+
cursor, captured = _make_cursor()
58+
sql = "SELECT * FROM t WHERE a LIKE '%foo%' AND b LIKE '%bar'"
59+
cursor.execute(sql)
60+
assert captured["sql"] == sql
61+
62+
def test_parameters_none_with_percent_in_query(self):
63+
"""Explicitly passing parameters=None with a percent-containing query
64+
should not raise."""
65+
cursor, captured = _make_cursor()
66+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
67+
cursor.execute(sql, parameters=None)
68+
assert captured["sql"] == sql
69+
70+
def test_empty_parameters_with_percent_in_query(self):
71+
"""Passing an empty dict as parameters with a percent-containing query
72+
should not raise."""
73+
cursor, captured = _make_cursor()
74+
sql = "SELECT * FROM table WHERE name LIKE '%good'"
75+
cursor.execute(sql, parameters={})
76+
assert captured["sql"] == sql
77+
78+
def test_parameter_substitution_works(self):
79+
"""Named pyformat parameter substitution should work correctly."""
80+
cursor, captured = _make_cursor()
81+
sql = "SELECT * FROM table WHERE id = %(id)s"
82+
cursor.execute(sql, parameters={"id": 42})
83+
assert captured["sql"] == "SELECT * FROM table WHERE id = 42"
84+
85+
def test_multiple_parameters(self):
86+
"""Multiple named parameters should all be substituted."""
87+
cursor, captured = _make_cursor()
88+
sql = "SELECT * FROM t WHERE id = %(id)s AND name = %(name)s"
89+
cursor.execute(sql, parameters={"id": 1, "name": "alice"})
90+
assert captured["sql"] == "SELECT * FROM t WHERE id = 1 AND name = alice"
91+
92+
def test_like_with_parameters(self):
93+
"""A LIKE expression with literal percent signs should work alongside
94+
named parameters without requiring %% escaping."""
95+
cursor, captured = _make_cursor()
96+
sql = "SELECT * FROM table WHERE name LIKE '%good%' AND id = %(id)s"
97+
cursor.execute(sql, parameters={"id": 42})
98+
assert captured["sql"] == (
99+
"SELECT * FROM table WHERE name LIKE '%good%' AND id = 42"
100+
)
101+
102+
def test_plain_query_without_parameters(self):
103+
"""A simple query with no percent signs and no parameters should work."""
104+
cursor, captured = _make_cursor()
105+
sql = "SELECT * FROM table"
106+
cursor.execute(sql)
107+
assert captured["sql"] == sql
108+
109+
def test_unknown_parameter_raises(self):
110+
"""Referencing a parameter key not in the dict should raise ProgrammingError."""
111+
cursor, _ = _make_cursor()
112+
sql = "SELECT * FROM table WHERE id = %(missing)s"
113+
with pytest.raises(ProgrammingError, match="missing"):
114+
cursor.execute(sql, parameters={"id": 42})
115+
116+
117+
class TestSubstituteParameters:
118+
"""Unit tests for the _substitute_parameters helper directly."""
119+
120+
def test_no_parameters_returns_operation_unchanged(self):
121+
sql = "SELECT * FROM t WHERE name LIKE '%test%'"
122+
assert _substitute_parameters(sql, None) == sql
123+
124+
def test_empty_dict_returns_operation_unchanged(self):
125+
sql = "SELECT * FROM t WHERE name LIKE '%test%'"
126+
assert _substitute_parameters(sql, {}) == sql
127+
128+
def test_substitutes_named_param(self):
129+
sql = "SELECT * FROM t WHERE id = %(id)s"
130+
assert _substitute_parameters(sql, {"id": 99}) == (
131+
"SELECT * FROM t WHERE id = 99"
132+
)
133+
134+
def test_preserves_literal_percent_with_params(self):
135+
sql = "SELECT * FROM t WHERE name LIKE '%foo%' AND id = %(id)s"
136+
assert _substitute_parameters(sql, {"id": 1}) == (
137+
"SELECT * FROM t WHERE name LIKE '%foo%' AND id = 1"
138+
)
139+
140+
def test_unknown_key_raises_programming_error(self):
141+
sql = "SELECT * FROM t WHERE id = %(nope)s"
142+
with pytest.raises(ProgrammingError, match="nope"):
143+
_substitute_parameters(sql, {"id": 1})
144+
145+
def test_repeated_param_substituted_everywhere(self):
146+
sql = "SELECT * FROM t WHERE a = %(v)s OR b = %(v)s"
147+
assert _substitute_parameters(sql, {"v": 7}) == (
148+
"SELECT * FROM t WHERE a = 7 OR b = 7"
149+
)
150+
151+
def test_bare_percent_s_not_treated_as_param(self):
152+
"""A bare %s (format-style, not pyformat) should be left untouched."""
153+
sql = "SELECT * FROM t WHERE id = %s"
154+
assert _substitute_parameters(sql, {"id": 1}) == sql

wherobots/db/cursor.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
11
import queue
2+
import re
23
from typing import Any, List, Tuple, Dict
34

45
from .errors import ProgrammingError
56
from .models import ExecutionResult, Store, StoreResult
67

8+
# Matches pyformat parameter markers: %(name)s
9+
_PYFORMAT_RE = re.compile(r"%\(([^)]+)\)s")
10+
11+
12+
def _substitute_parameters(
13+
operation: str, parameters: Dict[str, Any] | None
14+
) -> str:
15+
"""Substitute pyformat parameters into a SQL operation string.
16+
17+
Uses regex to match only %(name)s tokens, leaving literal percent
18+
characters (e.g. SQL LIKE wildcards) untouched.
19+
"""
20+
if not parameters:
21+
return operation
22+
23+
def replacer(match: re.Match) -> str:
24+
key = match.group(1)
25+
if key not in parameters:
26+
raise ProgrammingError(
27+
f"Parameter '{key}' not found in provided parameters"
28+
)
29+
return str(parameters[key])
30+
31+
return _PYFORMAT_RE.sub(replacer, operation)
32+
33+
734
_TYPE_MAP = {
835
"object": "STRING",
936
"int64": "NUMBER",
@@ -99,7 +126,9 @@ def execute(
99126
self.__description = None
100127

101128
self.__current_execution_id = self.__exec_fn(
102-
operation % (parameters or {}), self.__on_execution_result, store
129+
_substitute_parameters(operation, parameters),
130+
self.__on_execution_result,
131+
store,
103132
)
104133

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

0 commit comments

Comments
 (0)