Skip to content

Commit 7f5ccee

Browse files
Make Row a sequence like sqlite3.Row instead of a mapping
dqlitedbapi.Row subclassed collections.abc.Mapping, so iterating it (tuple(row), list(row), for v in row, a, b = row) yielded column NAMES. stdlib sqlite3.Row — the documented 1:1 porting target — is a sequence whose iteration yields VALUES, so ported code silently got names instead of data with no error. (The mapping __contains__ was also buggy, leaking IndexError/TypeError for out-of-range/bool keys.) Drop the Mapping base and yield values from __iter__, matching stdlib; add slice indexing (row[0:2]); keep keys(), int/str indexing, len, eq, hash, repr. dict(row) and **row still work because CPython consumes keys() + __getitem__, not __iter__. This restores the row factory's originally specified sequence shape. Add a side-by-side parity test against a real sqlite3.Row covering iteration, slicing, membership, and name/dict access. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2b8e05e commit 7f5ccee

3 files changed

Lines changed: 72 additions & 23 deletions

File tree

src/dqlitedbapi/row.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
at the first fetch with ``DataError("row_factory call failed:
77
argument 1 must be sqlite3.Cursor, not Cursor")``. ``dqlitedbapi.Row``
88
is the same surface (positional + column-name indexing, ``.keys()``,
9-
``dict(row)`` conversion, full ``Mapping`` protocol) without the
10-
cursor-type constraint.
9+
``dict(row)`` conversion) without the cursor-type constraint.
1110
1211
Use the same idiom on dqlite as on stdlib::
1312
@@ -16,23 +15,27 @@
1615
row = cur.fetchone()
1716
assert row["x"] == 1
1817
assert row[0] == 1
18+
assert tuple(row) == (1, 2)
1919
assert list(row.keys()) == ["x", "y"]
2020
assert dict(row) == {"x": 1, "y": 2}
2121
"""
2222

2323
from __future__ import annotations
2424

25-
from collections.abc import Iterator, Mapping
25+
from collections.abc import Iterator
2626
from typing import Any, final
2727

2828

2929
@final
30-
class Row(Mapping[str, Any]):
30+
class Row:
3131
"""Stdlib ``sqlite3.Row``-equivalent row factory.
3232
33-
Wraps a result tuple with column-name and positional access.
34-
Implements the full ``collections.abc.Mapping`` protocol so
35-
``dict(row)``, ``**row``-spread, and ``row.items()`` all work.
33+
Wraps a result tuple with column-name and positional access. Like
34+
stdlib ``sqlite3.Row`` it is a SEQUENCE, not a mapping: iteration
35+
(``tuple(row)`` / ``list(row)`` / ``for v in row`` / ``a, b = row``)
36+
yields VALUES, and ``row[i]`` / ``row[i:j]`` index positionally.
37+
``row["name"]`` and ``.keys()`` provide column-name access, and
38+
``dict(row)`` / ``**row`` work via ``keys()`` + ``__getitem__``.
3639
3740
Unlike stdlib ``sqlite3.Row``, this class accepts any cursor
3841
object that exposes a ``description`` attribute (the canonical
@@ -58,6 +61,10 @@ def __init__(self, cursor: object, row: tuple[Any, ...]) -> None:
5861
self._values: tuple[Any, ...] = tuple(row)
5962

6063
def __getitem__(self, key: object) -> Any:
64+
# Slice indexing returns a tuple of values, matching stdlib
65+
# ``sqlite3.Row`` (``row[0:2]`` -> ``(v0, v1)``).
66+
if isinstance(key, slice):
67+
return self._values[key]
6168
# ``bool`` is an ``int`` subclass: without the explicit
6269
# exclusion ``row[True]`` would silently return column 1 and
6370
# ``row[False]`` column 0. A ``bool`` index is almost always a
@@ -75,11 +82,12 @@ def __getitem__(self, key: object) -> Any:
7582
return self._values[idx]
7683
raise TypeError(f"Row indices must be int or str, not {type(key).__name__}")
7784

78-
def __iter__(self) -> Iterator[str]:
79-
# ``Mapping.__iter__`` yields keys; ``dict(row)`` consumes
80-
# this. Matches stdlib ``sqlite3.Row.keys()`` behaviour but
81-
# via the canonical Mapping protocol.
82-
return iter(self._columns)
85+
def __iter__(self) -> Iterator[Any]:
86+
# Sequence iteration yields VALUES, matching stdlib
87+
# ``sqlite3.Row`` (``tuple(row)`` / ``list(row)`` / ``a, b =
88+
# row``). ``dict(row)`` and ``**row`` do NOT use this — CPython
89+
# consumes ``keys()`` + ``__getitem__`` when ``keys()`` exists.
90+
return iter(self._values)
8391

8492
def __len__(self) -> int:
8593
return len(self._values)
@@ -92,9 +100,10 @@ def __eq__(self, other: object) -> bool:
92100
def __hash__(self) -> int:
93101
return hash((self._columns, self._values))
94102

95-
def keys(self) -> tuple[str, ...]: # type: ignore[override]
103+
def keys(self) -> tuple[str, ...]:
96104
"""Return the column names tuple — matches stdlib
97-
``sqlite3.Row.keys()`` shape (list-like).
105+
``sqlite3.Row.keys()`` shape (list-like). Used by ``dict(row)``
106+
and ``**row`` for name-keyed access.
98107
"""
99108
return self._columns
100109

tests/test_row_factory_sqlite3_equivalent.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Pin: ``dqlitedbapi.Row`` is the cross-driver-portable equivalent
2-
of stdlib ``sqlite3.Row`` — positional + column-name access,
3-
``Mapping`` protocol, ``dict(row)`` / ``**row`` spread, without
4-
the cursor-type constraint that makes stdlib's class unusable
5-
on this driver.
2+
of stdlib ``sqlite3.Row`` — a SEQUENCE (value iteration, positional +
3+
slice indexing) plus column-name access via ``row["name"]`` /
4+
``.keys()`` and ``dict(row)`` / ``**row`` spread, without the
5+
cursor-type constraint that makes stdlib's class unusable on this
6+
driver.
67
78
Cross-driver porting: swap ``sqlite3.Row`` for ``dqlitedbapi.Row``
89
1:1; every documented stdlib idiom holds.
@@ -71,8 +72,8 @@ def test_row_equality() -> None:
7172

7273

7374
def test_row_double_spread_into_kwargs() -> None:
74-
"""The ``**row`` spread requires the Mapping protocol to work
75-
on string keys.
75+
"""The ``**row`` spread works on string keys via ``keys()`` +
76+
``__getitem__`` (CPython consumes those, not ``__iter__``).
7677
"""
7778
cur = _cursor_with_description("a", "b")
7879
row = Row(cur, (10, 20))
@@ -135,3 +136,42 @@ def test_row_repr() -> None:
135136
row = Row(cur, (1, 2))
136137
assert "x=1" in repr(row)
137138
assert "y=2" in repr(row)
139+
140+
141+
def test_row_sqlite3_sequence_parity() -> None:
142+
"""``dqlitedbapi.Row`` matches stdlib ``sqlite3.Row``'s SEQUENCE
143+
semantics exactly: iteration / tuple / list / unpacking yield
144+
VALUES (not column names), positional + slice indexing work, and
145+
``in`` checks values — while name access, ``dict(row)``, ``**row``
146+
and ``keys()`` still behave identically. Pins the regression where
147+
Row subclassed ``Mapping`` and silently iterated column names.
148+
"""
149+
import sqlite3
150+
151+
cur = _cursor_with_description("x", "y")
152+
row = Row(cur, (1, 2))
153+
154+
con = sqlite3.connect(":memory:")
155+
con.row_factory = sqlite3.Row
156+
std = con.execute("SELECT 1 AS x, 2 AS y").fetchone()
157+
con.close()
158+
159+
# Sequence iteration yields values (the corrupted idioms).
160+
assert tuple(row) == tuple(std) == (1, 2)
161+
assert list(row) == list(std) == [1, 2]
162+
assert [v for v in row] == [1, 2]
163+
a, b = row
164+
assert (a, b) == (1, 2)
165+
# Positional + slice indexing.
166+
assert row[0] == std[0] == 1
167+
assert row[1] == std[1] == 2
168+
assert row[0:2] == std[0:2] == (1, 2)
169+
# Membership checks values, matching stdlib (not column names).
170+
assert (2 in row) is (2 in std) is True
171+
assert ("x" in row) is ("x" in std) is False
172+
# Name-keyed access and mapping conversions remain identical.
173+
assert row["x"] == std["x"] == 1
174+
assert tuple(row.keys()) == tuple(std.keys()) == ("x", "y")
175+
assert dict(row) == dict(std) == {"x": 1, "y": 2}
176+
assert {**row} == {"x": 1, "y": 2}
177+
assert len(row) == len(std) == 2

tests/test_row_hash_eq_empty.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
"""Pin: ``dqlitedbapi.Row`` Mapping-protocol surface that prior tests
2-
did not cover — hashability, ``__eq__`` vs a non-``Row``, and
3-
construction from a cursor with ``description=None``.
1+
"""Pin: ``dqlitedbapi.Row`` surface that prior tests did not cover —
2+
hashability, ``__eq__`` vs a non-``Row``, and construction from a
3+
cursor with ``description=None``.
44
55
``Row`` is a ``@final`` documented stdlib-``sqlite3.Row`` replacement.
66
Existing tests cover positional/name indexing, ``dict(row)``,

0 commit comments

Comments
 (0)