Skip to content

Commit 6560ea5

Browse files
Capture RETURNING rows in AsyncAdaptedCursor.executemany
AsyncAdaptedCursor.execute() mirrors the underlying cursor's description and fetchall() into self.description / self._rows when the statement produces a result set. executemany() cleared state up front but never re-read description or rows after the underlying call — it captured only lastrowid and rowcount. With insert_returning and use_insertmanyvalues pinned True in the dialect, async SQLAlchemy users doing ``insert(T).values([...]).returning(T.id)`` silently got empty results: the dbapi cursor accumulated the returned rows (see AsyncCursor.executemany's _ExecuteManyAccumulator) but the adapter dropped them. Mirror execute()'s post-call pattern: if cursor.description is truthy after executemany, set description and drain fetchall() into self._rows via deque; otherwise fall through to the existing lastrowid/rowcount capture. The up-front state reset stays. Tests: unit test verifies description=None DML path still captures lastrowid/rowcount; new test verifies description-populated RETURNING path captures rows in order and exposes description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8f3ec92 commit 6560ea5

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

src/sqlalchemydqlite/aio.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,18 @@ def executemany(self, operation: str, seq_of_parameters: Any) -> None:
8383
cursor = self._connection.cursor()
8484
try:
8585
await_only(cursor.executemany(operation, seq_of_parameters))
86-
self.lastrowid = cursor.lastrowid
87-
self.rowcount = cursor.rowcount
86+
# Mirror execute()'s post-call pattern: if the statement had
87+
# a RETURNING clause, the underlying cursor accumulates rows
88+
# across parameter sets and sets a description. Skipping the
89+
# description/rows capture silently loses every returned row
90+
# when SQLAlchemy's insertmanyvalues + RETURNING path is
91+
# driven through the async engine.
92+
if cursor.description:
93+
self.description = cursor.description
94+
self._rows = deque(await_only(cursor.fetchall()))
95+
else:
96+
self.lastrowid = cursor.lastrowid
97+
self.rowcount = cursor.rowcount
8898
finally:
8999
await_only(cursor.close())
90100

tests/test_async_adapter.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,17 @@ def test_rows_cleared_after_non_query_execute(self) -> None:
5454
assert result is None, f"Expected None after non-query execute, got {result}"
5555

5656
def test_rows_cleared_after_executemany(self) -> None:
57-
"""After executemany(), fetchone() must return None."""
57+
"""After a DML executemany(), fetchone() must return None and the
58+
adapter must reflect the underlying cursor's rowcount / lastrowid.
59+
"""
5860
cursor = _make_cursor()
5961

6062
# Simulate that a previous SELECT populated _rows
6163
cursor._rows = deque([(1, "alice"), (2, "bob")])
6264

6365
mock_inner = MagicMock()
66+
# DML: underlying cursor reports no description.
67+
mock_inner.description = None
6468
mock_inner.lastrowid = 3
6569
mock_inner.rowcount = 2
6670
mock_inner.executemany.return_value = None
@@ -70,8 +74,43 @@ def test_rows_cleared_after_executemany(self) -> None:
7074
with patch("sqlalchemydqlite.aio.await_only", side_effect=_run_sync):
7175
cursor.executemany("INSERT INTO t VALUES (?)", [(1,), (2,)])
7276

73-
result = cursor.fetchone()
74-
assert result is None, f"Expected None after executemany, got {result}"
77+
assert cursor.fetchone() is None
78+
assert cursor.lastrowid == 3
79+
assert cursor.rowcount == 2
80+
assert cursor.description is None
81+
82+
def test_executemany_returning_captures_rows_and_description(self) -> None:
83+
"""When executemany is run with a RETURNING clause, the underlying
84+
cursor accumulates rows across parameter sets. The adapter must
85+
mirror execute()'s pattern: read description and drain fetchall
86+
into self._rows so downstream SQLAlchemy result handling sees the
87+
rows. Before the fix the adapter silently dropped them.
88+
"""
89+
cursor = _make_cursor()
90+
91+
mock_inner = MagicMock()
92+
returned_rows = [(1, "a"), (2, "b"), (3, "c")]
93+
description = [
94+
("id", 1, None, None, None, None, None),
95+
("x", 3, None, None, None, None, None),
96+
]
97+
mock_inner.description = description
98+
mock_inner.executemany.return_value = None
99+
mock_inner.fetchall.return_value = returned_rows
100+
mock_inner.close.return_value = None
101+
cursor._connection.cursor.return_value = mock_inner
102+
103+
with patch("sqlalchemydqlite.aio.await_only", side_effect=_run_sync):
104+
cursor.executemany(
105+
"INSERT INTO t (x) VALUES (?) RETURNING id, x",
106+
[("a",), ("b",), ("c",)],
107+
)
108+
109+
assert cursor.description == description
110+
assert cursor.fetchone() == (1, "a")
111+
assert cursor.fetchone() == (2, "b")
112+
assert cursor.fetchone() == (3, "c")
113+
assert cursor.fetchone() is None
75114

76115

77116
def _has_finally_with_close(func: object) -> bool:

0 commit comments

Comments
 (0)