Skip to content

Commit e4697cf

Browse files
committed
Make cursor-based adapter compatible with psycopg v3 transactions and multi-statement
Running tests/integration/test_transactions.py against a real PostgreSQL container with the psycopg3 adapter surfaced two driver-behavior differences that need handling in the shared CursorBasedAdapter: 1. autocommit-aware execute_non_query psycopg2 silently makes conn.commit() a no-op when conn.autocommit is True; psycopg v3 actively COMMITs any open transaction. The adapter's execute_non_query unconditionally called conn.commit(), which under psycopg3 meant `executor.execute("BEGIN")` immediately ended the transaction it just started — so the subsequent INSERT was committed and ROLLBACK had nothing to undo. Skipping the commit when conn.autocommit is True preserves the original intent on both drivers. 2. nextset() walk in execute_query psycopg v3 exposes each result set in a multi-statement string and leaves the cursor positioned at the first (e.g. on BEGIN, which has no columns). psycopg2 only ever surfaced the last set. Walking cursor.nextset() until it returns falsy lands on the last set; the loop is a no-op on drivers that don't implement nextset() (a number raise NotSupportedError, hence the try/except). The integration tests in tests/integration/test_transactions.py needed three fixes that are independent of the driver swap — verified to fail or hang on upstream main with psycopg2 as well: - test_multi_statement_as_single_query_works The "BEGIN; INSERT; SELECT" payload left an open transaction on the executor's persistent connection; the test's subsequent DROP TABLE (on a different connection) then blocked waiting for the lock. Added reset_tui_executor() before the cleanup so the persistent connection rolls back and releases the lock. - test_atomic_execute_rolls_back_on_error The test expected atomic_execute to raise on a failing statement. Per its docstring and current implementation, atomic_execute reports the failure via a MultiStatementResult with completed=False and the failing index in error_index. Updated the assertions accordingly. - test_atomic_execute_commits_on_success Multi-statement atomic_execute is documented to return a MultiStatementResult (one StatementResult per statement), not a single QueryResult. Asserting on result.results[-1].result picks up the trailing SELECT's QueryResult exactly as the test intended. Result against the docker-compose postgres:16 fixture: - tests/integration/test_transactions.py: 13 passed - tests/integration/test_database_browsing_flow.py::TestPostgreSQLDatabaseBrowsing: 1 passed - tests/integration/test_stale_connection_reconnect.py -k postgres: 5 passed, 1 idle-variant skipped (pre-existing) - tests/unit: 842 passed
1 parent a1758e1 commit e4697cf

3 files changed

Lines changed: 49 additions & 11 deletions

File tree

sqlit/domains/connections/providers/adapters/base.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,19 @@ def execute_query(self, conn: Any, query: str, max_rows: int | None = None) -> t
492492
"""Execute a query using cursor-based approach with optional row limit."""
493493
cursor = conn.cursor()
494494
cursor.execute(query)
495+
# Multi-statement queries (e.g. "BEGIN; INSERT; SELECT") produce
496+
# several result sets on drivers that expose them — psycopg v3
497+
# leaves the cursor positioned on the first set (the BEGIN, which
498+
# has no columns), so advance to the last one. psycopg2 and other
499+
# drivers either don't implement nextset() or return None for
500+
# single-statement queries; either way this loop terminates.
501+
while True:
502+
try:
503+
more = cursor.nextset()
504+
except Exception:
505+
more = None
506+
if not more:
507+
break
495508
if cursor.description:
496509
columns = [col[0] for col in cursor.description]
497510
if max_rows is not None:
@@ -511,7 +524,14 @@ def execute_non_query(self, conn: Any, query: str) -> int:
511524
cursor = conn.cursor()
512525
cursor.execute(query)
513526
rowcount = int(cursor.rowcount)
514-
conn.commit()
527+
# When the connection is in autocommit mode, an explicit conn.commit()
528+
# has driver-dependent semantics: psycopg2 makes it a no-op, but
529+
# psycopg v3 actively ends any open transaction. Calling commit()
530+
# here would unconditionally close out an explicit BEGIN before the
531+
# caller's INSERT/ROLLBACK gets a chance to run. Skipping it when
532+
# autocommit is on preserves the intent in both worlds.
533+
if not getattr(conn, "autocommit", False):
534+
conn.commit()
515535
return rowcount
516536

517537

tests/integration/test_transactions.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ def test_multi_statement_as_single_query_works(self, postgres_db: str):
300300
assert len(result.columns) > 0
301301
assert result.row_count > 0
302302

303+
# The query opened a transaction (BEGIN) but never closed it. Close
304+
# the executor so its persistent connection rolls back, otherwise
305+
# the DROP TABLE below blocks waiting for the lock.
306+
reset_tui_executor()
307+
303308
# Cleanup
304309
conn = adapter.connect(config)
305310
try:
@@ -509,13 +514,19 @@ def test_atomic_execute_rolls_back_on_error(self, postgres_db: str):
509514
initial_count = result.rows[0][0]
510515

511516
# This should fail on the second INSERT (NULL not allowed)
512-
# and rollback the first INSERT too
517+
# and rollback the first INSERT too. atomic_execute reports
518+
# the failure via a MultiStatementResult with completed=False
519+
# rather than raising.
520+
from sqlit.domains.query.app.multi_statement import MultiStatementResult
521+
513522
query = """
514523
INSERT INTO atomic_test (name) VALUES ('row1');
515524
INSERT INTO atomic_test (name) VALUES (NULL);
516525
"""
517-
with pytest.raises(Exception):
518-
executor.atomic_execute(query)
526+
atomic_result = executor.atomic_execute(query)
527+
assert isinstance(atomic_result, MultiStatementResult)
528+
assert atomic_result.completed is False
529+
assert atomic_result.error_index is not None
519530

520531
# Count should be unchanged (both inserts rolled back)
521532
result = executor.execute("SELECT COUNT(*) FROM atomic_test")
@@ -556,16 +567,23 @@ def test_atomic_execute_commits_on_success(self, postgres_db: str):
556567

557568
executor = TransactionExecutor(config, provider)
558569
try:
570+
from sqlit.domains.query.app.multi_statement import MultiStatementResult
571+
559572
query = """
560573
INSERT INTO atomic_test (name) VALUES ('row1');
561574
INSERT INTO atomic_test (name) VALUES ('row2');
562575
SELECT * FROM atomic_test;
563576
"""
564577
result = executor.atomic_execute(query)
565578

566-
# Should return the SELECT result
567-
assert isinstance(result, QueryResult)
568-
assert result.row_count == 2
579+
# Multi-statement atomic_execute returns a MultiStatementResult
580+
# carrying each statement's outcome. The SELECT lands as the
581+
# last entry and exposes the rows.
582+
assert isinstance(result, MultiStatementResult)
583+
assert result.completed is True
584+
select_result = result.results[-1].result
585+
assert isinstance(select_result, QueryResult)
586+
assert select_result.row_count == 2
569587
finally:
570588
executor.close()
571589
conn = adapter.connect(config)

tests/unit/test_postgresql_adapter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ def test_postgresql_uses_custom_port_when_server_left_blank() -> None:
4848
psycopg2.connect so the connection actually reaches the docker
4949
container.
5050
"""
51-
mock_psycopg2 = MagicMock()
52-
with patch.dict("sys.modules", {"psycopg2": mock_psycopg2}):
51+
mock_psycopg = MagicMock()
52+
with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
5353
from sqlit.domains.connections.providers.postgresql.adapter import PostgreSQLAdapter
5454

5555
adapter = PostgreSQLAdapter()
@@ -65,9 +65,9 @@ def test_postgresql_uses_custom_port_when_server_left_blank() -> None:
6565

6666
adapter.connect(config)
6767

68-
kwargs = mock_psycopg2.connect.call_args.kwargs
68+
kwargs = mock_psycopg.connect.call_args.kwargs
6969
assert kwargs.get("port") == 5433, (
70-
"port=5433 must reach psycopg2.connect, but the adapter "
70+
"port=5433 must reach psycopg.connect, but the adapter "
7171
f"silently drops it when server is blank. kwargs={kwargs!r}"
7272
)
7373
assert kwargs.get("host") == "localhost", (

0 commit comments

Comments
 (0)