Skip to content

Commit b548ca1

Browse files
Say "type not supported" when no adapter ran for a bind param
Binding a value of an unsupported type with no registered adapter and no __conform__ hook (e.g. Decimal) raised a DataError claiming "adapter for X produced non-primitive X" — but no adapter ever ran, so the message sent operators hunting for a buggy adapter that does not exist. Track whether an adapter or __conform__ actually transformed the value and emit "type X is not supported" in the no-transform case (mirroring stdlib), reserving the "adapter for X produced ..." wording for a real adapter that returned a non-primitive. The DataError class is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ef6006 commit b548ca1

4 files changed

Lines changed: 85 additions & 12 deletions

‎src/dqlitedbapi/types.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,8 +1055,14 @@ def _convert_bind_param(value: Any) -> Any:
10551055
# ``sqlite3.register_adapter``.
10561056
original_type = type(value)
10571057
adapter = _ADAPTERS.get(original_type)
1058+
# Track whether an adapter or ``__conform__`` actually transformed the
1059+
# value, so the post-chain validation below can tell "a registered
1060+
# adapter returned a non-primitive" apart from "no adapter exists for
1061+
# this type" and emit an accurate diagnostic for each.
1062+
transformed = False
10581063
if adapter is not None:
10591064
value = adapter(value)
1065+
transformed = True
10601066
else:
10611067
# Stdlib parity: fall back to the value's ``__conform__``
10621068
# hook with ``PrepareProtocol`` as the requested protocol
@@ -1108,6 +1114,7 @@ def _convert_bind_param(value: Any) -> Any:
11081114
raise
11091115
if adapted is not None:
11101116
value = adapted
1117+
transformed = True
11111118
# ``datetime.datetime`` is a subclass of ``datetime.date`` but not
11121119
# of ``datetime.time``, so the datetime/date check must fire first
11131120
# for datetime inputs. ``datetime.time`` falls through to its own
@@ -1130,11 +1137,24 @@ def _convert_bind_param(value: Any) -> Any:
11301137
# the diagnostic clarity is the substantive improvement, not the
11311138
# class flip.
11321139
if not isinstance(value, _WIRE_PRIMITIVES):
1140+
if transformed:
1141+
# A registered adapter (or ``__conform__``) ran and returned a
1142+
# non-wire-primitive — name both the input type and the
1143+
# adapter-produced type so the operator can fix the adapter.
1144+
raise DataError(
1145+
f"adapter for {original_type.__name__} produced "
1146+
f"non-primitive {type(value).__name__}; wire layer accepts "
1147+
f"only int / float / str / bytes / bytearray / memoryview / "
1148+
f"bool / None. Register an adapter that returns one of those."
1149+
)
1150+
# No adapter / ``__conform__`` for this type — the value was never
1151+
# transformed. Say so plainly (mirroring stdlib's "type X is not
1152+
# supported") rather than blaming a non-existent adapter.
11331153
raise DataError(
1134-
f"adapter for {original_type.__name__} produced "
1135-
f"non-primitive {type(value).__name__}; wire layer accepts "
1136-
f"only int / float / str / bytes / bytearray / memoryview / "
1137-
f"bool / None. Register an adapter that returns one of those."
1154+
f"type {original_type.__name__} is not supported; wire layer "
1155+
f"accepts only int / float / str / bytes / bytearray / "
1156+
f"memoryview / bool / None. Register an adapter that returns "
1157+
f"one of those."
11381158
)
11391159
# Pre-wire NUL guard: dqlite's wire TEXT is NUL-terminated UTF-8
11401160
# (see ``dqlitewire.encode_text``), so embedded NULs cannot
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""``_convert_bind_param`` distinguishes "no adapter for this type" from
2+
"a registered adapter returned a non-primitive" in its ``DataError``
3+
diagnostic.
4+
5+
A value of an unsupported type with no registered adapter (e.g.
6+
``Decimal``) was never transformed, so the error must say the type is
7+
not supported — NOT falsely claim an adapter ran and misbehaved
8+
(which would send an operator hunting for a non-existent adapter). The
9+
"adapter for X produced non-primitive" wording is reserved for the case
10+
where a real registered adapter (or ``__conform__``) actually returned a
11+
non-wire-primitive. Both wordings keep the ``DataError`` class (a prior
12+
intentional pin); only the message text differs.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from decimal import Decimal
18+
19+
import pytest
20+
21+
from dqlitedbapi import DataError, register_adapter, unregister_adapter
22+
from dqlitedbapi.types import _convert_bind_param
23+
24+
25+
def test_unsupported_type_with_no_adapter_says_not_supported() -> None:
26+
with pytest.raises(DataError) as exc:
27+
_convert_bind_param(Decimal("1.5"))
28+
msg = str(exc.value)
29+
assert "is not supported" in msg
30+
# Must NOT falsely blame an adapter that never ran.
31+
assert "adapter for" not in msg
32+
assert "produced" not in msg
33+
34+
35+
def test_registered_adapter_returning_non_primitive_blames_the_adapter() -> None:
36+
class _Custom:
37+
pass
38+
39+
# A genuinely registered adapter that returns a non-wire-primitive.
40+
register_adapter(_Custom, lambda _v: ["not", "primitive"])
41+
try:
42+
with pytest.raises(DataError) as exc:
43+
_convert_bind_param(_Custom())
44+
finally:
45+
unregister_adapter(_Custom)
46+
msg = str(exc.value)
47+
assert "adapter for _Custom produced" in msg
48+
assert "non-primitive list" in msg

‎tests/test_convert_params_async_cooperative_yield.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,17 @@ def __conform__(self, _protocol: object) -> object:
131131

132132

133133
@pytest.mark.asyncio
134-
async def test_convert_params_async_adapter_failure_wraps_as_data_error() -> None:
135-
"""Exception parity: a documented adapter-misuse failure still wraps
136-
as ``DataError`` on the async path."""
134+
async def test_convert_params_async_unsupported_type_wraps_as_data_error() -> None:
135+
"""Exception parity: an unsupported type (a None-returning
136+
``__conform__`` declines adaptation, so no adapter runs) wraps as
137+
``DataError`` with the "type X is not supported" diagnostic on the
138+
async path — not the "adapter for X produced ..." wording."""
137139

138140
class WithFailingConform:
139141
def __conform__(self, _protocol: object) -> object:
140142
return None
141143

142-
with pytest.raises(DataError, match="adapter for"):
144+
with pytest.raises(DataError, match="is not supported"):
143145
await cursor_mod._convert_params_async([WithFailingConform()])
144146

145147

‎tests/test_convert_params_conform_raise_propagates_unwrapped.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,11 @@ def __conform__(self, _protocol: object) -> object:
7474
# then falls back to its own checks.
7575
return None
7676

77-
# An unknown type with a None-returning conform forces the
78-
# fallback adapter chain to ultimately raise TypeError, which
79-
# the new narrow arm still wraps.
80-
with pytest.raises(DataError, match="adapter for"):
77+
# A None-returning ``__conform__`` declines adaptation, so no
78+
# transform runs and the value reaches the post-chain check as an
79+
# unsupported type — wrapped as ``DataError`` with the "type X is
80+
# not supported" diagnostic (NOT the "adapter for X produced ..."
81+
# wording, which is reserved for a real adapter returning a
82+
# non-primitive).
83+
with pytest.raises(DataError, match="is not supported"):
8184
_convert_params([WithFailingConform()])

0 commit comments

Comments
 (0)