Skip to content

Commit 8d1676b

Browse files
fix: raise error on nested transaction instead of silent no-op
Previously, calling transaction() inside an active transaction silently yielded without any transactional semantics, which is a correctness trap. Now raises OperationalError so callers discover the issue immediately. Users needing nested transactions can use SAVEPOINT directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 29ec507 commit 8d1676b

2 files changed

Lines changed: 39 additions & 4 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from contextlib import asynccontextmanager
66
from typing import Any
77

8-
from dqliteclient.exceptions import ConnectionError, ProtocolError
8+
from dqliteclient.exceptions import ConnectionError, OperationalError, ProtocolError
99
from dqliteclient.protocol import DqliteProtocol
1010

1111

@@ -150,9 +150,9 @@ async def fetchval(self, sql: str, params: list[Any] | None = None) -> Any:
150150
async def transaction(self) -> AsyncIterator[None]:
151151
"""Context manager for transactions."""
152152
if self._in_transaction:
153-
# Nested transaction - just yield
154-
yield
155-
return
153+
raise OperationalError(
154+
0, "Nested transactions are not supported; use SAVEPOINT directly"
155+
)
156156

157157
await self.execute("BEGIN")
158158
self._in_transaction = True

tests/test_connection.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,41 @@ async def test_execute_not_connected(self) -> None:
9494
with pytest.raises(ConnectionError, match="Not connected"):
9595
await conn.execute("SELECT 1")
9696

97+
async def test_nested_transaction_raises(self) -> None:
98+
"""Nested transaction() should raise, not silently no-op."""
99+
conn = DqliteConnection("localhost:9001")
100+
101+
mock_reader = AsyncMock()
102+
mock_writer = MagicMock()
103+
mock_writer.drain = AsyncMock()
104+
mock_writer.close = MagicMock()
105+
mock_writer.wait_closed = AsyncMock()
106+
107+
from dqlitewire.messages import DbResponse, ResultResponse, WelcomeResponse
108+
109+
responses = [
110+
WelcomeResponse(heartbeat_timeout=15000).encode(),
111+
DbResponse(db_id=1).encode(),
112+
ResultResponse(last_insert_id=0, rows_affected=0).encode(), # BEGIN
113+
]
114+
mock_reader.read.side_effect = responses
115+
116+
with patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)):
117+
await conn.connect()
118+
119+
# Mock execute for BEGIN
120+
async def mock_execute(sql: str, params=None):
121+
return (0, 0)
122+
123+
conn.execute = mock_execute # type: ignore[assignment]
124+
125+
from dqliteclient.exceptions import OperationalError
126+
127+
async with conn.transaction():
128+
with pytest.raises(OperationalError, match="[Nn]ested"):
129+
async with conn.transaction():
130+
pass
131+
97132
async def test_fetch_not_connected(self) -> None:
98133
conn = DqliteConnection("localhost:9001")
99134

0 commit comments

Comments
 (0)