From 8e6eef2db117f533e95e569b54f6e13d188f137c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 8 Apr 2026 00:22:48 +0800 Subject: [PATCH 1/3] test(transaction): add comprehensive transaction mode and advanced feature tests - Add test_transaction_mode_effect.py for READ ONLY/READ WRITE mode effects - Verify READ ONLY mode rejects INSERT/UPDATE/DELETE operations - Verify READ WRITE mode allows all write operations - Test mode persistence across transactions - Test mode combined with isolation level - Include both sync and async test coverage - Add test_transaction_advanced_features.py for advanced PostgreSQL features - Test DEFERRABLE transaction behavior with SERIALIZABLE isolation - Test SET TRANSACTION statement for isolation level and access mode - Test SESSION CHARACTERISTICS persistence and reset - Include async test counterparts - Fix test_transaction_deferrable.py to use public API - Replace _deferred_constraints private attribute access with get_deferred_constraints() All tests verify PostgreSQL transaction features work correctly with the transaction manager and expression system. Co-Authored-By: Claude Opus 4.6 --- .../test_transaction_advanced_features.py | 460 ++++++++++++++++++ .../backend/test_transaction_deferrable.py | 17 +- .../backend/test_transaction_mode_effect.py | 332 +++++++++++++ 3 files changed, 800 insertions(+), 9 deletions(-) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py new file mode 100644 index 00000000..c31c1594 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py @@ -0,0 +1,460 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py +""" +PostgreSQL advanced transaction feature tests. + +This module tests DEFERRABLE transactions, SET TRANSACTION, and SESSION CHARACTERISTICS. +""" +import pytest +import pytest_asyncio +from decimal import Decimal + +from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode + + +class TestSyncDeferrableTransaction: + """Tests for DEFERRABLE transaction behavior with SERIALIZABLE isolation.""" + + @pytest.fixture + def test_tables(self, postgres_backend): + """Create test tables with deferred constraint.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_child CASCADE") + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_parent CASCADE") + + postgres_backend.execute(""" + CREATE TABLE test_deferred_parent ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + postgres_backend.execute(""" + CREATE TABLE test_deferred_child ( + id SERIAL PRIMARY KEY, + parent_id INT NOT NULL, + amount DECIMAL(10, 2), + CONSTRAINT deferred_fk + FOREIGN KEY (parent_id) REFERENCES test_deferred_parent(id) + DEFERRABLE INITIALLY IMMEDIATE + ) + """) + yield + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_child CASCADE") + postgres_backend.execute("DROP TABLE IF EXISTS test_deferred_parent CASCADE") + + def test_deferrable_serializable_allows_deferred_checking(self, postgres_backend, test_tables): + """Test DEFERRABLE SERIALIZABLE transaction with deferred constraint checking. + + DEFERRABLE affects when constraint checking occurs: + - INITIALLY IMMEDIATE: constraints checked at statement level by default + - SET CONSTRAINTS ... DEFERRED: defer until transaction commit + + This test verifies the transaction manager correctly sets DEFERRABLE mode. + """ + tx_manager = postgres_backend.transaction_manager + + # Set SERIALIZABLE isolation with DEFERRABLE + tx_manager.isolation_level = IsolationLevel.SERIALIZABLE + tx_manager.set_deferrable(True) + + with postgres_backend.transaction(): + # Defer the constraint + tx_manager.defer_constraint("deferred_fk") + + # Insert child before parent - normally would fail immediately + # but deferred constraint allows it until commit + postgres_backend.execute( + "INSERT INTO test_deferred_child (parent_id, amount) VALUES (%s, %s)", + (1, Decimal("100.00")) + ) + + # Insert parent to satisfy constraint before commit + postgres_backend.execute( + "INSERT INTO test_deferred_parent (id, name) VALUES (%s, %s)", + (1, "Parent1") + ) + + # Verify data was committed + rows = postgres_backend.fetch_all("SELECT * FROM test_deferred_parent") + assert len(rows) == 1 + + def test_not_deferrable_serializable(self, postgres_backend, test_tables): + """Test NOT DEFERRABLE SERIALIZABLE transaction.""" + tx_manager = postgres_backend.transaction_manager + + tx_manager.isolation_level = IsolationLevel.SERIALIZABLE + tx_manager.set_deferrable(False) + + # Transaction should start normally + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_deferred_parent (name) VALUES (%s)", + ("Parent2",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_deferred_parent") + assert len(rows) == 1 + + def test_deferrable_requires_serializable(self, postgres_backend, test_tables): + """Test that DEFERRABLE setting is only meaningful for SERIALIZABLE isolation. + + The dialect should ignore DEFERRABLE for non-SERIALIZABLE isolation levels. + """ + tx_manager = postgres_backend.transaction_manager + + # Set READ COMMITTED with DEFERRABLE (should be ignored) + tx_manager.isolation_level = IsolationLevel.READ_COMMITTED + tx_manager.set_deferrable(True) + + # Transaction should work normally (DEFERRABLE ignored) + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_deferred_parent (name) VALUES (%s)", + ("Parent3",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_deferred_parent") + assert len(rows) == 1 + + +class TestSyncSetTransaction: + """Tests for SET TRANSACTION statement.""" + + @pytest.fixture + def test_table(self, postgres_backend): + """Create a test table.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_set_trans_table") + postgres_backend.execute(""" + CREATE TABLE test_set_trans_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_set_trans_table" + postgres_backend.execute("DROP TABLE IF EXISTS test_set_trans_table") + + def test_set_transaction_isolation_level(self, postgres_backend, test_table): + """Test setting isolation level via SET TRANSACTION expression. + + SET TRANSACTION sets characteristics for the current transaction. + It must be called before any queries in the transaction. + """ + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + # Start transaction + postgres_backend.transaction_manager.begin() + + # Set isolation level using expression + expr = SetTransactionExpression(postgres_backend.dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE) + sql, params = expr.to_sql() + postgres_backend.execute(sql, params) + + # Verify isolation level was set + current_level = postgres_backend.transaction_manager.get_current_isolation_level() + assert current_level == IsolationLevel.SERIALIZABLE + + postgres_backend.transaction_manager.commit() + + def test_set_transaction_read_only(self, postgres_backend, test_table): + """Test setting READ ONLY mode via SET TRANSACTION expression.""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + postgres_backend.transaction_manager.begin() + + expr = SetTransactionExpression(postgres_backend.dialect) + expr.read_only() + sql, params = expr.to_sql() + postgres_backend.execute(sql, params) + + # Verify READ ONLY mode by attempting a write + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_set_trans_table (name) VALUES (%s)", + ("ShouldFail",) + ) + assert "read-only" in str(exc_info.value).lower() + + # Transaction is aborted after error, rollback to clean up + try: + postgres_backend.transaction_manager.rollback() + except Exception: + pass # Transaction may already be terminated + + +class TestSyncSessionCharacteristics: + """Tests for SESSION CHARACTERISTICS.""" + + @pytest.fixture + def test_table(self, postgres_backend): + """Create a test table.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_session_table") + postgres_backend.execute(""" + CREATE TABLE test_session_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_session_table" + postgres_backend.execute("DROP TABLE IF EXISTS test_session_table") + + def test_session_characteristics_persist_across_transactions(self, postgres_backend, test_table): + """Test that SESSION CHARACTERISTICS affect all subsequent transactions.""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + # Set session characteristics to READ ONLY + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_only() + sql, params = expr.to_sql() + postgres_backend.execute(sql, params) + + # First transaction should be READ ONLY + tx_manager = postgres_backend.transaction_manager + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_session_table (name) VALUES (%s)", + ("ShouldFail1",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + tx_manager.rollback() + + # Second transaction should also be READ ONLY + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_session_table (name) VALUES (%s)", + ("ShouldFail2",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + tx_manager.rollback() + finally: + # Reset session characteristics to READ WRITE for teardown + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + def test_reset_session_characteristics(self, postgres_backend, test_table): + """Test resetting session characteristics to READ WRITE.""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + # Set session characteristics to READ ONLY + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_only() + postgres_backend.execute(expr.to_sql()[0]) + + # Reset to READ WRITE + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + # Now transactions should allow writes + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_session_table (name) VALUES (%s)", + ("ShouldSucceed",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_session_table") + assert len(rows) == 1 + + def test_session_characteristics_with_isolation_level(self, postgres_backend, test_table): + """Test session characteristics with isolation level. + + Note: The transaction manager always explicitly sets isolation level + in BEGIN statements, which overrides SESSION CHARACTERISTICS. + This test verifies that SESSION CHARACTERISTICS can be set and + that explicit settings in BEGIN take precedence. + + To test SESSION CHARACTERISTICS isolation level directly, + you would need to execute a plain BEGIN without parameters. + """ + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + # Set session characteristics with SERIALIZABLE isolation + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + # The transaction manager's isolation_level property determines + # what gets set in BEGIN statement, not session characteristics + # So we set the transaction manager's isolation level to match + postgres_backend.transaction_manager.isolation_level = IsolationLevel.SERIALIZABLE + + with postgres_backend.transaction(): + level = postgres_backend.transaction_manager.get_current_isolation_level() + assert level == IsolationLevel.SERIALIZABLE + finally: + # Reset to default READ WRITE with READ COMMITTED + postgres_backend.transaction_manager.isolation_level = IsolationLevel.READ_COMMITTED + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).isolation_level(IsolationLevel.READ_COMMITTED).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + +class TestAsyncDeferrableTransaction: + """Async tests for DEFERRABLE transaction behavior.""" + + @pytest_asyncio.fixture + async def async_test_tables(self, async_postgres_backend): + """Create test tables with deferred constraint.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_child CASCADE") + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_parent CASCADE") + + await async_postgres_backend.execute(""" + CREATE TABLE test_async_deferred_parent ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + await async_postgres_backend.execute(""" + CREATE TABLE test_async_deferred_child ( + id SERIAL PRIMARY KEY, + parent_id INT NOT NULL, + amount DECIMAL(10, 2), + CONSTRAINT async_deferred_fk + FOREIGN KEY (parent_id) REFERENCES test_async_deferred_parent(id) + DEFERRABLE INITIALLY IMMEDIATE + ) + """) + yield + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_child CASCADE") + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_deferred_parent CASCADE") + + @pytest.mark.asyncio + async def test_async_deferrable_serializable(self, async_postgres_backend, async_test_tables): + """Test DEFERRABLE SERIALIZABLE transaction (async).""" + tx_manager = async_postgres_backend.transaction_manager + + tx_manager.isolation_level = IsolationLevel.SERIALIZABLE + await tx_manager.set_deferrable(True) + + async with async_postgres_backend.transaction(): + await tx_manager.defer_constraint("async_deferred_fk") + + await async_postgres_backend.execute( + "INSERT INTO test_async_deferred_child (parent_id, amount) VALUES (%s, %s)", + (1, Decimal("100.00")) + ) + + await async_postgres_backend.execute( + "INSERT INTO test_async_deferred_parent (id, name) VALUES (%s, %s)", + (1, "AsyncParent1") + ) + + rows = await async_postgres_backend.fetch_all("SELECT * FROM test_async_deferred_parent") + assert len(rows) == 1 + + +class TestAsyncSetTransaction: + """Async tests for SET TRANSACTION statement.""" + + @pytest_asyncio.fixture + async def async_test_table(self, async_postgres_backend): + """Create a test table.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_set_trans_table") + await async_postgres_backend.execute(""" + CREATE TABLE test_async_set_trans_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_async_set_trans_table" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_set_trans_table") + + @pytest.mark.asyncio + async def test_async_set_transaction_isolation_level(self, async_postgres_backend, async_test_table): + """Test setting isolation level via SET TRANSACTION (async).""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + await async_postgres_backend.transaction_manager.begin() + + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.isolation_level(IsolationLevel.SERIALIZABLE) + sql, params = expr.to_sql() + await async_postgres_backend.execute(sql, params) + + current_level = await async_postgres_backend.transaction_manager.get_current_isolation_level() + assert current_level == IsolationLevel.SERIALIZABLE + + await async_postgres_backend.transaction_manager.commit() + + @pytest.mark.asyncio + async def test_async_set_transaction_read_only(self, async_postgres_backend, async_test_table): + """Test setting READ ONLY mode via SET TRANSACTION (async).""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + await async_postgres_backend.transaction_manager.begin() + + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.read_only() + await async_postgres_backend.execute(expr.to_sql()[0]) + + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_async_set_trans_table (name) VALUES (%s)", + ("ShouldFail",) + ) + assert "read-only" in str(exc_info.value).lower() + + # Transaction is aborted after error, rollback to clean up + try: + await async_postgres_backend.transaction_manager.rollback() + except Exception: + pass # Transaction may already be terminated + + +class TestAsyncSessionCharacteristics: + """Async tests for SESSION CHARACTERISTICS.""" + + @pytest_asyncio.fixture + async def async_test_table(self, async_postgres_backend): + """Create a test table.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_session_table") + await async_postgres_backend.execute(""" + CREATE TABLE test_async_session_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) + ) + """) + yield "test_async_session_table" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_async_session_table") + + @pytest.mark.asyncio + async def test_async_session_characteristics_persist(self, async_postgres_backend, async_test_table): + """Test that SESSION CHARACTERISTICS affect subsequent transactions (async).""" + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).read_only() + await async_postgres_backend.execute(expr.to_sql()[0]) + + tx_manager = async_postgres_backend.transaction_manager + await tx_manager.begin() + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_async_session_table (name) VALUES (%s)", + ("ShouldFail",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + await tx_manager.rollback() + + # Second transaction also READ ONLY + await tx_manager.begin() + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_async_session_table (name) VALUES (%s)", + ("ShouldFail2",) + ) + assert "read-only" in str(exc_info.value).lower() + if tx_manager.is_active: + await tx_manager.rollback() + finally: + # Reset session characteristics to READ WRITE for teardown + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).read_write() + await async_postgres_backend.execute(expr.to_sql()[0]) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py index 38541cd8..de3885f7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_deferrable.py @@ -211,13 +211,12 @@ async def test_async_defer_multiple_constraints(self, async_postgres_backend, as async def test_async_get_deferred_constraints(self, async_postgres_backend, async_test_table_with_deferrable_fk): """Test getting list of deferred constraints (async).""" tx_manager = async_postgres_backend.transaction_manager - + async with async_postgres_backend.transaction(): await tx_manager.defer_constraint("async_deferred_fk") - - # Check _deferred_constraints attribute directly since - # AsyncPostgresTransactionManager doesn't have get_deferred_constraints method - deferred = tx_manager._deferred_constraints + + # Use public API to get deferred constraints + deferred = tx_manager.get_deferred_constraints() assert "async_deferred_fk" in deferred @pytest.mark.asyncio @@ -227,7 +226,7 @@ async def test_async_deferred_constraints_cleared_after_commit(self, async_postg async with async_postgres_backend.transaction(): await tx_manager.defer_constraint("async_deferred_fk") - assert len(tx_manager._deferred_constraints) == 1 + assert len(tx_manager.get_deferred_constraints()) == 1 await async_postgres_backend.execute( "INSERT INTO test_deferred_parent (id, name) VALUES (%s, %s)", @@ -238,7 +237,7 @@ async def test_async_deferred_constraints_cleared_after_commit(self, async_postg (3, Decimal("100.00")) ) - assert len(tx_manager._deferred_constraints) == 0 + assert len(tx_manager.get_deferred_constraints()) == 0 @pytest.mark.asyncio async def test_async_deferred_constraints_cleared_after_rollback(self, async_postgres_backend, async_test_table_with_deferrable_fk): @@ -248,9 +247,9 @@ async def test_async_deferred_constraints_cleared_after_rollback(self, async_pos try: async with async_postgres_backend.transaction(): await tx_manager.defer_constraint("async_deferred_fk") - assert len(tx_manager._deferred_constraints) == 1 + assert len(tx_manager.get_deferred_constraints()) == 1 raise Exception("Force rollback") except Exception: pass - assert len(tx_manager._deferred_constraints) == 0 + assert len(tx_manager.get_deferred_constraints()) == 0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py new file mode 100644 index 00000000..021b43ac --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py @@ -0,0 +1,332 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_mode_effect.py +""" +PostgreSQL transaction access mode effect tests. + +This module tests the actual effect of READ ONLY and READ WRITE transaction modes. +It verifies that the modes actually affect database operations as expected. +""" +import pytest +import pytest_asyncio +from decimal import Decimal + +from rhosocial.activerecord.backend.transaction import TransactionMode + + +class TestSyncTransactionModeEffect: + """Synchronous transaction access mode effect tests.""" + + @pytest.fixture + def test_table(self, postgres_backend): + """Create a test table.""" + postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + postgres_backend.execute(""" + CREATE TABLE test_mode_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + amount DECIMAL(10, 2) + ) + """) + yield "test_mode_table" + postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + + def test_read_write_mode_allows_insert(self, postgres_backend, test_table): + """Test that READ WRITE mode allows INSERT operations.""" + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ReadWriteTest", Decimal("100.00")) + ) + + rows = postgres_backend.fetch_all("SELECT name FROM test_mode_table") + assert len(rows) == 1 + assert rows[0]["name"] == "ReadWriteTest" + + def test_read_write_mode_allows_update(self, postgres_backend, test_table): + """Test that READ WRITE mode allows UPDATE operations.""" + # First insert a row + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("Original", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + postgres_backend.execute( + "UPDATE test_mode_table SET amount = %s WHERE name = %s", + (Decimal("200.00"), "Original") + ) + + rows = postgres_backend.fetch_all("SELECT amount FROM test_mode_table WHERE name = %s", ("Original",)) + assert len(rows) == 1 + assert rows[0]["amount"] == Decimal("200.00") + + def test_read_write_mode_allows_delete(self, postgres_backend, test_table): + """Test that READ WRITE mode allows DELETE operations.""" + # First insert a row + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ToBeDeleted", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + postgres_backend.execute( + "DELETE FROM test_mode_table WHERE name = %s", + ("ToBeDeleted",) + ) + + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 0 + + def test_read_only_mode_allows_select(self, postgres_backend, test_table): + """Test that READ ONLY mode allows SELECT operations.""" + # Insert test data first + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ReadOnlySelect", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + with postgres_backend.transaction(): + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + + assert len(rows) == 1 + assert rows[0]["name"] == "ReadOnlySelect" + + def test_read_only_mode_rejects_insert(self, postgres_backend, test_table): + """Test that READ ONLY mode rejects INSERT operations. + + PostgreSQL raises an error when attempting to write in READ ONLY mode. + Error code: 25006 (read_only_sql_transaction) + """ + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + tx_manager.begin() + # PostgreSQL raises an error for write operations in READ ONLY mode + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("100.00")) + ) + # Verify it's a read-only transaction error + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + # Transaction is auto-aborted after error, check if rollback is needed + if tx_manager.is_active: + tx_manager.rollback() + + # Verify no data was inserted + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 0 + + def test_read_only_mode_rejects_update(self, postgres_backend, test_table): + """Test that READ ONLY mode rejects UPDATE operations.""" + # Insert test data first + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("Original", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "UPDATE test_mode_table SET amount = %s WHERE name = %s", + (Decimal("200.00"), "Original") + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + # Verify data was not modified + rows = postgres_backend.fetch_all("SELECT amount FROM test_mode_table WHERE name = %s", ("Original",)) + assert rows[0]["amount"] == Decimal("100.00") + + def test_read_only_mode_rejects_delete(self, postgres_backend, test_table): + """Test that READ ONLY mode rejects DELETE operations.""" + # Insert test data first + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ToBeDeleted", Decimal("100.00")) + ) + + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "DELETE FROM test_mode_table WHERE name = %s", + ("ToBeDeleted",) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + # Verify data was not deleted + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 1 + + def test_mode_cannot_change_during_active_transaction(self, postgres_backend, test_table): + """Test that transaction mode cannot be changed during an active transaction.""" + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + with postgres_backend.transaction(): + from rhosocial.activerecord.backend.errors import TransactionError + with pytest.raises(TransactionError): + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + def test_mode_persists_across_transactions(self, postgres_backend, test_table): + """Test that transaction mode setting persists across multiple transactions.""" + tx_manager = postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + # First transaction + with postgres_backend.transaction(): + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert rows == [] + + # Second transaction should still be READ ONLY + tx_manager.begin() + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("100.00")) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + def test_mode_combined_with_isolation_level(self, postgres_backend, test_table): + """Test that mode can be combined with isolation level.""" + from rhosocial.activerecord.backend.transaction import IsolationLevel + + tx_manager = postgres_backend.transaction_manager + tx_manager.isolation_level = IsolationLevel.REPEATABLE_READ + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + # Insert test data first (outside transaction) + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("CombinedTest", Decimal("100.00")) + ) + + tx_manager.begin() + # Should be able to read + rows = postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 1 + + # Should not be able to write + with pytest.raises(Exception) as exc_info: + postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("200.00")) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg + + if tx_manager.is_active: + tx_manager.rollback() + + +class TestAsyncTransactionModeEffect: + """Asynchronous transaction access mode effect tests.""" + + @pytest_asyncio.fixture + async def async_test_table(self, async_postgres_backend): + """Create a test table.""" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + await async_postgres_backend.execute(""" + CREATE TABLE test_mode_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + amount DECIMAL(10, 2) + ) + """) + yield "test_mode_table" + await async_postgres_backend.execute("DROP TABLE IF EXISTS test_mode_table") + + @pytest.mark.asyncio + async def test_async_read_write_mode_allows_insert(self, async_postgres_backend, async_test_table): + """Test that READ WRITE mode allows INSERT operations (async).""" + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + async with async_postgres_backend.transaction(): + await async_postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("AsyncReadWriteTest", Decimal("100.00")) + ) + + rows = await async_postgres_backend.fetch_all("SELECT name FROM test_mode_table") + assert len(rows) == 1 + assert rows[0]["name"] == "AsyncReadWriteTest" + + @pytest.mark.asyncio + async def test_async_read_only_mode_rejects_insert(self, async_postgres_backend, async_test_table): + """Test that READ ONLY mode rejects INSERT operations (async).""" + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + await tx_manager.begin() + with pytest.raises(Exception) as exc_info: + await async_postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("ShouldFail", Decimal("100.00")) + ) + error_msg = str(exc_info.value).lower() + assert "read-only" in error_msg or "cannot" in error_msg + + if tx_manager.is_active: + await tx_manager.rollback() + + rows = await async_postgres_backend.fetch_all("SELECT * FROM test_mode_table") + assert len(rows) == 0 + + @pytest.mark.asyncio + async def test_async_read_only_mode_allows_select(self, async_postgres_backend, async_test_table): + """Test that READ ONLY mode allows SELECT operations (async).""" + await async_postgres_backend.execute( + "INSERT INTO test_mode_table (name, amount) VALUES (%s, %s)", + ("AsyncReadOnlySelect", Decimal("100.00")) + ) + + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_ONLY + + async with async_postgres_backend.transaction(): + rows = await async_postgres_backend.fetch_all("SELECT * FROM test_mode_table") + + assert len(rows) == 1 + assert rows[0]["name"] == "AsyncReadOnlySelect" + + @pytest.mark.asyncio + async def test_async_mode_cannot_change_during_active_transaction(self, async_postgres_backend, async_test_table): + """Test that transaction mode cannot be changed during an active transaction (async).""" + tx_manager = async_postgres_backend.transaction_manager + tx_manager.transaction_mode = TransactionMode.READ_WRITE + + async with async_postgres_backend.transaction(): + from rhosocial.activerecord.backend.errors import TransactionError + with pytest.raises(TransactionError): + tx_manager.transaction_mode = TransactionMode.READ_ONLY From 477e2896c1f2db146722744ab68fc4a7b57f02a1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 8 Apr 2026 08:39:09 +0800 Subject: [PATCH 2/3] fix(transaction): respect PostgreSQL optional syntax for BEGIN statement PostgreSQL does not require explicit isolation level in BEGIN statement. When isolation_level is None (not explicitly set), BEGIN should not include ISOLATION LEVEL clause, allowing SESSION CHARACTERISTICS to take effect. Changes: - Remove default isolation_level initialization in PostgresTransactionManager - Remove default isolation_level initialization in AsyncPostgresTransactionManager - Update test_session_characteristics_with_isolation_level to verify that SESSION CHARACTERISTICS work when isolation_level is None - Add async counterpart test for sync/async parity This ensures: 1. BEGIN without explicit isolation level respects SESSION CHARACTERISTICS 2. Users can still set explicit isolation_level to override session defaults 3. Synchronous and asynchronous behaviors are equivalent Co-Authored-By: Claude Opus 4.6 --- .../backend/impl/postgres/transaction.py | 12 ++- .../test_transaction_advanced_features.py | 81 +++++++++++++++---- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py index 3b0cb5c4..0acb2611 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py @@ -46,8 +46,10 @@ def __init__(self, backend: "PostgresBackend", logger=None): logger: Optional logger instance. """ super().__init__(backend, logger) - # PostgreSQL default isolation level is READ COMMITTED - self._isolation_level = IsolationLevel.READ_COMMITTED + # PostgreSQL default isolation level is READ COMMITTED, but we don't + # set it here to allow SESSION CHARACTERISTICS to take effect. + # Users can explicitly set isolation_level property if needed. + # self._isolation_level = None (inherited from base class) # DEFERRABLE mode for SERIALIZABLE transactions self._is_deferrable: Optional[bool] = None # Deferred constraints list @@ -196,8 +198,10 @@ def __init__(self, backend: "AsyncPostgresBackend", logger=None): logger: Optional logger instance. """ super().__init__(backend, logger) - # PostgreSQL default isolation level is READ COMMITTED - self._isolation_level = IsolationLevel.READ_COMMITTED + # PostgreSQL default isolation level is READ COMMITTED, but we don't + # set it here to allow SESSION CHARACTERISTICS to take effect. + # Users can explicitly set isolation_level property if needed. + # self._isolation_level = None (inherited from base class) # DEFERRABLE mode for SERIALIZABLE transactions self._is_deferrable: Optional[bool] = None # Deferred constraints list diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py index c31c1594..be6eb8e9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py @@ -262,13 +262,10 @@ def test_reset_session_characteristics(self, postgres_backend, test_table): def test_session_characteristics_with_isolation_level(self, postgres_backend, test_table): """Test session characteristics with isolation level. - Note: The transaction manager always explicitly sets isolation level - in BEGIN statements, which overrides SESSION CHARACTERISTICS. - This test verifies that SESSION CHARACTERISTICS can be set and - that explicit settings in BEGIN take precedence. - - To test SESSION CHARACTERISTICS isolation level directly, - you would need to execute a plain BEGIN without parameters. + When isolation_level is not explicitly set (None), SESSION CHARACTERISTICS + should take effect. This test verifies: + 1. SESSION CHARACTERISTICS can set default isolation level + 2. BEGIN without explicit isolation level uses session default """ from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression @@ -278,19 +275,30 @@ def test_session_characteristics_with_isolation_level(self, postgres_backend, te expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_write() postgres_backend.execute(expr.to_sql()[0]) - # The transaction manager's isolation_level property determines - # what gets set in BEGIN statement, not session characteristics - # So we set the transaction manager's isolation level to match - postgres_backend.transaction_manager.isolation_level = IsolationLevel.SERIALIZABLE + # Verify isolation_level is None (not explicitly set) + tx_manager = postgres_backend.transaction_manager + assert tx_manager.isolation_level is None, \ + "isolation_level should be None to allow SESSION CHARACTERISTICS to take effect" + + # Use begin() directly (not context manager) to avoid implicit isolation level setting + tx_manager.begin() + level = tx_manager.get_current_isolation_level() + assert level == IsolationLevel.SERIALIZABLE, \ + f"Expected SERIALIZABLE from SESSION CHARACTERISTICS, got {level}" + tx_manager.commit() + # Now test with explicit isolation level override + tx_manager.isolation_level = IsolationLevel.READ_COMMITTED with postgres_backend.transaction(): - level = postgres_backend.transaction_manager.get_current_isolation_level() - assert level == IsolationLevel.SERIALIZABLE + level = tx_manager.get_current_isolation_level() + assert level == IsolationLevel.READ_COMMITTED, \ + f"Explicit isolation level should override SESSION CHARACTERISTICS, got {level}" finally: # Reset to default READ WRITE with READ COMMITTED - postgres_backend.transaction_manager.isolation_level = IsolationLevel.READ_COMMITTED + tx_manager = postgres_backend.transaction_manager + tx_manager.isolation_level = None expr = SetTransactionExpression(postgres_backend.dialect) - expr.session(True).isolation_level(IsolationLevel.READ_COMMITTED).read_write() + expr.session(True).read_write() postgres_backend.execute(expr.to_sql()[0]) @@ -458,3 +466,46 @@ async def test_async_session_characteristics_persist(self, async_postgres_backen expr = SetTransactionExpression(async_postgres_backend.dialect) expr.session(True).read_write() await async_postgres_backend.execute(expr.to_sql()[0]) + + @pytest.mark.asyncio + async def test_async_session_characteristics_with_isolation_level(self, async_postgres_backend, async_test_table): + """Test session characteristics with isolation level (async). + + When isolation_level is not explicitly set (None), SESSION CHARACTERISTICS + should take effect. This test verifies: + 1. SESSION CHARACTERISTICS can set default isolation level + 2. BEGIN without explicit isolation level uses session default + """ + from rhosocial.activerecord.backend.expression.transaction import SetTransactionExpression + + try: + # Set session characteristics with SERIALIZABLE isolation + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_write() + await async_postgres_backend.execute(expr.to_sql()[0]) + + # Verify isolation_level is None (not explicitly set) + tx_manager = async_postgres_backend.transaction_manager + assert tx_manager.isolation_level is None, \ + "isolation_level should be None to allow SESSION CHARACTERISTICS to take effect" + + # Use begin() directly (not context manager) to avoid implicit isolation level setting + await tx_manager.begin() + level = await tx_manager.get_current_isolation_level() + assert level == IsolationLevel.SERIALIZABLE, \ + f"Expected SERIALIZABLE from SESSION CHARACTERISTICS, got {level}" + await tx_manager.commit() + + # Now test with explicit isolation level override + tx_manager.isolation_level = IsolationLevel.READ_COMMITTED + async with async_postgres_backend.transaction(): + level = await tx_manager.get_current_isolation_level() + assert level == IsolationLevel.READ_COMMITTED, \ + f"Explicit isolation level should override SESSION CHARACTERISTICS, got {level}" + finally: + # Reset to default READ WRITE with no explicit isolation level + tx_manager = async_postgres_backend.transaction_manager + tx_manager.isolation_level = None + expr = SetTransactionExpression(async_postgres_backend.dialect) + expr.session(True).read_write() + await async_postgres_backend.execute(expr.to_sql()[0]) From c067f9f5dddf32ca6b41751c91313b6800d5b36e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 8 Apr 2026 08:44:58 +0800 Subject: [PATCH 3/3] chore: add changelog fragment for PR #20 Co-Authored-By: Claude Opus 4.6 --- changelog.d/20.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/20.fixed.md diff --git a/changelog.d/20.fixed.md b/changelog.d/20.fixed.md new file mode 100644 index 00000000..dc6b190e --- /dev/null +++ b/changelog.d/20.fixed.md @@ -0,0 +1 @@ +Fixed PostgreSQL transaction manager to respect optional BEGIN syntax - isolation level is no longer forced, allowing SESSION CHARACTERISTICS to take effect when not explicitly set.