Skip to content

Commit

Permalink
Generate fact events automatically
Browse files Browse the repository at this point in the history
Aggregates can now registes for fact events and have them generated automatically
whenever they are changed.

Also:
- Allow Value Objects to hold all kinds of fields except associations
  • Loading branch information
subhashb committed Jul 1, 2024
1 parent a0fd5c6 commit 32fe82c
Show file tree
Hide file tree
Showing 22 changed files with 782 additions and 13 deletions.
70 changes: 68 additions & 2 deletions src/protean/core/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import logging

from protean.core.entity import BaseEntity
from protean.core.event import BaseEvent
from protean.core.value_object import BaseValueObject
from protean.exceptions import NotSupportedError
from protean.fields import Integer
from protean.fields import HasMany, HasOne, Integer, List, Reference, ValueObject
from protean.reflection import fields
from protean.utils import DomainObjects, derive_element_class, inflection

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,9 +64,71 @@ def _default_options(cls):
("model", None),
("stream_name", inflection.underscore(cls.__name__)),
("schema_name", inflection.underscore(cls.__name__)),
("fact_events", False),
]


def element_to_fact_event(element_cls):
"""Convert an Element to a Fact Event.
This is a helper function to convert an Element to a Fact Event. Fact Events are used to
store the state of an Aggregate Element at a point in time. This function is used during
domain initialization to detect aggregates that have registered for fact events generation.
Associations are converted to Value Objects:
1. A `HasOne` association is replaced with a Value Object.
2. A `HasMany` association is replaced with a List of Value Objects.
The target class of associations is constructed as the Value Object.
"""
# Gather all fields defined in the element, except References.
# We ignore references.
attrs = {
key: value
for key, value in fields(element_cls).items()
if not isinstance(value, Reference)
}

# Recursively convert HasOne and HasMany associations to Value Objects
for key, value in attrs.items():
if isinstance(value, HasOne):
attrs[key] = element_to_fact_event(value.to_cls)
elif isinstance(value, HasMany):
attrs[key] = List(content_type=element_to_fact_event(value.to_cls))

# If we are dealing with an Entity, we convert it to a Value Object
# and return it.
if element_cls.element_type == DomainObjects.ENTITY:
for _, attr_value in attrs.items():
if attr_value.identifier:
attr_value.identifier = False
if attr_value.unique:
attr_value.unique = False

value_object_cls = type(
f"{element_cls.__name__}ValueObject",
(BaseValueObject,),
attrs,
)
value_object_field = ValueObject(value_object_cls=value_object_cls)
return value_object_field

# Otherwise, we are dealing with an aggregate. By the time we reach here,
# we have already converted all associations in the aggregate to Value Objects.
# We can now proceed to construct the Fact Event.
event_cls = type(
f"{element_cls.__name__}FactEvent",
(BaseEvent,),
attrs,
)

# Store the fact event class as part of the aggregate itself
setattr(element_cls, "_fact_event_cls", event_cls)

# Return the fact event class to be registered with the domain
return event_cls


def aggregate_factory(element_cls, **kwargs):
element_cls = derive_element_class(element_cls, BaseAggregate, **kwargs)

Expand All @@ -79,8 +144,9 @@ def aggregate_factory(element_cls, **kwargs):
return element_cls


# Context manager to temporarily disable invariant checks on aggregate
class atomic_change:
"""Context manager to temporarily disable invariant checks on aggregate"""

def __init__(self, aggregate):
self.aggregate = aggregate

Expand Down
11 changes: 11 additions & 0 deletions src/protean/core/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ def add(self, aggregate: BaseAggregate) -> BaseAggregate: # noqa: C901
):
self._dao.save(aggregate)

# If there are Fact Events associated with the Aggregate, raise them now
if aggregate.meta_.fact_events:
payload = aggregate.to_dict()

# Remove state attribute from the payload, as it is not needed for the Fact Event
payload.pop("state_", None)

# Construct and raise the Fact Event
fact_event = aggregate._fact_event_cls(**payload)
aggregate.raise_(fact_event)

# If we started a UnitOfWork, commit it now
if own_current_uow:
own_current_uow.commit()
Expand Down
7 changes: 4 additions & 3 deletions src/protean/core/value_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from protean.container import BaseContainer, OptionsMixin, fields
from protean.exceptions import IncorrectUsageError, NotSupportedError, ValidationError
from protean.fields import Reference, ValueObject
from protean.fields import Reference
from protean.fields.association import Association
from protean.utils import DomainObjects, derive_element_class

Expand Down Expand Up @@ -34,11 +34,12 @@ def __init_subclass__(subclass) -> None:
@classmethod
def __validate_for_basic_field_types(subclass):
for field_name, field_obj in fields(subclass).items():
if isinstance(field_obj, (Reference, Association, ValueObject)):
# Value objects can hold all kinds of fields, except associations
if isinstance(field_obj, (Reference, Association)):
raise IncorrectUsageError(
{
"_value_object": [
f"Value Objects can only contain basic field types. "
f"Value Objects cannot have associations. "
f"Remove {field_name} ({field_obj.__class__.__name__}) from class {subclass.__name__}"
]
}
Expand Down
13 changes: 13 additions & 0 deletions src/protean/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from protean.adapters import Brokers, Caches, EmailProviders, Providers
from protean.adapters.event_store import EventStore
from protean.core.aggregate import element_to_fact_event
from protean.core.command import BaseCommand
from protean.core.command_handler import BaseCommandHandler
from protean.core.event import BaseEvent
Expand Down Expand Up @@ -205,6 +206,9 @@ def init(self, traverse=True): # noqa: C901
# Set Aggregate Cluster Options
self._set_aggregate_cluster_options()

# Generate Fact Event Classes
self._generate_fact_event_classes()

# Run Validations
self._validate_domain()

Expand Down Expand Up @@ -780,6 +784,15 @@ def _set_aggregate_cluster_options(self):
element.cls.meta_.aggregate_cluster.meta_.provider,
)

def _generate_fact_event_classes(self):
"""Generate FactEvent classes for all aggregates with `fact_events` enabled"""
for _, element in self.registry._elements[
DomainObjects.AGGREGATE.value
].items():
if element.cls.meta_.fact_events:
event_cls = element_to_fact_event(element.cls)
self.register(event_cls, part_of=element.cls)

######################
# Element Decorators #
######################
Expand Down
3 changes: 3 additions & 0 deletions src/protean/reflection.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ def declared_fields(class_or_instance):
# Might it be worth caching this, per class?
try:
fields_dict = dict(getattr(class_or_instance, _FIELDS))

# Remove internal fields
fields_dict.pop("_version", None)
fields_dict.pop("_metadata", None)
except AttributeError:
raise IncorrectUsageError(
{"field": [f"{class_or_instance} does not have fields"]}
Expand Down
40 changes: 40 additions & 0 deletions tests/aggregate/events/test_raising_fact_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import pytest

from protean import BaseAggregate, BaseEntity
from protean.fields import HasOne, String
from protean.utils.mixins import Message


class Account(BaseEntity):
password_hash = String(max_length=512)


class User(BaseAggregate):
name = String(max_length=50, required=True)
email = String(required=True)
status = String(choices=["ACTIVE", "ARCHIVED"])

account = HasOne(Account)


@pytest.fixture(autouse=True)
def register_elements(test_domain):
test_domain.register(User, fact_events=True)
test_domain.register(Account, part_of=User)
test_domain.init(traverse=False)


def test_generation_of_first_fact_event_on_persistence(test_domain):
user = User(name="John Doe", email="[email protected]")
test_domain.repository_for(User).add(user)

# Read event from event store
event_messages = test_domain.event_store.store.read(f"user-{user.id}")
assert len(event_messages) == 1

# Deserialize event
event = Message.to_object(event_messages[0])
assert event is not None
assert event.__class__.__name__ == "UserFactEvent"
assert event.name == "John Doe"
assert event.email == "[email protected]"
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import pytest

from protean import BaseAggregate, BaseEntity, BaseEvent
from protean.core.aggregate import element_to_fact_event
from protean.fields import Date, HasMany, List, String, ValueObject
from protean.reflection import declared_fields


class Customer(BaseAggregate):
name = String(max_length=50)
orders = HasMany("Order")


class Order(BaseEntity):
ordered_on = Date()


@pytest.fixture(autouse=True)
def register_elements(test_domain):
test_domain.register(Customer)
test_domain.register(Order, part_of=Customer)
test_domain.init(traverse=False)


@pytest.fixture
def event_cls():
return element_to_fact_event(Customer)


def test_fact_event_class_generation(event_cls):
assert event_cls.__name__ == "CustomerFactEvent"
assert issubclass(event_cls, BaseEvent)
assert len(declared_fields(event_cls)) == 3

assert all(
field_name in declared_fields(event_cls)
for field_name in ["name", "orders", "id"]
)


def test_orders_is_a_list_of_value_objects(event_cls):
orders_field = declared_fields(event_cls)["orders"]

assert isinstance(orders_field, List)
assert isinstance(orders_field.content_type, ValueObject)
assert orders_field.content_type._value_object_cls.__name__ == "OrderValueObject"


def test_order_value_object_fields(event_cls):
orders_field = declared_fields(event_cls)["orders"]
order_vo_cls = orders_field.content_type._value_object_cls

assert len(declared_fields(order_vo_cls)) == 2
assert all(
field_name in declared_fields(order_vo_cls)
for field_name in ["ordered_on", "id"]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import pytest

from protean import BaseAggregate, BaseEntity, BaseEvent, BaseValueObject
from protean.core.aggregate import element_to_fact_event
from protean.fields import HasMany, HasOne, Integer, List, String, ValueObject
from protean.reflection import declared_fields


class University(BaseAggregate):
name = String(max_length=50)
departments = HasMany("Department")


class Department(BaseEntity):
name = String(max_length=50)
dean = HasOne("Dean")


class Dean(BaseEntity):
name = String(max_length=50)
age = Integer(min_value=21)


@pytest.fixture(autouse=True)
def register_elements(test_domain):
test_domain.register(University)
test_domain.register(Department, part_of=University)
test_domain.register(Dean, part_of=Department)
test_domain.init(traverse=False)


@pytest.fixture
def event_cls():
return element_to_fact_event(University)


def test_fact_event_class_generation(event_cls):
assert event_cls.__name__ == "UniversityFactEvent"
assert issubclass(event_cls, BaseEvent)
assert len(declared_fields(event_cls)) == 3

assert all(
field_name in declared_fields(event_cls)
for field_name in ["name", "departments", "id"]
)


def test_departments_is_a_list_of_value_objects(event_cls):
departments_field = declared_fields(event_cls)["departments"]

assert isinstance(departments_field, List)
assert isinstance(departments_field.content_type, ValueObject)
assert (
departments_field.content_type._value_object_cls.__name__
== "DepartmentValueObject"
)


def test_dean_is_a_value_object(event_cls):
departments_field = declared_fields(event_cls)["departments"]
dean_field = declared_fields(departments_field.content_type._value_object_cls)[
"dean"
]
dean_vo_cls = dean_field._value_object_cls

assert issubclass(dean_vo_cls, BaseValueObject)
assert dean_vo_cls.__name__ == "DeanValueObject"


def test_department_value_object_fields(event_cls):
departments_field = declared_fields(event_cls)["departments"]
department_vo_cls = departments_field.content_type._value_object_cls

assert len(declared_fields(department_vo_cls)) == 3
assert all(
field_name in declared_fields(department_vo_cls)
for field_name in ["name", "dean", "id"]
)


def test_dean_value_object_fields(event_cls):
departments_field = declared_fields(event_cls)["departments"]
dean_field = declared_fields(departments_field.content_type._value_object_cls)[
"dean"
]
dean_vo_cls = dean_field._value_object_cls

assert len(declared_fields(dean_vo_cls)) == 3
assert all(
field_name in declared_fields(dean_vo_cls)
for field_name in ["name", "age", "id"]
)
Loading

0 comments on commit 32fe82c

Please sign in to comment.