Skip to content

Commit 1ecfbea

Browse files
Document chained-equality idiom for unhashable PEP 249 type sentinels
_DBAPIType objects (STRING/BINARY/NUMBER/DATETIME/ROWID) are intentionally unhashable so that no canonical hash silently violates the hash-eq invariant when a multi-value sentinel (NUMBER == FLOAT_CODE, NUMBER == INT_CODE) is used as a dict key. That decision is correct, but the user-facing introspection idiom — chained equality vs. set membership — was not pinned. A naive caller writing `desc[i][1] in {STRING, NUMBER}` gets a noisy TypeError where the PEP 249 idiom `desc[i][1] == STRING or desc[i][1] == NUMBER` works. Document the chained-equality form in the class docstring and the README "Limitations vs stdlib" section, and add a regression pin for the documented idiom against wire-level ValueType ints. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9df25c commit 1ecfbea

3 files changed

Lines changed: 56 additions & 0 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,24 @@ borrowed from one.
153153
`sqlite3`'s implicit-transaction model — see Transactions above.
154154
- **SERIALIZABLE isolation only.** Every statement is ordered by Raft;
155155
weaker isolation levels aren't exposed.
156+
- **PEP 249 type sentinels (`STRING`, `BINARY`, `NUMBER`, `DATETIME`,
157+
`ROWID`) are unhashable.** Use chained equality against
158+
`description[i][1]`, NOT set/dict membership:
159+
160+
```python
161+
type_code = cur.description[i][1]
162+
if type_code == STRING or type_code == NUMBER: # OK
163+
...
164+
if type_code in {STRING, NUMBER}: # raises TypeError
165+
...
166+
```
167+
168+
The sentinels wrap multiple wire type codes (`NUMBER` covers
169+
INTEGER+FLOAT+BOOLEAN, `DATETIME` covers DATE+TIMESTAMP+ISO8601),
170+
so no canonical hash can satisfy the Python hash-eq invariant.
171+
Stdlib `sqlite3` doesn't export these sentinels at all, so the
172+
chained-equality form is the cross-driver-portable idiom.
173+
156174
- **`WITH ... INSERT/UPDATE/DELETE` (CTE-prefixed pure DML) reports
157175
zero `rowcount` and no `lastrowid`.** The driver dispatches between
158176
the row-returning and execute paths via a prefix-based heuristic;

src/dqlitedbapi/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,19 @@ class _DBAPIType:
202202
would make ``{NUMBER: x}[FLOAT_CODE]`` raise ``KeyError`` despite
203203
``NUMBER == FLOAT_CODE`` being True. Refusing to hash turns that
204204
silent miss into a noisy ``TypeError``.
205+
206+
**Caller idiom** — introspecting ``description[i][1]`` against
207+
type sentinels: use chained equality, NOT set membership::
208+
209+
type_code = cur.description[i][1]
210+
if type_code == STRING or type_code == NUMBER:
211+
...
212+
213+
Set/dict membership (``type_code in {STRING, NUMBER}``) raises
214+
``TypeError: unhashable type`` for the reason above. The
215+
chained-``==`` form is the PEP 249 idiom and works against this
216+
driver and stdlib ``sqlite3`` (which does not export these
217+
sentinels at all).
205218
"""
206219

207220
def __init__(self, *values: str | int | ValueType, _name: str = "") -> None:

tests/test_dbapi_type_hash_consistency.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ def test_cannot_be_dict_keys(self) -> None:
3939
dict([(NUMBER, "x")]) # noqa: C406 -- literal triggers B018
4040

4141

42+
class TestDocumentedIdiom:
43+
"""Pin the chained-equality idiom documented in the class
44+
docstring and README. Without this pin a future refactor that
45+
implements ``__hash__ = lambda self: id(self)`` (which would
46+
re-enable ``in {...}`` syntactically) would silently break
47+
multi-value sentinel dispatch — set membership would fall back
48+
to identity, missing every legitimate ``ValueType`` int.
49+
"""
50+
51+
def test_type_in_set_raises_typeerror(self) -> None:
52+
# The shape that PEP 249 callers might naively try first.
53+
with pytest.raises(TypeError, match="unhashable"):
54+
_ = STRING in {STRING, NUMBER}
55+
56+
def test_chained_equality_is_the_documented_idiom(self) -> None:
57+
from dqlitewire.constants import ValueType
58+
59+
# Wire type code as seen in description[i][1].
60+
type_code = int(ValueType.TEXT)
61+
assert type_code == STRING or type_code == NUMBER # noqa: PLR1714
62+
63+
type_code = int(ValueType.INTEGER)
64+
assert type_code == STRING or type_code == NUMBER # noqa: PLR1714
65+
66+
4267
class TestDbapiTypesDistinct:
4368
"""Distinct _DBAPIType instances must not collide under eq."""
4469

0 commit comments

Comments
 (0)