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. 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 new file mode 100644 index 00000000..be6eb8e9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_transaction_advanced_features.py @@ -0,0 +1,511 @@ +# 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. + + 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(postgres_backend.dialect) + expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_write() + postgres_backend.execute(expr.to_sql()[0]) + + # 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 = 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 + tx_manager = postgres_backend.transaction_manager + tx_manager.isolation_level = None + expr = SetTransactionExpression(postgres_backend.dialect) + expr.session(True).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]) + + @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]) 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