Skip to content

Commit 7815cbd

Browse files
executemany([]) clears prior SELECT state
ISSUE-34 — executemany with an empty seq_of_parameters used to leave _description / _rows untouched from any preceding SELECT on the same cursor. A caller checking cursor.description after executemany([]) saw stale column metadata from the prior query — a subtle trap. Now both sync Cursor._executemany_async and AsyncCursor.executemany clear description/rows/row_index before the loop, so an empty parameter sequence leaves the cursor in a clean "no result set" state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dcabd08 commit 7815cbd

File tree

3 files changed

+62
-2
lines changed

3 files changed

+62
-2
lines changed

src/dqlitedbapi/aio/cursor.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,17 @@ async def execute(
121121
async def executemany(
122122
self, operation: str, seq_of_parameters: Sequence[Sequence[Any]]
123123
) -> "AsyncCursor":
124-
"""Execute a database operation multiple times."""
124+
"""Execute a database operation multiple times.
125+
126+
An empty ``seq_of_parameters`` must not leave stale SELECT
127+
state around: reset description / rows so callers can't
128+
confuse an empty executemany with a preceding SELECT.
129+
"""
125130
self._check_closed()
126131

132+
self._description = None
133+
self._rows = []
134+
self._row_index = 0
127135
total_affected = 0
128136
for params in seq_of_parameters:
129137
await self.execute(operation, params)

src/dqlitedbapi/cursor.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,16 @@ def executemany(self, operation: str, seq_of_parameters: Sequence[Sequence[Any]]
240240
async def _executemany_async(
241241
self, operation: str, seq_of_parameters: Sequence[Sequence[Any]]
242242
) -> None:
243-
"""Async implementation of executemany."""
243+
"""Async implementation of executemany.
244+
245+
An empty ``seq_of_parameters`` must not leave stale SELECT
246+
state around: reset description / rows to None / empty so
247+
callers can't confuse an empty executemany with a preceding
248+
SELECT.
249+
"""
250+
self._description = None
251+
self._rows = []
252+
self._row_index = 0
244253
total_affected = 0
245254
for params in seq_of_parameters:
246255
await self._execute_async(operation, params)

tests/test_executemany_empty.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""executemany([]) doesn't leak stale SELECT state (ISSUE-34)."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
from dqlitedbapi.cursor import Cursor
6+
7+
8+
def _cursor_with_prior_select() -> Cursor:
9+
conn = MagicMock()
10+
c = Cursor(conn)
11+
c._description = [("id", None, None, None, None, None, None)]
12+
c._rows = [(1,), (2,)]
13+
c._rowcount = 2
14+
return c
15+
16+
17+
class TestExecutemanyEmpty:
18+
def test_empty_executemany_clears_description(self) -> None:
19+
"""After executemany([]) the cursor must not appear to hold a
20+
prior SELECT result."""
21+
c = _cursor_with_prior_select()
22+
# Mock _run_sync so we don't need a loop.
23+
c._connection._run_sync = MagicMock(side_effect=lambda coro: coro.close() or None)
24+
c._connection._check_thread = MagicMock()
25+
26+
# Directly drive _executemany_async — it's the code we're
27+
# verifying. Run it synchronously via a throwaway event loop.
28+
import asyncio
29+
30+
asyncio.run(_run_empty(c))
31+
32+
assert c.description is None
33+
assert c._rows == []
34+
assert c.rowcount == 0
35+
36+
37+
async def _run_empty(c: Cursor) -> None:
38+
# Stub _execute_async so we don't need the full connection pathway.
39+
async def _noop(*_a: object, **_kw: object) -> None:
40+
return None
41+
42+
c._execute_async = _noop # type: ignore[method-assign]
43+
await c._executemany_async("INSERT INTO t VALUES (?)", [])

0 commit comments

Comments
 (0)