diff --git a/changelog.d/27.added.md b/changelog.d/27.added.md new file mode 100644 index 0000000..945f488 --- /dev/null +++ b/changelog.d/27.added.md @@ -0,0 +1 @@ +Added sync and async contracts for the `StaleRecordError` optimistic-lock exception, including `DatabaseError` compatibility and distinction from `IntegrityError`. diff --git a/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles.py b/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles.py index 474744c..785e6ff 100644 --- a/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles.py +++ b/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles.py @@ -6,7 +6,7 @@ import pytest -from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.errors import StaleRecordError from rhosocial.activerecord.testsuite.utils import assert_datetime_equal @@ -24,7 +24,8 @@ def test_combined_update(combined_article_model): # Verify updated state assert article.version == 2 # Version number increments - assert_datetime_equal(article.created_at, original_updated_at) # Creation time remains unchanged + # Creation time remains unchanged + assert_datetime_equal(article.created_at, original_updated_at) assert article.updated_at > original_updated_at # Update time changes @@ -67,7 +68,9 @@ def test_combined_concurrent_update(combined_article_model): # Second update fails concurrent_article.content = "Updated by second" - with pytest.raises(DatabaseError, match="Record was updated by another process"): + with pytest.raises( + StaleRecordError, match="Record was updated by another process" + ): concurrent_article.save() # Verify final state diff --git a/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles_async.py b/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles_async.py index 54547e0..c61423f 100644 --- a/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/mixins/test_combined_articles_async.py @@ -6,7 +6,7 @@ import pytest -from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.errors import StaleRecordError from rhosocial.activerecord.testsuite.utils import assert_datetime_equal @@ -24,7 +24,8 @@ async def test_combined_update(async_combined_article_model): # Verify updated state assert article.version == 2 # Version number increments - assert_datetime_equal(article.created_at, original_updated_at) # Creation time remains unchanged + # Creation time remains unchanged + assert_datetime_equal(article.created_at, original_updated_at) assert article.updated_at > original_updated_at # Update time changes @@ -67,7 +68,9 @@ async def test_combined_concurrent_update(async_combined_article_model): # Second update fails concurrent_article.content = "Updated by second" - with pytest.raises(DatabaseError, match="Record was updated by another process"): + with pytest.raises( + StaleRecordError, match="Record was updated by another process" + ): await concurrent_article.save() # Verify final state diff --git a/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock.py b/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock.py index 4b7a2f9..12a9a8a 100644 --- a/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock.py +++ b/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock.py @@ -4,7 +4,12 @@ """ import pytest -from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.errors import ( + DatabaseError, + IntegrityError, + StaleRecordError, +) +from rhosocial.activerecord.backend.options import UpdateOptions def test_optimistic_lock(versioned_product_model): @@ -30,15 +35,63 @@ def test_optimistic_lock(versioned_product_model): # Original record update should now fail product.price = 25.0 # product.version is still 2 here - with pytest.raises(DatabaseError, match="Record was updated by another process"): + with pytest.raises( + StaleRecordError, match="Record was updated by another process" + ) as exc_info: product.save() + stale = exc_info.value + assert stale.model is versioned_product_model + assert stale.primary_key == product.id + assert stale.expected_version == 2 + assert stale.operation == "update" + # Verify final version latest_product = versioned_product_model.find_one(product.id) assert latest_product.version == 3 assert latest_product.price == pytest.approx(20.0) +def test_stale_record_error_is_database_error(versioned_product_model): + """A stale record remains catchable through the DatabaseError base class.""" + product = versioned_product_model(name="Test Product", price=10.0) + product.save() + + product_conflict = versioned_product_model.find_one(product.id) + product_conflict.price = 20.0 + product_conflict.save() + + product.price = 25.0 + with pytest.raises( + DatabaseError, match="Record was updated by another process" + ) as exc_info: + product.save() + + # The concrete type is still available to callers that opt in. + assert isinstance(exc_info.value, StaleRecordError) + + +def test_integrity_error_is_not_stale(versioned_product_model): + """A plain database constraint failure keeps its own type.""" + product = versioned_product_model(name="Test Product", price=10.0) + product.save() + + # Update through the backend without the optimistic-lock condition, and + # violate the NOT NULL constraint. This is a plain database error, not a + # stale-record conflict. + where = versioned_product_model._build_pk_where_predicate(product._get_pk_value()) + with pytest.raises(IntegrityError) as exc_info: + product.backend().update( + UpdateOptions( + table=product.table_name(), + data={"name": None}, + where=where, + ) + ) + + assert not isinstance(exc_info.value, StaleRecordError) + + def test_version_increment(versioned_product_model): """Test version number increments correctly""" # Create new record diff --git a/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock_async.py b/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock_async.py index a944fd5..b2f6743 100644 --- a/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock_async.py @@ -4,7 +4,12 @@ """ import pytest -from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.errors import ( + DatabaseError, + IntegrityError, + StaleRecordError, +) +from rhosocial.activerecord.backend.options import UpdateOptions async def test_optimistic_lock(async_versioned_product_model): @@ -30,15 +35,65 @@ async def test_optimistic_lock(async_versioned_product_model): # Original record update should now fail product.price = 25.0 # product.version is still 2 here - with pytest.raises(DatabaseError, match="Record was updated by another process"): + with pytest.raises( + StaleRecordError, match="Record was updated by another process" + ) as exc_info: await product.save() + stale = exc_info.value + assert stale.model is async_versioned_product_model + assert stale.primary_key == product.id + assert stale.expected_version == 2 + assert stale.operation == "update" + # Verify final version latest_product = await async_versioned_product_model.find_one(product.id) assert latest_product.version == 3 assert latest_product.price == pytest.approx(20.0) +async def test_stale_record_error_is_database_error(async_versioned_product_model): + """A stale record remains catchable through the DatabaseError base class.""" + product = async_versioned_product_model(name="Test Product", price=10.0) + await product.save() + + product_conflict = await async_versioned_product_model.find_one(product.id) + product_conflict.price = 20.0 + await product_conflict.save() + + product.price = 25.0 + with pytest.raises( + DatabaseError, match="Record was updated by another process" + ) as exc_info: + await product.save() + + # The concrete type is still available to callers that opt in. + assert isinstance(exc_info.value, StaleRecordError) + + +async def test_integrity_error_is_not_stale(async_versioned_product_model): + """A plain database constraint failure keeps its own type.""" + product = async_versioned_product_model(name="Test Product", price=10.0) + await product.save() + + # Update through the backend without the optimistic-lock condition, and + # violate the NOT NULL constraint. This is a plain database error, not a + # stale-record conflict. + where = async_versioned_product_model._build_pk_where_predicate( + product._get_pk_value() + ) + with pytest.raises(IntegrityError) as exc_info: + await product.backend().update( + UpdateOptions( + table=product.table_name(), + data={"name": None}, + where=where, + ) + ) + + assert not isinstance(exc_info.value, StaleRecordError) + + async def test_version_increment(async_versioned_product_model): """Test version number increments correctly""" # Create new record