Skip to content

Commit 61a5d28

Browse files
committed
Add convert_named_to_positional() method to support pyformat-style named parameters
Tests: Add tests to cover new feature with backward compatibility
1 parent 8835494 commit 61a5d28

4 files changed

Lines changed: 194 additions & 0 deletions

File tree

src/crate/client/cursor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from .converter import Converter, DataType
2626
from .exceptions import ProgrammingError
27+
from .params import convert_named_to_positional
2728

2829

2930
class Cursor:
@@ -54,6 +55,9 @@ def execute(self, sql, parameters=None, bulk_parameters=None):
5455
if self._closed:
5556
raise ProgrammingError("Cursor closed")
5657

58+
if isinstance(parameters, dict):
59+
sql, parameters = convert_named_to_positional(sql, parameters)
60+
5761
self._result = self.connection.client.sql(
5862
sql, parameters, bulk_parameters
5963
)

src/crate/client/params.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# -*- coding: utf-8; -*-
2+
#
3+
# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
4+
# license agreements. See the NOTICE file distributed with this work for
5+
# additional information regarding copyright ownership. Crate licenses
6+
# this file to you under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License. You may
8+
# obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15+
# License for the specific language governing permissions and limitations
16+
# under the License.
17+
#
18+
# However, if you have executed another commercial license agreement
19+
# with Crate these terms will supersede the license and you may use the
20+
# software solely pursuant to the terms of the relevant commercial agreement.
21+
import re
22+
import typing as t
23+
24+
from .exceptions import ProgrammingError
25+
26+
_NAMED_PARAM_RE = re.compile(r"%\((\w+)\)s")
27+
28+
29+
def convert_named_to_positional(
30+
sql: str, params: t.Dict[str, t.Any]
31+
) -> t.Tuple[str, t.List[t.Any]]:
32+
"""Convert pyformat-style named parameters to positional qmark parameters.
33+
34+
Converts ``%(name)s`` placeholders to ``?`` and returns an ordered list
35+
of corresponding values extracted from ``params``.
36+
37+
The same name may appear multiple times; each occurrence appends the
38+
value to the positional list independently.
39+
40+
Raises ``ProgrammingError`` if a placeholder name is absent from ``params``.
41+
Extra keys in ``params`` are silently ignored.
42+
43+
Example::
44+
45+
sql = "SELECT * FROM t WHERE a = %(a)s AND b = %(b)s"
46+
params = {"a": 1, "b": 2}
47+
# returns: ("SELECT * FROM t WHERE a = ? AND b = ?", [1, 2])
48+
"""
49+
positional: t.List[t.Any] = []
50+
51+
def _replace(match: "re.Match[str]") -> str:
52+
name = match.group(1)
53+
if name not in params:
54+
raise ProgrammingError(
55+
f"Named parameter '{name}' not found in the parameters dict"
56+
)
57+
positional.append(params[name])
58+
return "?"
59+
60+
converted_sql = _NAMED_PARAM_RE.sub(_replace, sql)
61+
return converted_sql, positional

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.

tests/client/test_params.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# -*- coding: utf-8; -*-
2+
#
3+
# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
4+
# license agreements. See the NOTICE file distributed with this work for
5+
# additional information regarding copyright ownership. Crate licenses
6+
# this file to you under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License. You may
8+
# obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15+
# License for the specific language governing permissions and limitations
16+
# under the License.
17+
#
18+
# However, if you have executed another commercial license agreement
19+
# with Crate these terms will supersede the license and you may use the
20+
# software solely pursuant to the terms of the relevant commercial agreement.
21+
22+
import pytest
23+
24+
from crate.client.exceptions import ProgrammingError
25+
from crate.client.params import convert_named_to_positional
26+
27+
28+
def test_basic_conversion():
29+
"""Named placeholders are replaced with ? and values are ordered."""
30+
sql, params = convert_named_to_positional(
31+
"SELECT * FROM t WHERE a = %(a)s AND b = %(b)s",
32+
{"a": 1, "b": 2},
33+
)
34+
assert sql == "SELECT * FROM t WHERE a = ? AND b = ?"
35+
assert params == [1, 2]
36+
37+
38+
def test_repeated_param():
39+
"""The same name appearing multiple times appends the value each time."""
40+
sql, params = convert_named_to_positional(
41+
"SELECT %(x)s, %(x)s",
42+
{"x": 42},
43+
)
44+
assert sql == "SELECT ?, ?"
45+
assert params == [42, 42]
46+
47+
48+
def test_missing_param_raises():
49+
"""A placeholder without a matching key raises ProgrammingError."""
50+
with pytest.raises(ProgrammingError, match="Named parameter 'z' not found"):
51+
convert_named_to_positional("SELECT %(z)s", {"a": 1})
52+
53+
54+
def test_extra_params_ignored():
55+
"""Extra keys in the params dict cause no error."""
56+
sql, params = convert_named_to_positional(
57+
"SELECT %(a)s",
58+
{"a": 10, "b": 99, "c": "unused"},
59+
)
60+
assert sql == "SELECT ?"
61+
assert params == [10]
62+
63+
64+
def test_no_named_params():
65+
"""SQL without %(...)s placeholders is returned unchanged."""
66+
sql, params = convert_named_to_positional(
67+
"SELECT * FROM t WHERE a = ?",
68+
{},
69+
)
70+
assert sql == "SELECT * FROM t WHERE a = ?"
71+
assert params == []
72+
73+
74+
def test_various_value_types():
75+
"""Different value types (str, int, float, None, bool) are handled."""
76+
sql, params = convert_named_to_positional(
77+
"INSERT INTO t VALUES (%(s)s, %(i)s, %(f)s, %(n)s, %(b)s)",
78+
{"s": "hello", "i": 7, "f": 3.14, "n": None, "b": True},
79+
)
80+
assert sql == "INSERT INTO t VALUES (?, ?, ?, ?, ?)"
81+
assert params == ["hello", 7, 3.14, None, True]
82+
83+
84+
def test_preserves_surrounding_text():
85+
"""Non-placeholder text in the SQL is not modified."""
86+
sql, params = convert_named_to_positional(
87+
"SELECT name FROM locations WHERE name = %(name)s ORDER BY name",
88+
{"name": "Algol"},
89+
)
90+
assert sql == "SELECT name FROM locations WHERE name = ? ORDER BY name"
91+
assert params == ["Algol"]

0 commit comments

Comments
 (0)