Skip to content

Commit 93b3ba6

Browse files
authored
Add named parameter support (#795)
Closes #774
1 parent b9dd9c0 commit 93b3ba6

File tree

5 files changed

+112
-1
lines changed

5 files changed

+112
-1
lines changed

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ Changes for crate
55
Unreleased
66
==========
77

8+
- Added named parameter support (``pyformat`` paramstyle). Passing a
9+
:class:`py:dict` as ``parameters`` to ``cursor.execute()`` now accepts
10+
``%(name)s`` placeholders and converts them to positional ``?`` markers
11+
client-side. Positional parameters using ``?`` continue to work unchanged.
12+
813
2026/03/09 2.1.2
914
================
1015

docs/query.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ characters appear, in the order they appear.
5454
Always use the parameter interpolation feature of the client library to
5555
guard against malicious input, as demonstrated in the example above.
5656

57+
Named parameters
58+
----------------
59+
60+
For queries with many parameters or repeated values, named parameters improve
61+
readability. Pass a :class:`py:dict` as the second argument using
62+
``%(name)s`` placeholders:
63+
64+
>>> cursor.execute(
65+
... "INSERT INTO locations (name, date, kind, position) "
66+
... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)",
67+
... {"name": "Einstein Cross", "date": "2007-03-11", "kind": "Quasar", "pos": 7})
68+
69+
The same parameter name may appear multiple times in the query:
70+
71+
>>> cursor.execute(
72+
... "SELECT * FROM locations WHERE name = %(q)s OR kind = %(q)s",
73+
... {"q": "Quasar"})
74+
75+
The client converts the ``%(name)s`` placeholders to positional ``?`` markers
76+
before sending the query to CrateDB, so no server-side changes are required.
77+
78+
.. NOTE::
79+
80+
Named parameters are not yet supported by ``executemany()``. Use
81+
positional ``?`` placeholders with a :class:`py:list` of tuples for bulk
82+
operations.
83+
5784
Bulk inserts
5885
------------
5986

src/crate/client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@
4646
# codeql[py/unused-global-variable]
4747
apilevel = "2.0"
4848
threadsafety = 1
49-
paramstyle = "qmark"
49+
paramstyle = "pyformat"

src/crate/client/cursor.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,51 @@
1818
# However, if you have executed another commercial license agreement
1919
# with Crate these terms will supersede the license and you may use the
2020
# software solely pursuant to the terms of the relevant commercial agreement.
21+
import re
2122
import typing as t
2223
import warnings
2324
from datetime import datetime, timedelta, timezone
2425

2526
from .converter import Converter, DataType
2627
from .exceptions import ProgrammingError
2728

29+
_NAMED_PARAM_RE = re.compile(r"%\((\w+)\)s")
30+
31+
32+
def _convert_named_to_positional(
33+
sql: str, params: t.Dict[str, t.Any]
34+
) -> t.Tuple[str, t.List[t.Any]]:
35+
"""Convert pyformat-style named parameters to positional qmark parameters.
36+
37+
Converts ``%(name)s`` placeholders to ``?`` and returns an ordered list
38+
of corresponding values extracted from ``params``.
39+
40+
The same name may appear multiple times; each occurrence appends the
41+
value to the positional list independently.
42+
43+
Raises ``ProgrammingError`` if a placeholder name is absent from ``params``.
44+
Extra keys in ``params`` are silently ignored.
45+
46+
Example::
47+
48+
sql = "SELECT * FROM t WHERE a = %(a)s AND b = %(b)s"
49+
params = {"a": 1, "b": 2}
50+
# returns: ("SELECT * FROM t WHERE a = ? AND b = ?", [1, 2])
51+
"""
52+
positional: t.List[t.Any] = []
53+
54+
def _replace(match: "re.Match[str]") -> str:
55+
name = match.group(1)
56+
if name not in params:
57+
raise ProgrammingError(
58+
f"Named parameter '{name}' not found in the parameters dict"
59+
)
60+
positional.append(params[name])
61+
return "?"
62+
63+
converted_sql = _NAMED_PARAM_RE.sub(_replace, sql)
64+
return converted_sql, positional
65+
2866

2967
class Cursor:
3068
"""
@@ -54,6 +92,9 @@ def execute(self, sql, parameters=None, bulk_parameters=None):
5492
if self._closed:
5593
raise ProgrammingError("Cursor closed")
5694

95+
if isinstance(parameters, dict):
96+
sql, parameters = _convert_named_to_positional(sql, parameters)
97+
5798
self._result = self.connection.client.sql(
5899
sql, parameters, bulk_parameters
59100
)

tests/client/test_cursor.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,44 @@ def test_execute_with_timezone(mocked_connection):
492492
assert result[0][1].tzname() == "UTC"
493493

494494

495+
def test_execute_with_named_params(mocked_connection):
496+
"""
497+
Verify that named %(name)s parameters are converted to positional ? markers
498+
and the values are passed as an ordered list.
499+
"""
500+
cursor = mocked_connection.cursor()
501+
cursor.execute(
502+
"SELECT * FROM t WHERE a = %(a)s AND b = %(b)s",
503+
{"a": 1, "b": 2},
504+
)
505+
mocked_connection.client.sql.assert_called_once_with(
506+
"SELECT * FROM t WHERE a = ? AND b = ?", [1, 2], None
507+
)
508+
509+
510+
def test_execute_with_named_params_repeated(mocked_connection):
511+
"""
512+
Verify that a parameter name used multiple times in the SQL is resolved
513+
correctly each time it appears.
514+
"""
515+
cursor = mocked_connection.cursor()
516+
cursor.execute("SELECT %(x)s, %(x)s", {"x": 42})
517+
mocked_connection.client.sql.assert_called_once_with(
518+
"SELECT ?, ?", [42, 42], None
519+
)
520+
521+
522+
def test_execute_with_named_params_missing(mocked_connection):
523+
"""
524+
Verify that a ProgrammingError is raised when a placeholder name is absent
525+
from the parameters dict, and that the client is never called.
526+
"""
527+
cursor = mocked_connection.cursor()
528+
with pytest.raises(ProgrammingError, match="Named parameter 'z' not found"):
529+
cursor.execute("SELECT %(z)s", {"a": 1})
530+
mocked_connection.client.sql.assert_not_called()
531+
532+
495533
def test_cursor_close(mocked_connection):
496534
"""
497535
Verify that a cursor is not closed if not specifically closed.

0 commit comments

Comments
 (0)