Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @invariant decorator to explicitly configure invariants #425

Merged
merged 3 commits into from
May 21, 2024
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
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
6 changes: 4 additions & 2 deletions src/protean/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
__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
from .core.domain_service import BaseDomainService
from .core.email import BaseEmail
from .core.entity import BaseEntity
from .core.entity import BaseEntity, invariant
from .core.event import BaseEvent
from .core.event_handler import BaseEventHandler
from .core.event_sourced_aggregate import BaseEventSourcedAggregate, apply
Expand Down Expand Up @@ -53,4 +53,6 @@
"current_uow",
"get_version",
"handle",
"invariant",
"atomic_change",
]
15 changes: 14 additions & 1 deletion 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 @@ -320,7 +323,17 @@ def __setattr__(self, name, value):
if (
name in attributes(self)
or name in fields(self)
or name in ["errors", "state_", "_temp_cache", "_events", "_initialized"]
or name
in [
"errors", # Errors in state transition
"state_", # Tracking dirty state of the entity
"_temp_cache", # Temporary cache (Assocations) for storing data befor persisting
"_events", # Temp placeholder for events raised by the entity
"_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_"))
):
super().__setattr__(name, value)
Expand Down
33 changes: 32 additions & 1 deletion src/protean/core/aggregate.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Aggregate Functionality and Classes"""

import inspect
import logging

from protean.container import EventedMixin
Expand Down Expand Up @@ -50,6 +51,10 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# Set root in all child elements
# This is where we kick-off the process of setting the owner and root
self._set_root_and_owner(self, self)

@classmethod
def _default_options(cls):
return [
Expand All @@ -61,4 +66,30 @@ def _default_options(cls):


def aggregate_factory(element_cls, **kwargs):
return derive_element_class(element_cls, BaseAggregate, **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[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
97 changes: 91 additions & 6 deletions src/protean/core/entity.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Entity Functionality and Classes"""

import copy
import functools
import inspect
import logging

from collections import defaultdict
Expand Down Expand Up @@ -131,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 @@ -145,6 +149,13 @@ def __init__(self, *template, **kwargs): # noqa: C901
# Placeholder for HasMany change tracking
self._temp_cache = defaultdict(lambda: defaultdict(dict))

# Attributes to preserve heirarchy of element instances
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 @@ -281,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 @@ -296,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 @@ -421,6 +459,33 @@ def _extract_options(cls, **opts):
)
setattr(cls.meta_, key, value)

def _set_root_and_owner(self, root, owner):
"""Set the root and owner entities on all child entities

This is a recursive process set in motion by the aggregate's `__init__` method.
"""
self._root = root
self._owner = owner

# Set `_root` on all child entities
for field_name, field_obj in declared_fields(self).items():
# We care only about enclosed fields (associations)
if isinstance(field_obj, Association):
# Get current assigned value
value = getattr(self, field_name)
if value is not None:
# Link child entities to own root
items = value if isinstance(value, list) else [value]
for item in items:
if not item._root:
item._set_root_and_owner(self._root, self)

def __init_subclass__(subclass) -> None:
super().__init_subclass__()

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


def entity_factory(element_cls, **kwargs):
element_cls = derive_element_class(element_cls, BaseEntity, **kwargs)
Expand Down Expand Up @@ -480,4 +545,24 @@ def entity_factory(element_cls, **kwargs):
shadow_field_name, shadow_field = field.get_shadow_field()
shadow_field.__set_name__(element_cls, shadow_field_name)

# Iterate through methods marked as `@invariant` and record them for later use
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[method_name] = method

return element_cls


def invariant(fn):
"""Decorator to mark invariant methods in an Entity"""

@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)

setattr(wrapper, "_invariant", True)

return wrapper
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
Loading
Loading