Skip to content

Commit 1cfb2fa

Browse files
Pin cursor-scoped lastrowid (deliberately not connection-scoped)
stdlib sqlite3.Cursor.lastrowid is connection-scoped: every cursor on the connection sees the same last_insert_rowid() from the underlying SQLite handle. The dqlite dbapi deliberately diverges — each cursor's lastrowid reflects only INSERTs run on THAT cursor, leaving sibling cursors at None until they do their own INSERT. Both behaviours are PEP 249 compliant; the dqlite contract is documented but unpinned. A future refactor to "match stdlib" semantics would silently change user-visible behaviour for any caller that holds two cursors per connection inside a transaction. Pin the cursor-scoped invariant in both sync and async paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1f3e6c6 commit 1cfb2fa

1 file changed

Lines changed: 90 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""``lastrowid`` is cursor-scoped, not connection-scoped.
2+
3+
stdlib ``sqlite3.Cursor.lastrowid`` is connection-scoped: every
4+
cursor on the connection sees the same ``last_insert_rowid()`` from
5+
the underlying SQLite handle. The dqlite dbapi deliberately diverges
6+
— each cursor's ``lastrowid`` reflects only INSERTs run on THAT
7+
cursor, leaving sibling cursors at None until they do their own
8+
INSERT.
9+
10+
Both behaviours are PEP 249 compliant; the dqlite contract is
11+
documented but unpinned. A future refactor to "match stdlib"
12+
semantics would silently change user-visible behaviour for any
13+
caller that holds two cursors per connection inside a transaction.
14+
15+
Pin the cursor-scoped invariant so the deliberate design choice is
16+
loud against drift.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import pytest
22+
23+
import dqlitedbapi
24+
25+
26+
@pytest.mark.integration
27+
def test_sync_lastrowid_is_cursor_scoped_not_connection_scoped(
28+
cluster_address: str,
29+
) -> None:
30+
with dqlitedbapi.connect(cluster_address) as conn:
31+
cur1 = conn.cursor()
32+
cur2 = conn.cursor()
33+
cur1.execute("DROP TABLE IF EXISTS test_lastrowid_scoped")
34+
cur1.execute("CREATE TABLE test_lastrowid_scoped (id INTEGER PRIMARY KEY, x TEXT)")
35+
conn.commit()
36+
37+
# Sibling cursor with no prior INSERT must see lastrowid=None.
38+
assert cur2.lastrowid is None
39+
40+
cur1.execute("BEGIN")
41+
cur1.execute("INSERT INTO test_lastrowid_scoped (x) VALUES ('a')")
42+
first_rowid = cur1.lastrowid
43+
assert first_rowid is not None
44+
# cur2 (no INSERT yet) still sees None.
45+
assert cur2.lastrowid is None
46+
47+
cur2.execute("INSERT INTO test_lastrowid_scoped (x) VALUES ('b')")
48+
second_rowid = cur2.lastrowid
49+
assert second_rowid is not None
50+
assert second_rowid != first_rowid
51+
# cur1's lastrowid is unchanged by cur2's INSERT — cursor-scoped.
52+
assert cur1.lastrowid == first_rowid
53+
54+
conn.commit()
55+
56+
57+
@pytest.mark.integration
58+
@pytest.mark.asyncio
59+
async def test_async_lastrowid_is_cursor_scoped_not_connection_scoped(
60+
cluster_address: str,
61+
) -> None:
62+
from dqlitedbapi.aio import aconnect
63+
64+
conn = await aconnect(cluster_address)
65+
try:
66+
cur1 = conn.cursor()
67+
cur2 = conn.cursor()
68+
await cur1.execute("DROP TABLE IF EXISTS test_async_lastrowid_scoped")
69+
await cur1.execute(
70+
"CREATE TABLE test_async_lastrowid_scoped (id INTEGER PRIMARY KEY, x TEXT)"
71+
)
72+
await conn.commit()
73+
74+
assert cur2.lastrowid is None
75+
76+
await cur1.execute("BEGIN")
77+
await cur1.execute("INSERT INTO test_async_lastrowid_scoped (x) VALUES ('a')")
78+
first_rowid = cur1.lastrowid
79+
assert first_rowid is not None
80+
assert cur2.lastrowid is None
81+
82+
await cur2.execute("INSERT INTO test_async_lastrowid_scoped (x) VALUES ('b')")
83+
second_rowid = cur2.lastrowid
84+
assert second_rowid is not None
85+
assert second_rowid != first_rowid
86+
assert cur1.lastrowid == first_rowid
87+
88+
await conn.commit()
89+
finally:
90+
await conn.close()

0 commit comments

Comments
 (0)