Skip to content

Commit e6980b1

Browse files
Reject multi-statement SQL in Cursor.execute
dqlite's server prepare path returns only the first statement; without this guard, cur.execute("INSERT ...; INSERT ...") silently drops everything past the first semicolon with no diagnostic — silent data loss visible only as a missing row count on the second statement. Match stdlib sqlite3.Cursor.execute by raising ProgrammingError with the canonical wording. The detector neutralises string literals, identifiers, and comments before scanning, so a semicolon inside a quoted token is not treated as a statement boundary. Multi-statement intent goes via executescript (already stubbed as NotSupportedError). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a9d47f6 commit e6980b1

4 files changed

Lines changed: 167 additions & 0 deletions

File tree

src/dqlitedbapi/aio/cursor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
_ExecuteManyAccumulator,
1515
_is_dml_with_returning,
1616
_is_insert_or_replace,
17+
_is_multi_statement,
1718
_is_row_returning,
1819
_strip_leading_comments,
1920
_to_signed_int64,
@@ -337,6 +338,15 @@ async def execute(self, operation: str, parameters: Sequence[Any] | None = None)
337338
# query's description.
338339
self._reset_execute_state()
339340

341+
# Match stdlib ``sqlite3.Cursor.execute``: a multi-statement
342+
# SQL string is rejected as ``ProgrammingError``. dqlite's
343+
# server prepare path returns only the first statement; without
344+
# this guard, ``"INSERT ...; INSERT ..."`` silently drops
345+
# everything past the first ``;``. Use ``executescript`` for
346+
# multi-statement intent (we stub it as ``NotSupportedError``).
347+
if _is_multi_statement(operation):
348+
raise ProgrammingError("You can only execute one statement at a time.")
349+
340350
_, op_lock = self._connection._ensure_locks()
341351
async with op_lock:
342352
# PEP 249 §6.1.1 — clear messages under the lock so the

src/dqlitedbapi/cursor.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,38 @@ def _strip_sql_noise(sql: str) -> str:
492492
return _SQL_NOISE_RE.sub(" ", sql)
493493

494494

495+
def _is_multi_statement(sql: str) -> bool:
496+
"""Return ``True`` if ``sql`` contains more than one statement.
497+
498+
The dqlite server's prepare path returns a single statement
499+
(the wire protocol's ``PrepareRequest`` ignores trailing text
500+
after the first ``;``). A user passing
501+
``"INSERT ...; INSERT ..."`` would otherwise get only the first
502+
INSERT executed with no diagnostic.
503+
504+
A trailing ``;`` is fine; whitespace and comments after the final
505+
``;`` are fine. Any non-whitespace, non-comment content past a
506+
``;`` is rejected as a multi-statement.
507+
508+
String literals and comments are neutralised first (via
509+
``_strip_sql_noise``) so a ``;`` inside a string or comment is
510+
not detected as a statement boundary.
511+
"""
512+
cleaned = _strip_sql_noise(sql)
513+
semicolon = cleaned.find(";")
514+
while semicolon != -1:
515+
# The character past the ``;`` and everything after must
516+
# reduce to whitespace / comments / nothing.
517+
tail = _strip_leading_comments(cleaned[semicolon + 1 :])
518+
if tail:
519+
return True
520+
next_idx = cleaned.find(";", semicolon + 1)
521+
if next_idx == -1:
522+
return False
523+
semicolon = next_idx
524+
return False
525+
526+
495527
class _ExecuteManyCursor(Protocol):
496528
"""Structural shape of :class:`Cursor` / :class:`AsyncCursor` as
497529
consumed by :class:`_ExecuteManyAccumulator`.
@@ -1015,6 +1047,15 @@ def execute(self, operation: str, parameters: Sequence[Any] | None = None) -> Se
10151047
# previous query's description / rows.
10161048
self._reset_execute_state()
10171049

1050+
# Match stdlib ``sqlite3.Cursor.execute``: a multi-statement
1051+
# SQL string is rejected as ``ProgrammingError``. dqlite's
1052+
# server prepare path returns only the first statement; without
1053+
# this guard, ``"INSERT ...; INSERT ..."`` silently drops
1054+
# everything past the first ``;``. Use ``executescript`` for
1055+
# multi-statement intent (we stub it as ``NotSupportedError``).
1056+
if _is_multi_statement(operation):
1057+
raise ProgrammingError("You can only execute one statement at a time.")
1058+
10181059
self._connection._run_sync(self._execute_async(operation, parameters))
10191060
return self
10201061

tests/test_aio_register_adapter_export.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
though calling it from either namespace mutates the same registry.
66
"""
77

8+
89
def test_aio_exports_register_adapter() -> None:
910
from dqlitedbapi.aio import register_adapter
1011

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""``Cursor.execute`` and ``AsyncCursor.execute`` reject
2+
multi-statement SQL with ``ProgrammingError``, matching stdlib
3+
``sqlite3.Cursor.execute``.
4+
5+
dqlite's server prepare path returns only the first statement;
6+
without this guard, ``"INSERT ...; INSERT ..."`` silently drops
7+
everything past the first ``;`` with no diagnostic — silent data
8+
loss.
9+
10+
Multi-statement intent is via ``executescript`` (we stub it as
11+
``NotSupportedError``).
12+
"""
13+
14+
import pytest
15+
16+
from dqlitedbapi.cursor import _is_multi_statement
17+
18+
19+
class TestIsMultiStatement:
20+
@pytest.mark.parametrize(
21+
"sql",
22+
[
23+
# Single statement, no trailing semicolon.
24+
"SELECT 1",
25+
# Single statement, trailing whitespace only.
26+
"SELECT 1 ",
27+
# Single statement plus trailing semicolon.
28+
"SELECT 1;",
29+
# Single statement + trailing whitespace after semicolon.
30+
"SELECT 1;\n \n",
31+
# Single statement + trailing line comment.
32+
"SELECT 1; -- trailing",
33+
# Single statement + trailing block comment.
34+
"SELECT 1; /* trailing */",
35+
# Semicolon inside string literal — not a boundary.
36+
"INSERT INTO t VALUES (';')",
37+
# Semicolon inside line comment — not a boundary.
38+
"-- ; not a real semicolon\nSELECT 1",
39+
# Semicolon inside block comment — not a boundary.
40+
"/* ; not real */ SELECT 1",
41+
# Semicolon inside double-quoted identifier — not a
42+
# boundary (SQLite identifier-quoting rule).
43+
'SELECT "col;name" FROM t',
44+
# Empty SQL (covered by a sibling issue's empty-SQL
45+
# classification fix; here it must NOT be flagged as
46+
# multi-statement).
47+
"",
48+
" ",
49+
"-- comment\n",
50+
],
51+
)
52+
def test_single_statement_not_flagged(self, sql: str) -> None:
53+
assert _is_multi_statement(sql) is False
54+
55+
@pytest.mark.parametrize(
56+
"sql",
57+
[
58+
# Two DML statements.
59+
"INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)",
60+
# DDL + DDL.
61+
"CREATE TABLE a (x); CREATE TABLE b (y)",
62+
# Mixed DDL + DML.
63+
"CREATE TABLE t (x); INSERT INTO t VALUES (1)",
64+
# Whitespace-then-statement after the first ``;``.
65+
"SELECT 1; SELECT 2",
66+
# Comment + second statement past the first ``;``.
67+
"SELECT 1; /* sep */ SELECT 2",
68+
# Double semicolon followed by another statement —
69+
# stdlib treats consecutive ``;`` as separate statements.
70+
"SELECT 1;; SELECT 2",
71+
],
72+
)
73+
def test_multi_statement_flagged(self, sql: str) -> None:
74+
assert _is_multi_statement(sql) is True
75+
76+
77+
class TestExecuteRejectsMultiStatementSync:
78+
def test_rejects_two_dml(self) -> None:
79+
from dqlitedbapi.connection import Connection
80+
from dqlitedbapi.cursor import Cursor
81+
from dqlitedbapi.exceptions import ProgrammingError
82+
83+
conn = Connection("localhost:19001", timeout=2.0)
84+
cur = Cursor(conn)
85+
with pytest.raises(ProgrammingError, match="one statement at a time"):
86+
cur.execute("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)")
87+
88+
def test_accepts_single_statement_with_trailing_comment(self) -> None:
89+
from dqlitedbapi.connection import Connection
90+
from dqlitedbapi.cursor import Cursor
91+
from dqlitedbapi.exceptions import ProgrammingError
92+
93+
conn = Connection("localhost:19001", timeout=2.0)
94+
cur = Cursor(conn)
95+
# Should not raise the multi-statement error. (It will fail
96+
# at the wire round-trip because the server isn't running,
97+
# but that's a separate error class.)
98+
with pytest.raises(Exception) as excinfo:
99+
cur.execute("SELECT 1; -- comment")
100+
assert not isinstance(excinfo.value, ProgrammingError) or (
101+
"one statement at a time" not in str(excinfo.value)
102+
)
103+
104+
105+
@pytest.mark.asyncio
106+
class TestExecuteRejectsMultiStatementAsync:
107+
async def test_rejects_two_dml(self) -> None:
108+
from dqlitedbapi.aio.connection import AsyncConnection
109+
from dqlitedbapi.aio.cursor import AsyncCursor
110+
from dqlitedbapi.exceptions import ProgrammingError
111+
112+
conn = AsyncConnection("localhost:19001")
113+
cur = AsyncCursor(conn)
114+
with pytest.raises(ProgrammingError, match="one statement at a time"):
115+
await cur.execute("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)")

0 commit comments

Comments
 (0)