Skip to content

Commit afd6aa2

Browse files
committed
Ability to truely use pyformat style
1 parent c73d32f commit afd6aa2

File tree

5 files changed

+392
-6
lines changed

5 files changed

+392
-6
lines changed

README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,90 @@ async with async_connection.cursor() as cursor:
4747
rows = await cursor.fetchmany(size=5)
4848
rows = await cursor.fetchall()
4949
```
50+
51+
## Query parameters
52+
53+
### Standard mode (`convert_parameters=True`)
54+
55+
Pass `convert_parameters=True` to `connect()` to use familiar Python DB-API
56+
parameter syntax. The driver will convert placeholders and infer YDB types
57+
from Python values automatically.
58+
59+
**Named parameters**`%(name)s` with a `dict`:
60+
61+
```python
62+
connection = ydb_dbapi.connect(
63+
host="localhost", port="2136", database="/local",
64+
convert_parameters=True,
65+
)
66+
67+
with connection.cursor() as cursor:
68+
cursor.execute(
69+
"SELECT * FROM users WHERE id = %(id)s AND active = %(active)s",
70+
{"id": 42, "active": True},
71+
)
72+
```
73+
74+
**Positional parameters**`%s` with a `list` or `tuple`:
75+
76+
```python
77+
with connection.cursor() as cursor:
78+
cursor.execute(
79+
"INSERT INTO users (id, name, score) VALUES (%s, %s, %s)",
80+
[1, "Alice", 9.8],
81+
)
82+
```
83+
84+
Use `%%` to insert a literal `%` character in the query.
85+
86+
**Automatic type mapping:**
87+
88+
| Python type | YDB type |
89+
|--------------------|-------------|
90+
| `bool` | `Bool` |
91+
| `int` | `Int64` |
92+
| `float` | `Double` |
93+
| `str` | `Utf8` |
94+
| `bytes` | `String` |
95+
| `datetime.datetime`| `Timestamp` |
96+
| `datetime.date` | `Date` |
97+
| `datetime.timedelta`| `Interval` |
98+
| `decimal.Decimal` | `Decimal(22, 9)` |
99+
| `None` | `NULL` (passed as-is) |
100+
101+
**Explicit types with `ydb.TypedValue`:**
102+
103+
When automatic inference is not suitable (e.g. you need `Int32` instead of
104+
`Int64`, or `Json`), wrap the value in `ydb.TypedValue` — it will be passed
105+
through unchanged:
106+
107+
```python
108+
import ydb
109+
110+
with connection.cursor() as cursor:
111+
cursor.execute(
112+
"INSERT INTO events (id, payload) VALUES (%(id)s, %(payload)s)",
113+
{
114+
"id": ydb.TypedValue(99, ydb.PrimitiveType.Int32),
115+
"payload": ydb.TypedValue('{"key": "value"}', ydb.PrimitiveType.Json),
116+
},
117+
)
118+
```
119+
120+
### Native YDB mode (default)
121+
122+
By default (`convert_parameters=False`) the driver passes parameters
123+
directly to the YDB SDK without any transformation. Use `$name` placeholders
124+
in the query and supply a `dict` with `$`-prefixed keys:
125+
126+
```python
127+
connection = ydb_dbapi.connect(
128+
host="localhost", port="2136", database="/local",
129+
)
130+
131+
with connection.cursor() as cursor:
132+
cursor.execute(
133+
"SELECT * FROM users WHERE id = $id",
134+
{"$id": ydb.TypedValue(42, ydb.PrimitiveType.Int64)},
135+
)
136+
```

tests/test_convert_parameters.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
import decimal
5+
6+
import ydb
7+
from ydb_dbapi.utils import convert_query_parameters
8+
9+
10+
class TestNamedStyle:
11+
"""%(name)s placeholders with a dict."""
12+
13+
def test_basic_query_transformation(self):
14+
q, _ = convert_query_parameters(
15+
"SELECT %(id)s FROM t WHERE name = %(name)s",
16+
{"id": 1, "name": "alice"},
17+
)
18+
assert q == "SELECT $id FROM t WHERE name = $name"
19+
20+
def test_keys_prefixed_with_dollar(self):
21+
_, p = convert_query_parameters("SELECT %(x)s", {"x": 1})
22+
assert "$x" in p
23+
assert "x" not in p
24+
25+
def test_int(self):
26+
_, p = convert_query_parameters("SELECT %(x)s", {"x": 42})
27+
assert p["$x"] == ydb.TypedValue(42, ydb.PrimitiveType.Int64)
28+
29+
def test_float(self):
30+
_, p = convert_query_parameters("SELECT %(x)s", {"x": 3.14})
31+
assert p["$x"] == ydb.TypedValue(3.14, ydb.PrimitiveType.Double)
32+
33+
def test_str(self):
34+
_, p = convert_query_parameters("SELECT %(x)s", {"x": "hello"})
35+
assert p["$x"] == ydb.TypedValue("hello", ydb.PrimitiveType.Utf8)
36+
37+
def test_bytes(self):
38+
_, p = convert_query_parameters("SELECT %(x)s", {"x": b"data"})
39+
assert p["$x"] == ydb.TypedValue(b"data", ydb.PrimitiveType.String)
40+
41+
def test_bool(self):
42+
_, p = convert_query_parameters("SELECT %(x)s", {"x": True})
43+
assert p["$x"] == ydb.TypedValue(True, ydb.PrimitiveType.Bool)
44+
45+
def test_bool_not_confused_with_int(self):
46+
# bool is subclass of int — must map to Bool, not Int64
47+
_, p = convert_query_parameters("SELECT %(x)s", {"x": False})
48+
assert p["$x"].value_type == ydb.PrimitiveType.Bool
49+
50+
def test_date(self):
51+
d = datetime.date(2024, 1, 15)
52+
_, p = convert_query_parameters("SELECT %(x)s", {"x": d})
53+
assert p["$x"] == ydb.TypedValue(d, ydb.PrimitiveType.Date)
54+
55+
def test_datetime(self):
56+
tz = datetime.timezone.utc
57+
dt = datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=tz)
58+
_, p = convert_query_parameters("SELECT %(x)s", {"x": dt})
59+
assert p["$x"] == ydb.TypedValue(dt, ydb.PrimitiveType.Timestamp)
60+
61+
def test_datetime_not_confused_with_date(self):
62+
# datetime is subclass of date — must map to Timestamp, not Date
63+
tz = datetime.timezone.utc
64+
dt = datetime.datetime(2024, 6, 1, 0, 0, 0, tzinfo=tz)
65+
_, p = convert_query_parameters("SELECT %(x)s", {"x": dt})
66+
assert p["$x"].value_type == ydb.PrimitiveType.Timestamp
67+
68+
def test_timedelta(self):
69+
td = datetime.timedelta(seconds=60)
70+
_, p = convert_query_parameters("SELECT %(x)s", {"x": td})
71+
assert p["$x"] == ydb.TypedValue(td, ydb.PrimitiveType.Interval)
72+
73+
def test_decimal(self):
74+
d = decimal.Decimal("3.14")
75+
_, p = convert_query_parameters("SELECT %(x)s", {"x": d})
76+
assert isinstance(p["$x"], ydb.TypedValue)
77+
assert p["$x"].value == d
78+
79+
def test_none_passed_as_is(self):
80+
_, p = convert_query_parameters("SELECT %(x)s", {"x": None})
81+
assert p["$x"] is None
82+
83+
def test_unknown_type_passed_as_is(self):
84+
obj = object()
85+
_, p = convert_query_parameters("SELECT %(x)s", {"x": obj})
86+
assert p["$x"] is obj
87+
88+
def test_multiple_params(self):
89+
q, p = convert_query_parameters(
90+
"INSERT INTO t VALUES (%(a)s, %(b)s, %(c)s)",
91+
{"a": 1, "b": "hi", "c": True},
92+
)
93+
assert q == "INSERT INTO t VALUES ($a, $b, $c)"
94+
assert "$a" in p
95+
assert "$b" in p
96+
assert "$c" in p
97+
98+
def test_percent_percent_escape(self):
99+
q, _ = convert_query_parameters(
100+
"SELECT %% as pct, %(x)s", {"x": 1}
101+
)
102+
assert q == "SELECT % as pct, $x"
103+
104+
def test_empty_params(self):
105+
q, p = convert_query_parameters("SELECT 1", {})
106+
assert q == "SELECT 1"
107+
assert p == {}
108+
109+
110+
class TestCustomTypes:
111+
"""Pass-through for ydb.TypedValue (explicit type hint)."""
112+
113+
def test_typed_value_passed_through(self):
114+
tv = ydb.TypedValue(42, ydb.PrimitiveType.Int32)
115+
_, p = convert_query_parameters("SELECT %(x)s", {"x": tv})
116+
assert p["$x"] is tv
117+
118+
def test_typed_value_not_double_wrapped(self):
119+
tv = ydb.TypedValue("hello", ydb.PrimitiveType.Utf8)
120+
_, p = convert_query_parameters("SELECT %(x)s", {"x": tv})
121+
assert isinstance(p["$x"], ydb.TypedValue)
122+
assert p["$x"].value_type == ydb.PrimitiveType.Utf8
123+
124+
def test_typed_value_positional(self):
125+
tv = ydb.TypedValue(99, ydb.PrimitiveType.Int32)
126+
_, p = convert_query_parameters("SELECT %s", [tv])
127+
assert p["$p1"] is tv
128+
129+
def test_unknown_type_passed_as_is(self):
130+
# No TypedValue, no known type — value goes through unchanged
131+
val = object()
132+
_, p = convert_query_parameters("SELECT %(x)s", {"x": val})
133+
assert p["$x"] is val
134+
135+
136+
class TestPositionalStyle:
137+
"""Positional %s placeholders with a list or tuple."""
138+
139+
def test_basic_list(self):
140+
q, p = convert_query_parameters("SELECT %s", [42])
141+
assert q == "SELECT $p1"
142+
assert p["$p1"] == ydb.TypedValue(42, ydb.PrimitiveType.Int64)
143+
144+
def test_basic_tuple(self):
145+
q, p = convert_query_parameters("SELECT %s", (42,))
146+
assert q == "SELECT $p1"
147+
assert p["$p1"] == ydb.TypedValue(42, ydb.PrimitiveType.Int64)
148+
149+
def test_multiple_params_numbered_sequentially(self):
150+
q, p = convert_query_parameters(
151+
"INSERT INTO t VALUES (%s, %s, %s)", [1, "hi", 3.14]
152+
)
153+
assert q == "INSERT INTO t VALUES ($p1, $p2, $p3)"
154+
assert p["$p1"] == ydb.TypedValue(1, ydb.PrimitiveType.Int64)
155+
assert p["$p2"] == ydb.TypedValue("hi", ydb.PrimitiveType.Utf8)
156+
assert p["$p3"] == ydb.TypedValue(3.14, ydb.PrimitiveType.Double)
157+
158+
def test_none_passed_as_is(self):
159+
_, p = convert_query_parameters("SELECT %s", [None])
160+
assert p["$p1"] is None
161+
162+
def test_percent_percent_escape(self):
163+
q, p = convert_query_parameters("SELECT %%, %s", [7])
164+
assert q == "SELECT %, $p1"
165+
assert p["$p1"] == ydb.TypedValue(7, ydb.PrimitiveType.Int64)
166+
167+
def test_empty_list(self):
168+
q, p = convert_query_parameters("SELECT 1", [])
169+
assert q == "SELECT 1"
170+
assert p == {}

ydb_dbapi/connections.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,15 @@ def __init__(
8282
root_certificates_path: str | None = None,
8383
root_certificates: str | None = None,
8484
driver_config_kwargs: dict | None = None,
85+
convert_parameters: bool = False,
8586
**kwargs: Any,
8687
) -> None:
8788
protocol = protocol if protocol else "grpc"
8889
self.endpoint = f"{protocol}://{host}:{port}"
8990
self.credentials = prepare_credentials(credentials)
9091
self.database = database
9192
self.table_path_prefix = ydb_table_path_prefix
93+
self.convert_parameters = convert_parameters
9294

9395
self.connection_kwargs: dict = kwargs
9496

@@ -216,6 +218,7 @@ def __init__(
216218
root_certificates_path: str | None = None,
217219
root_certificates: str | None = None,
218220
driver_config_kwargs: dict | None = None,
221+
convert_parameters: bool = False,
219222
**kwargs: Any,
220223
) -> None:
221224
super().__init__(
@@ -229,6 +232,7 @@ def __init__(
229232
root_certificates_path=root_certificates_path,
230233
root_certificates=root_certificates,
231234
driver_config_kwargs=driver_config_kwargs,
235+
convert_parameters=convert_parameters,
232236
**kwargs,
233237
)
234238
self._current_cursor: Cursor | None = None
@@ -242,6 +246,7 @@ def cursor(self) -> Cursor:
242246
table_path_prefix=self.table_path_prefix,
243247
request_settings=self.request_settings,
244248
retry_settings=self.retry_settings,
249+
convert_parameters=self.convert_parameters,
245250
)
246251

247252
def wait_ready(self, timeout: int = 10) -> None:
@@ -411,6 +416,7 @@ def __init__(
411416
root_certificates_path: str | None = None,
412417
root_certificates: str | None = None,
413418
driver_config_kwargs: dict | None = None,
419+
convert_parameters: bool = False,
414420
**kwargs: Any,
415421
) -> None:
416422
super().__init__(
@@ -424,6 +430,7 @@ def __init__(
424430
root_certificates_path=root_certificates_path,
425431
root_certificates=root_certificates,
426432
driver_config_kwargs=driver_config_kwargs,
433+
convert_parameters=convert_parameters,
427434
**kwargs,
428435
)
429436
self._current_cursor: AsyncCursor | None = None
@@ -437,6 +444,7 @@ def cursor(self) -> AsyncCursor:
437444
table_path_prefix=self.table_path_prefix,
438445
request_settings=self.request_settings,
439446
retry_settings=self.retry_settings,
447+
convert_parameters=self.convert_parameters,
440448
)
441449

442450
async def wait_ready(self, timeout: int = 10) -> None:

0 commit comments

Comments
 (0)