Skip to content

Commit

Permalink
Introduce version to Events
Browse files Browse the repository at this point in the history
Events can now optionally specify a version with `__version__` class attribute.

This commit also contains:
- An optional `finalize` flag to `BaseContainer.__init__` to control element finalization
- Additional tests for Event Sourced Aggregates
  • Loading branch information
subhashb committed Jun 27, 2024
1 parent 3b30259 commit f89ec6f
Show file tree
Hide file tree
Showing 10 changed files with 361 additions and 64 deletions.
10 changes: 9 additions & 1 deletion src/protean/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ def __init__(self, *template, **kwargs): # noqa: C901
"""
self._initialized = False

# Flag to control if the container is marked initialized and immutable
# Other elements, like BaseEvent, that subclass BaseContainer, will be
# able to augment the initialization with their custom code, and then
# mark the container as initialized.
self._finalize = kwargs.pop("finalize", True)

if self.meta_.abstract is True:
raise NotSupportedError(
f"{self.__class__.__name__} class has been marked abstract"
Expand Down Expand Up @@ -264,7 +270,8 @@ def __init__(self, *template, **kwargs): # noqa: C901

self.defaults()

self._initialized = True
if self._finalize:
self._initialized = True

# Raise any errors found during load
if self.errors:
Expand Down Expand Up @@ -317,6 +324,7 @@ def __setattr__(self, name, value):
"_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
"_finalize", # Flag to indicate if the entity is to be finalized
"_root", # Root entity in the hierarchy
"_owner", # Owner entity in the hierarchy
"_disable_invariant_checks", # Flag to disable invariant checks
Expand Down
13 changes: 13 additions & 0 deletions src/protean/core/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
class Metadata(BaseValueObject):
kind = String(default="EVENT")
timestamp = DateTime(default=lambda: datetime.now(timezone.utc))
version = String(default="v1")


class BaseEvent(BaseContainer, OptionsMixin): # FIXME Remove OptionsMixin
Expand Down Expand Up @@ -90,6 +91,18 @@ def __track_id_field(subclass):
# No Identity fields declared
pass

def __init__(self, *args, **kwargs):
super().__init__(*args, finalize=False, **kwargs)

if hasattr(self.__class__, "__version__"):
# Value Objects are immutable, so we create a clone/copy and associate it
self._metadata = Metadata(
self._metadata.to_dict(), version=self.__class__.__version__
)

# Finally lock the event and make it immutable
self._initialized = True

@property
def payload(self):
"""Return the payload of the event."""
Expand Down
9 changes: 3 additions & 6 deletions src/protean/core/event_sourced_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def _apply(self, event: BaseEvent) -> None:
# FIXME Handle case of missing projection method
if event_name not in self._projections:
raise NotImplementedError(
f"No handler registered for event {event_name} in {self.__class__.__name__}"
f"No handler registered for event `{event_name}` in `{self.__class__.__name__}`"
)

for fn in self._projections[event_name]:
Expand Down Expand Up @@ -125,7 +125,7 @@ def apply(fn):
raise IncorrectUsageError(
{
"_entity": [
f"Apply method {fn.__name__} has incorrect number of arguments"
f"Handler method `{fn.__name__}` has incorrect number of arguments"
]
}
)
Expand Down Expand Up @@ -183,9 +183,6 @@ def event_sourced_aggregate_factory(element_cls, **opts):
#
# The domain validation should check for the same event class being present
# in `_events_cls_map` of multiple aggregate classes.
if inspect.isclass(method._event_cls) and issubclass(
method._event_cls, BaseEvent
):
method._event_cls.meta_.part_of = element_cls
method._event_cls.meta_.part_of = element_cls

return element_cls
23 changes: 23 additions & 0 deletions tests/container/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from protean.container import BaseContainer, OptionsMixin
from protean.fields import String


class TestControlledFinalization:
def test_that_objects_are_initialized_by_default(self):
# FIXME Remove OptionsMixin when it becomes optional
class Foo(BaseContainer, OptionsMixin):
foo = String()

foo = Foo()
assert foo._initialized is True

def test_that_objects_can_be_initialized_manually(self):
class Foo(BaseContainer, OptionsMixin):
foo = String()

def __init__(self, *args, **kwargs):
super().__init__(*args, finalize=False, **kwargs)

foo = Foo()

assert foo._initialized is False
46 changes: 44 additions & 2 deletions tests/event/test_event_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import pytest

from protean import BaseEvent, BaseEventSourcedAggregate
from protean.fields import String
from protean.fields import String, ValueObject
from protean.fields.basic import Identifier
from protean.reflection import declared_fields, fields


class User(BaseEventSourcedAggregate):
Expand All @@ -28,7 +29,47 @@ def register_elements(test_domain):
test_domain.init(traverse=False)


def test_event_metadata(test_domain):
def test_event_has_metadata_value_object():
assert "_metadata" in declared_fields(UserLoggedIn)
assert isinstance(declared_fields(UserLoggedIn)["_metadata"], ValueObject)

assert hasattr(UserLoggedIn, "_metadata")


def test_metadata_defaults():
event = UserLoggedIn(user_id=str(uuid4()))
assert event._metadata is not None
assert event._metadata.kind == "EVENT"
assert isinstance(event._metadata.timestamp, datetime)


def test_metadata_can_be_overridden():
# Setting `kind` breaks the system elsewhere, but suffices for this test
event = UserLoggedIn(user_id=str(uuid4()), _metadata={"kind": "FOO"})
assert event._metadata is not None
assert event._metadata.kind == "FOO"
assert isinstance(event._metadata.timestamp, datetime)


class TestEventMetadataVersion:
def test_metadata_has_event_version(self):
metadata_field = fields(UserLoggedIn)["_metadata"]
assert hasattr(metadata_field.value_object_cls, "version")

def test_event_metadata_version_default(self):
event = UserLoggedIn(user_id=str(uuid4()))
assert event._metadata.version == "v1"

def test_overridden_version(self):
class UserLoggedIn(BaseEvent):
__version__ = "v2"
user_id = Identifier(identifier=True)

event = UserLoggedIn(user_id=str(uuid4()))
assert event._metadata.version == "v2"


def test_event_metadata():
user_id = str(uuid4())
user = User(id=user_id, email="<EMAIL>", name="<NAME>")

Expand All @@ -47,6 +88,7 @@ def test_event_metadata(test_domain):
"_metadata": {
"kind": "EVENT",
"timestamp": str(event._metadata.timestamp),
"version": "v1",
},
"user_id": event.user_id,
}
1 change: 1 addition & 0 deletions tests/event/test_event_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def test_event_payload():
"_metadata": {
"kind": "EVENT",
"timestamp": str(event._metadata.timestamp),
"version": "v1",
},
"user_id": event.user_id,
}
Expand Down
36 changes: 36 additions & 0 deletions tests/event/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class UserAdded(BaseEvent):
"_metadata": {
"kind": "EVENT",
"timestamp": str(event._metadata.timestamp),
"version": "v1",
},
"email": {
"address": "[email protected]",
Expand Down Expand Up @@ -78,3 +79,38 @@ def special_method(self):
pass

assert fully_qualified_name(AnnotatedDomainEvent) in test_domain.registry.events


class TestDomainEventEquivalence:
def test_that_two_domain_events_with_same_values_are_considered_equal(self):
identifier = uuid.uuid4()
event_1 = PersonAdded(id=identifier, first_name="John", last_name="Doe")
event_2 = PersonAdded(id=identifier, first_name="John", last_name="Doe")

assert event_1 == event_2

def test_that_two_domain_events_with_different_values_are_not_considered_equal(
self,
):
identifier = uuid.uuid4()

event_1 = PersonAdded(id=identifier, first_name="John", last_name="Doe")
event_2 = PersonAdded(id=identifier, first_name="Jane", last_name="Doe")

assert event_1 != event_2

def test_that_two_domain_events_with_different_values_are_not_considered_equal_with_different_types(
self,
):
identifier = uuid.uuid4()

class User(Person):
pass

class UserAdded(PersonAdded):
pass

event_1 = PersonAdded(id=identifier, first_name="John", last_name="Doe")
event_2 = UserAdded(id=identifier, first_name="John", last_name="Doe")

assert event_1 != event_2
Loading

0 comments on commit f89ec6f

Please sign in to comment.