Skip to content

Commit 0423e4c

Browse files
Copy rowcount and lastrowid on the async adapter RETURNING path
AsyncAdaptedCursor.execute and executemany cleared rowcount and lastrowid at the top of each call and only re-populated them in the non-description (DML) branch. The underlying AsyncCursor also sets rowcount = len(rows) on the RETURNING path (and lastrowid on INSERT ... RETURNING), so discarding those values left result.rowcount pinned at -1 and silently collapsed the PEP 249 "affected rows" signal into "not determinable" whenever a statement returned rows. Mirror the DML-branch assignment after the fetch-first-then-assign block so the atomicity pattern (ISSUE-252) is preserved: a fetchall() raise still leaves description / _rows / rowcount / lastrowid consistent with the pre-call cleared state. Both execute() and executemany() pick the change up; existing executemany-RETURNING test is extended to assert rowcount / lastrowid and a new test pins the single-execute case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a738963 commit 0423e4c

2 files changed

Lines changed: 52 additions & 0 deletions

File tree

src/sqlalchemydqlite/aio.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ def execute(self, operation: str, parameters: Any = None) -> None:
9797
fetched = deque(await_only(cursor.fetchall()))
9898
self.description = cursor.description
9999
self._rows = fetched
100+
# Mirror the DML branch: rowcount / lastrowid are set by
101+
# the underlying cursor on the RETURNING path too
102+
# (rowcount = len(rows); lastrowid from the last
103+
# INSERT). SQLAlchemy's Result layer reads both through
104+
# the adapter, so leaving rowcount at -1 would silently
105+
# collapse "N rows returned" into "not determinable".
106+
self.rowcount = cursor.rowcount
107+
self.lastrowid = cursor.lastrowid
100108
else:
101109
self.lastrowid = cursor.lastrowid
102110
self.rowcount = cursor.rowcount
@@ -127,6 +135,12 @@ def executemany(self, operation: str, seq_of_parameters: Iterable[Sequence[Any]]
127135
fetched = deque(await_only(cursor.fetchall()))
128136
self.description = cursor.description
129137
self._rows = fetched
138+
# Mirror execute()'s RETURNING path: rowcount /
139+
# lastrowid are accumulated by the underlying cursor
140+
# across parameter sets and must flow through the
141+
# adapter so SQLAlchemy's Result layer sees them.
142+
self.rowcount = cursor.rowcount
143+
self.lastrowid = cursor.lastrowid
130144
else:
131145
self.lastrowid = cursor.lastrowid
132146
self.rowcount = cursor.rowcount

tests/test_async_adapter.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ def test_executemany_returning_captures_rows_and_description(self) -> None:
9898
mock_inner.executemany.return_value = None
9999
mock_inner.fetchall.return_value = returned_rows
100100
mock_inner.close.return_value = None
101+
# Underlying AsyncCursor sets rowcount = len(rows) for the
102+
# RETURNING path; the adapter must mirror it so
103+
# result.rowcount in SQLAlchemy is not stuck at -1.
104+
mock_inner.rowcount = len(returned_rows)
105+
mock_inner.lastrowid = 3
101106
cursor._connection.cursor.return_value = mock_inner
102107

103108
with patch("sqlalchemydqlite.aio.await_only", side_effect=_run_sync):
@@ -107,11 +112,44 @@ def test_executemany_returning_captures_rows_and_description(self) -> None:
107112
)
108113

109114
assert cursor.description == description
115+
assert cursor.rowcount == len(returned_rows)
116+
assert cursor.lastrowid == 3
110117
assert cursor.fetchone() == (1, "a")
111118
assert cursor.fetchone() == (2, "b")
112119
assert cursor.fetchone() == (3, "c")
113120
assert cursor.fetchone() is None
114121

122+
def test_execute_returning_captures_rows_rowcount_and_lastrowid(self) -> None:
123+
"""Single-execute RETURNING: the adapter must copy rowcount
124+
and lastrowid from the underlying cursor alongside description
125+
and rows, so SQLAlchemy's Result layer sees the affected-row
126+
count for INSERT ... RETURNING.
127+
"""
128+
cursor = _make_cursor()
129+
130+
mock_inner = MagicMock()
131+
returned_rows = [(7, "alice")]
132+
description = [
133+
("id", 1, None, None, None, None, None),
134+
("name", 3, None, None, None, None, None),
135+
]
136+
mock_inner.description = description
137+
mock_inner.execute.return_value = None
138+
mock_inner.fetchall.return_value = returned_rows
139+
mock_inner.close.return_value = None
140+
mock_inner.rowcount = 1
141+
mock_inner.lastrowid = 7
142+
cursor._connection.cursor.return_value = mock_inner
143+
144+
with patch("sqlalchemydqlite.aio.await_only", side_effect=_run_sync):
145+
cursor.execute("INSERT INTO t (name) VALUES (?) RETURNING id, name", ("alice",))
146+
147+
assert cursor.description == description
148+
assert cursor.rowcount == 1
149+
assert cursor.lastrowid == 7
150+
assert cursor.fetchone() == (7, "alice")
151+
assert cursor.fetchone() is None
152+
115153

116154
def _has_finally_with_close(func: object) -> bool:
117155
"""Check if a function has cursor.close() inside a finally block."""

0 commit comments

Comments
 (0)