Skip to content

Commit

Permalink
Run all invariants on init and attribute changes
Browse files Browse the repository at this point in the history
When an aggregate or entity object is initialized, or its attribute value
is changed, this commit ensures that all `clean()` methods (which in turn
run all invariant validations) are run across the aggregate.

This commit also adds `atomic_change` context manager for scenarios
where multiple aggregates in an aggregate need to be changed before
it becomes valid again.
  • Loading branch information
subhashb committed May 21, 2024
1 parent d57833b commit 331cc74
Show file tree
Hide file tree
Showing 16 changed files with 459 additions and 47 deletions.
4 changes: 4 additions & 0 deletions docs/guides/compose-a-domain/object-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ document outlines generic aspects that apply to every domain element.
`Element` is a base class inherited by all domain elements. Currently, it does
not have any data structures or behavior associated with it.

## Element Type

<Element>.element_type

## Data Containers

Protean provides data container elements, aligned with DDD principles to model
Expand Down
20 changes: 20 additions & 0 deletions docs/guides/domain-definition/fields/simple-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ Out[2]:
'id': '88a21815-7d9b-4138-9cac-5a06889d4318'}
```

Protean will intelligently convert a valid date string into a date object, with
the help of the venerable
[`dateutil`](https://dateutil.readthedocs.io/en/stable/) module.

```shell
In [1]: post = Post(title='Foo', published_on="2020-01-01")

In [2]: post.to_dict()
Out[2]:
{'title': 'Foo',
'published_on': '2020-01-01',
'id': 'ffcb3b26-71f0-45d0-8ca0-b71a9603f792'}

In [3]: Post(title='Foo', published_on="2019-02-29")
ERROR: Error during initialization: {'published_on': ['"2019-02-29" has an invalid date format.']}
...
ValidationError: {'published_on': ['"2019-02-29" has an invalid date format.']}
```


## DateTime

A date and time, represented in Python by a `datetime.datetime` instance.
Expand Down
3 changes: 2 additions & 1 deletion src/protean/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
__version__ = "0.11.0"

from .core.aggregate import BaseAggregate
from .core.aggregate import BaseAggregate, atomic_change
from .core.application_service import BaseApplicationService
from .core.command import BaseCommand
from .core.command_handler import BaseCommandHandler
Expand Down Expand Up @@ -54,4 +54,5 @@
"get_version",
"handle",
"invariant",
"atomic_change",
]
4 changes: 4 additions & 0 deletions src/protean/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def __init__(self, *template, **kwargs): # noqa: C901
This initialization technique supports keyword arguments as well as dictionaries. You
can even use a template for initial data.
"""
self._initialized = False

if self.meta_.abstract is True:
raise NotSupportedError(
Expand Down Expand Up @@ -265,6 +266,8 @@ def __init__(self, *template, **kwargs): # noqa: C901

self.defaults()

self._initialized = True

# `clean()` will return a `defaultdict(list)` if errors are to be raised
custom_errors = self.clean() or {}
for field in custom_errors:
Expand Down Expand Up @@ -329,6 +332,7 @@ def __setattr__(self, name, value):
"_initialized", # Flag to indicate if the entity has been initialized
"_root", # Root entity in the hierarchy
"_owner", # Owner entity in the hierarchy
"_disable_invariant_checks", # Flag to disable invariant checks
]
or name.startswith(("add_", "remove_"))
):
Expand Down
18 changes: 17 additions & 1 deletion src/protean/core/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,27 @@ def aggregate_factory(element_cls, **kwargs):
element_cls = derive_element_class(element_cls, BaseAggregate, **kwargs)

# Iterate through methods marked as `@invariant` and record them for later use
# `_invariants` is a dictionary initialized in BaseEntity.__init_subclass__
methods = inspect.getmembers(element_cls, predicate=inspect.isroutine)
for method_name, method in methods:
if not (
method_name.startswith("__") and method_name.endswith("__")
) and hasattr(method, "_invariant"):
element_cls._invariants.append(method)
element_cls._invariants[method_name] = method

return element_cls


# Context manager to temporarily disable invariant checks on aggregate
class atomic_change:
def __init__(self, aggregate):
self.aggregate = aggregate

def __enter__(self):
# Temporary disable invariant checks
self.aggregate._disable_invariant_checks = True

def __exit__(self, *args):
# Run clean() on exit to trigger invariant checks
self.aggregate._disable_invariant_checks = False
self.aggregate.clean()
7 changes: 0 additions & 7 deletions src/protean/core/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,11 @@ def __init_subclass__(subclass) -> None:
subclass.__track_id_field()

def __init__(self, *args, **kwargs):
# Set the flag to prevent any further modifications
self._initialized = False

try:
super().__init__(*args, **kwargs)
except ValidationError as exception:
raise InvalidDataError(exception.messages)

# If we made it this far, the Value Object is initialized
# and should be marked as such
self._initialized = True

def __setattr__(self, name, value):
if not hasattr(self, "_initialized") or not self._initialized:
return super().__setattr__(name, value)
Expand Down
48 changes: 40 additions & 8 deletions src/protean/core/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ def __init__(self, *template, **kwargs): # noqa: C901
user = User(base_user.to_dict(), first_name='John', last_name='Doe')
"""

self._initialized = False

if self.meta_.abstract is True:
raise NotSupportedError(
f"{self.__class__.__name__} class has been marked abstract"
Expand All @@ -151,6 +153,9 @@ def __init__(self, *template, **kwargs): # noqa: C901
self._owner = None
self._root = None

# To control invariant checks
self._disable_invariant_checks = False

# Collect Reference field attribute names to prevent accidental overwriting
# of shadow fields.
reference_attributes = {
Expand Down Expand Up @@ -287,8 +292,10 @@ def __init__(self, *template, **kwargs): # noqa: C901

self.defaults()

self._initialized = True

# `clean()` will return a `defaultdict(list)` if errors are to be raised
custom_errors = self.clean() or {}
custom_errors = self.clean(return_errors=True) or {}
for field in custom_errors:
self.errors[field].extend(custom_errors[field])

Expand All @@ -302,11 +309,36 @@ def defaults(self):
To be overridden in concrete Containers, when an attribute's default depends on other attribute values.
"""

def clean(self):
"""Placeholder method for validations.
To be overridden in concrete Containers, when complex validations spanning multiple fields are required.
"""
return defaultdict(list)
def clean(self, return_errors=False):
"""Invoked after initialization to perform additional validations."""
# Call all methods marked as invariants
if self._initialized and not self._disable_invariant_checks:
errors = defaultdict(list)

for invariant_method in self._invariants.values():
try:
invariant_method(self)
except ValidationError as err:
for field_name in err.messages:
errors[field_name].extend(err.messages[field_name])

# Run through all associations and trigger their clean method
for field_name, field_obj in declared_fields(self).items():
if isinstance(field_obj, Association):
value = getattr(self, field_name)
if value is not None:
items = value if isinstance(value, list) else [value]
for item in items:
item_errors = item.clean(return_errors=True)
if item_errors:
for sub_field_name, error_list in item_errors.items():
errors[sub_field_name].extend(error_list)

if return_errors:
return errors

if errors:
raise ValidationError(errors)

def __eq__(self, other):
"""Equivalence check to be based only on Identity"""
Expand Down Expand Up @@ -452,7 +484,7 @@ def __init_subclass__(subclass) -> None:
super().__init_subclass__()

# Record invariant methods
setattr(subclass, "_invariants", [])
setattr(subclass, "_invariants", {})


def entity_factory(element_cls, **kwargs):
Expand Down Expand Up @@ -519,7 +551,7 @@ def entity_factory(element_cls, **kwargs):
if not (
method_name.startswith("__") and method_name.endswith("__")
) and hasattr(method, "_invariant"):
element_cls._invariants.append(method)
element_cls._invariants[method_name] = method

return element_cls

Expand Down
10 changes: 0 additions & 10 deletions src/protean/core/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,6 @@ def __init_subclass__(subclass) -> None:
if not subclass.meta_.abstract:
subclass.__track_id_field()

def __init__(self, *args, **kwargs):
# Set the flag to prevent any further modifications
self._initialized = False

super().__init__(*args, **kwargs)

# If we made it this far, the Value Object is initialized
# and should be marked as such
self._initialized = True

def __setattr__(self, name, value):
if not hasattr(self, "_initialized") or not self._initialized:
return super().__setattr__(name, value)
Expand Down
13 changes: 13 additions & 0 deletions src/protean/fields/association.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ def __set__(self, instance, value):
elif isinstance(field_obj, HasOne):
setattr(old_value, field_name, None)

if instance._initialized and instance._root is not None:
instance._root.clean() # Trigger validations from the top

def _fetch_objects(self, instance, key, identifier):
"""Fetch single linked object"""
try:
Expand Down Expand Up @@ -526,6 +529,10 @@ def add(self, instance, items) -> None:

current_value_ids = [value.id for value in data]

# Remove items when set to empty
if len(items) == 0 and len(current_value_ids) > 0:
self.remove(instance, data)

for item in items:
# Items to add
if item.id not in current_value_ids:
Expand Down Expand Up @@ -563,6 +570,9 @@ def add(self, instance, items) -> None:
# Reset Cache
self.delete_cached_value(instance)

if instance._initialized and instance._root is not None:
instance._root.clean() # Trigger validations from the top

def remove(self, instance, items) -> None:
"""
Available as `add_<HasMany Field Name>` method on the entity instance.
Expand Down Expand Up @@ -609,6 +619,9 @@ def remove(self, instance, items) -> None:
elif isinstance(field_obj, HasOne):
setattr(item, field_name, None)

if instance._initialized and instance._root is not None:
instance._root.clean() # Trigger validations from the top

def _fetch_objects(self, instance, key, value) -> list:
"""
Fetch linked entities.
Expand Down
9 changes: 9 additions & 0 deletions src/protean/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,17 @@ def __get__(self, instance, owner):

def __set__(self, instance, value):
value = self._load(value)

instance.__dict__[self.field_name] = value

# The hasattr check is necessary to avoid running clean on unrelated elements
if (
instance._initialized
and hasattr(instance, "_root")
and instance._root is not None
):
instance._root.clean() # Trigger validations from the top

# Mark Entity as Dirty
if hasattr(instance, "state_"):
instance.state_.mark_changed()
Expand Down
7 changes: 1 addition & 6 deletions src/protean/fields/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,12 +421,7 @@ def __set__(self, instance, value):
if existing_value is not None and value != existing_value:
raise InvalidOperationError("Identifiers cannot be changed once set")

value = self._load(value)
instance.__dict__[self.field_name] = value

if hasattr(instance, "state_"):
# Mark Entity as Dirty
instance.state_.mark_changed()
super().__set__(instance, value)

def as_dict(self, value):
"""Return JSON-compatible value of self"""
Expand Down
45 changes: 45 additions & 0 deletions tests/aggregate/test_atomic_change.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Test `atomic_change` context manager"""

import pytest

from protean import BaseAggregate, atomic_change, invariant
from protean.fields import Integer
from protean.exceptions import ValidationError


class TestAtomicChange:
def test_atomic_change_context_manager(self):
class TestAggregate(BaseAggregate):
pass

aggregate = TestAggregate()

with atomic_change(aggregate):
assert aggregate._disable_invariant_checks is True

assert aggregate._disable_invariant_checks is False

def test_clean_is_not_triggered_within_context_manager(self, test_domain):
class TestAggregate(BaseAggregate):
value1 = Integer()
value2 = Integer()

@invariant
def raise_error(self):
if self.value2 != self.value1 + 1:
raise ValidationError({"_entity": ["Invariant error"]})

test_domain.register(TestAggregate)
test_domain.init(traverse=False)

aggregate = TestAggregate(value1=1, value2=2)

# This raises an error because of the invariant
with pytest.raises(ValidationError):
aggregate.value1 = 2
aggregate.value2 = 3

# This should not raise an error because of the context manager
with atomic_change(aggregate):
aggregate.value1 = 2
aggregate.value2 = 3
15 changes: 7 additions & 8 deletions tests/entity/elements.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections import defaultdict
from enum import Enum

from protean import BaseAggregate, BaseEntity
from protean import BaseAggregate, BaseEntity, invariant
from protean.exceptions import ValidationError
from protean.fields import Auto, HasOne, Integer, String


Expand Down Expand Up @@ -149,10 +149,9 @@ def defaults(self):
else:
self.status = BuildingStatus.WIP.value

def clean(self):
errors = defaultdict(list)

@invariant
def test_building_status_to_be_done_if_floors_above_4(self):
if self.floors >= 4 and self.status != BuildingStatus.DONE.value:
errors["status"].append("should be DONE")

return errors
raise ValidationError(
{"_entity": ["Building status should be DONE if floors are above 4"]}
)
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ def test_that_entity_has_recorded_invariants(test_domain):

assert len(Order._invariants) == 3
# Methods are presented in ascending order (alphabetical order) of member names.
assert Order._invariants[0].__name__ == "item_quantities_should_be_positive"
assert Order._invariants[1].__name__ == "must_have_at_least_one_item"
assert Order._invariants[2].__name__ == "total_should_be_sum_of_item_prices"
assert "item_quantities_should_be_positive" in Order._invariants
assert "must_have_at_least_one_item" in Order._invariants
assert "total_should_be_sum_of_item_prices" in Order._invariants

assert len(OrderItem._invariants) == 1
assert OrderItem._invariants[0].__name__ == "price_should_be_non_negative"
assert "price_should_be_non_negative" in OrderItem._invariants
Loading

0 comments on commit 331cc74

Please sign in to comment.