Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/27.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added sync and async contracts for the `StaleRecordError` optimistic-lock exception, including `DatabaseError` compatibility and distinction from `IntegrityError`.
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -30,15 +35,63 @@

# 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:

Check warning on line 83 in src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-testsuite&issues=AaHA3Ble8uUS8c5x121n&open=AaHA3Ble8uUS8c5x121n&pullRequest=27
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -30,15 +35,65 @@

# 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:

Check warning on line 85 in src/rhosocial/activerecord/testsuite/feature/mixins/test_optimistic_lock_async.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-testsuite&issues=AaHA3Bjt8uUS8c5x121m&open=AaHA3Bjt8uUS8c5x121m&pullRequest=27
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
Expand Down
Loading