Skip to content

Commit 770041c

Browse files
fix: drop unreachable drain loop from query_sql
The C dqlite server rejects multi-statement SELECT with a FailureResponse (SQLITE_ERROR, "nonempty statement tail") rather than emitting additional RowsResponse frames: handle_query_work_cb checks is_statement_empty on the tail and, if non-empty, routes to handle_query_sql_done_cb which emits a failure. The "drain extra result sets" loop added by #054 was therefore unreachable dead code built on a misreading of the server. Remove the loop. Update the docstring to note the real behaviour. Replace the test that simulated the dead-code path with one asserting the client leaves stray bytes in the buffer instead of silently consuming them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56a2a2e commit 770041c

2 files changed

Lines changed: 16 additions & 18 deletions

File tree

src/dqliteclient/protocol.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ async def query_sql(
170170
) -> tuple[list[str], list[list[Any]]]:
171171
"""Execute a query directly.
172172
173-
Returns (column_names, rows).
173+
Returns (column_names, rows). Multi-statement SELECT is rejected
174+
by the server with OperationalError(SQLITE_ERROR, "nonempty
175+
statement tail") — there are no additional result sets to drain.
174176
"""
175177
request = QuerySqlRequest(db_id=db_id, sql=sql, params=params if params is not None else [])
176178
self._writer.write(request.encode())
@@ -184,7 +186,6 @@ async def query_sql(
184186
if not isinstance(response, RowsResponse):
185187
raise ProtocolError(f"Expected RowsResponse, got {type(response).__name__}")
186188

187-
# Store column names from first response
188189
column_names = response.column_names
189190

190191
# Handle multi-part responses via decode_continuation(),
@@ -197,12 +198,6 @@ async def query_sql(
197198
all_rows.extend(next_response.rows)
198199
response = next_response
199200

200-
# Drain any extra result sets from multi-statement SQL
201-
while self._decoder.has_message():
202-
extra = await self._read_response()
203-
if isinstance(extra, FailureResponse):
204-
raise OperationalError(extra.code, extra.message)
205-
206201
return column_names, all_rows
207202

208203
async def _read_data(self) -> bytes:

tests/test_protocol.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -156,38 +156,41 @@ async def test_exec_sql_reads_single_response(
156156
# The stray message must still be buffered — not silently consumed.
157157
assert protocol._decoder.has_message()
158158

159-
async def test_query_sql_multi_statement_drains_extra(
159+
async def test_query_sql_reads_single_response(
160160
self,
161161
protocol: DqliteProtocol,
162162
mock_reader: AsyncMock,
163163
) -> None:
164-
"""Multi-statement queries should drain extra result sets, not corrupt buffer."""
164+
"""query_sql reads exactly one RowsResponse — it must not drain extras.
165+
166+
The C server rejects multi-statement SELECT with a FailureResponse
167+
("nonempty statement tail") rather than sending multiple RowsResponses.
168+
Any stray post-response bytes must be left alone, not silently
169+
consumed.
170+
"""
165171
from dqlitewire.constants import ValueType
166172
from dqlitewire.messages import RowsResponse
167173

168-
# Two result sets from "SELECT 1; SELECT 2"
169174
rows1 = RowsResponse(
170175
column_names=["a"],
171176
column_types=[ValueType.INTEGER],
172177
rows=[[1]],
173178
has_more=False,
174179
)
175-
rows2 = RowsResponse(
180+
stray = RowsResponse(
176181
column_names=["b"],
177182
column_types=[ValueType.INTEGER],
178183
rows=[[2]],
179184
has_more=False,
180185
)
181-
mock_reader.read.return_value = rows1.encode() + rows2.encode()
186+
mock_reader.read.return_value = rows1.encode() + stray.encode()
182187

183-
columns, rows = await protocol.query_sql(1, "SELECT 1; SELECT 2")
188+
columns, rows = await protocol.query_sql(1, "SELECT 1")
184189

185-
# Returns first result set
186190
assert columns == ["a"]
187191
assert rows == [[1]]
188-
189-
# Decoder buffer should be clean -- extra result set was drained
190-
assert not protocol._decoder.has_message()
192+
# Stray response must remain in the buffer, not silently consumed.
193+
assert protocol._decoder.has_message()
191194

192195
async def test_query_sql(
193196
self,

0 commit comments

Comments
 (0)