Skip to content

Commit b1e38c7

Browse files
Assign description atomically with fetched rows in async adapter
AsyncAdaptedCursor.execute and .executemany previously set self.description from the underlying cursor before awaiting fetchall. If fetchall raised (CancelledError from an outer timeout, mid-stream server fault, etc.), the adapter was left with description populated and _rows empty — a state SQLAlchemy's Result layer treats as an empty result set, indistinguishable from "execute succeeded but fetched no rows". Fetch first into a local, then assign both together so a raise at fetch time leaves description at None.
1 parent 3a72d77 commit b1e38c7

2 files changed

Lines changed: 49 additions & 2 deletions

File tree

src/sqlalchemydqlite/aio.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,16 @@ def execute(self, operation: str, parameters: Any = None) -> None:
7979
await_only(cursor.execute(operation))
8080

8181
if cursor.description:
82+
# Fetch first, assign atomically. If ``fetchall`` raises
83+
# (CancelledError from an outer timeout, server fault
84+
# mid-stream, etc.), ``self.description`` must not be left
85+
# set while ``self._rows`` is still empty — SQLAlchemy's
86+
# Result layer treats (description, empty rows) as an
87+
# empty result set, indistinguishable from "execute
88+
# succeeded but fetched no rows".
89+
fetched = deque(await_only(cursor.fetchall()))
8290
self.description = cursor.description
83-
self._rows = deque(await_only(cursor.fetchall()))
91+
self._rows = fetched
8492
else:
8593
self.lastrowid = cursor.lastrowid
8694
self.rowcount = cursor.rowcount
@@ -105,8 +113,12 @@ def executemany(self, operation: str, seq_of_parameters: Iterable[Sequence[Any]]
105113
# when SQLAlchemy's insertmanyvalues + RETURNING path is
106114
# driven through the async engine.
107115
if cursor.description:
116+
# Same fetch-first-then-assign pattern as ``execute``:
117+
# a raise from ``fetchall`` must not leave description
118+
# populated with empty rows.
119+
fetched = deque(await_only(cursor.fetchall()))
108120
self.description = cursor.description
109-
self._rows = deque(await_only(cursor.fetchall()))
121+
self._rows = fetched
110122
else:
111123
self.lastrowid = cursor.lastrowid
112124
self.rowcount = cursor.rowcount

tests/test_async_adapter.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,41 @@ def test_cursor_returns_async_adapted_cursor_wrapping_inner(self) -> None:
463463
assert isinstance(cursor, AsyncAdaptedCursor)
464464

465465

466+
class TestAsyncAdaptedCursorDescriptionConsistency:
467+
"""If ``fetchall`` raises mid-call, ``description`` must not be
468+
left set with an empty ``_rows`` buffer — SQLAlchemy's Result
469+
layer treats that pair as an empty result set, indistinguishable
470+
from "execute succeeded but fetched no rows"."""
471+
472+
def test_fetchall_raise_leaves_description_none(self) -> None:
473+
import pytest
474+
475+
cursor = _make_cursor()
476+
477+
mock_inner = MagicMock()
478+
mock_inner.description = (("id", 1, None, None, None, None, None),)
479+
mock_inner.execute.return_value = None
480+
mock_inner.close.return_value = None
481+
# ``fetchall`` raises before returning — the adapter must not
482+
# commit ``description`` nor leave ``_rows`` in a half-assigned
483+
# state.
484+
mock_inner.fetchall.side_effect = RuntimeError("synthetic fetchall failure")
485+
cursor._connection.cursor.return_value = mock_inner
486+
487+
with (
488+
patch("sqlalchemydqlite.aio.await_only", side_effect=_run_sync),
489+
pytest.raises(RuntimeError, match="synthetic fetchall failure"),
490+
):
491+
cursor.execute("SELECT id FROM t")
492+
493+
assert cursor.description is None, (
494+
"description must roll back to None when fetchall raises, "
495+
"so SQLAlchemy's Result layer cannot misread the cursor as "
496+
"holding an empty result set"
497+
)
498+
assert len(cursor._rows) == 0
499+
500+
466501
class TestAsyncAdaptedCursorDescriptionType:
467502
"""``cursor.description`` is a PEP 249 sequence of sequences. The
468503
adapter must accept (and pass through) any sequence the underlying

0 commit comments

Comments
 (0)