From 1a4b495c2e2faeabc404c9a73be511163c1f09da Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Tue, 9 Jul 2024 13:56:19 -0700 Subject: [PATCH 01/17] Gather Event version on class initialization --- src/protean/core/event.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 526970f5..36811806 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -75,6 +75,10 @@ def __init_subclass__(subclass) -> None: if not subclass.meta_.abstract: subclass.__track_id_field() + # Use explicit version if specified, else default to "v1" + if not hasattr(subclass, "__version__"): + setattr(subclass, "__version__", "v1") + def __setattr__(self, name, value): if not hasattr(self, "_initialized") or not self._initialized: return super().__setattr__(name, value) @@ -120,12 +124,6 @@ def __init__(self, *args, **kwargs): # Store the expected version temporarily for use during persistence self._expected_version = kwargs.pop("_expected_version", -1) - version = ( - self.__class__.__version__ - if hasattr(self.__class__, "__version__") - else "v1" - ) - origin_stream_name = None if hasattr(g, "message_in_context"): if ( @@ -139,7 +137,7 @@ def __init__(self, *args, **kwargs): self._metadata.to_dict(), # Template kind="EVENT", origin_stream_name=origin_stream_name, - version=version, + version=self.__class__.__version__, # Was set in `__init_subclass__` ) # Finally lock the event and make it immutable From 6750c1e524cf6109e9eb47adc23b5696ceca7926 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Tue, 9 Jul 2024 14:06:20 -0700 Subject: [PATCH 02/17] Move event type to --- src/protean/container.py | 3 ++- src/protean/core/entity.py | 3 ++- tests/aggregate/events/test_aggregate_streams.py | 8 ++++---- tests/event/test_event_metadata.py | 2 +- tests/event/test_event_payload.py | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/protean/container.py b/src/protean/container.py index 093d1f14..fe9a2e52 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -14,6 +14,7 @@ ValidationError, ) from protean.fields import Auto, Field, FieldBase, ValueObject +from protean.globals import current_domain from protean.reflection import id_field from protean.utils import generate_identity @@ -414,7 +415,7 @@ def raise_(self, event, fact_event=False) -> None: _expected_version=self._event_position, _metadata={ "id": (f"{stream_name}-{identifier}-{self._version}"), - "type": f"{self.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", + "type": f"{current_domain.name}.{event.__class__.__name__}.{event._metadata.version}", "kind": "EVENT", "stream_name": f"{stream_name}-{identifier}", "origin_stream_name": event._metadata.origin_stream_name, diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index bc7115d7..46736303 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -16,6 +16,7 @@ ) from protean.fields import Auto, HasMany, Reference, ValueObject from protean.fields.association import Association +from protean.globals import current_domain from protean.reflection import ( _FIELDS, attributes, @@ -470,7 +471,7 @@ def raise_(self, event) -> None: "id": ( f"{stream_name}-{identifier}-{aggregate_version}.{event_number}" ), - "type": f"{self._root.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", + "type": f"{current_domain.name}.{event.__class__.__name__}.{event._metadata.version}", "kind": "EVENT", "stream_name": f"{stream_name}-{identifier}", "origin_stream_name": event._metadata.origin_stream_name, diff --git a/tests/aggregate/events/test_aggregate_streams.py b/tests/aggregate/events/test_aggregate_streams.py index 4d818c0e..af11dff6 100644 --- a/tests/aggregate/events/test_aggregate_streams.py +++ b/tests/aggregate/events/test_aggregate_streams.py @@ -62,12 +62,12 @@ def test_event_metadata(self): assert len(user._events) == 2 assert user._events[0]._metadata.id == f"user-{user.id}-0.1" - assert user._events[0]._metadata.type == "User.UserRenamed.v1" + assert user._events[0]._metadata.type == "Test.UserRenamed.v1" assert user._events[0]._metadata.version == "v1" assert user._events[0]._metadata.sequence_id == "0.1" assert user._events[1]._metadata.id == f"user-{user.id}-0.2" - assert user._events[1]._metadata.type == "User.UserActivated.v1" + assert user._events[1]._metadata.type == "Test.UserActivated.v1" assert user._events[1]._metadata.version == "v1" assert user._events[1]._metadata.sequence_id == "0.2" @@ -90,11 +90,11 @@ def test_event_metadata_from_stream(self, test_domain): assert len(event_messages) == 2 assert event_messages[0].metadata.id == f"user-{user.id}-0.1" - assert event_messages[0].metadata.type == "User.UserRenamed.v1" + assert event_messages[0].metadata.type == "Test.UserRenamed.v1" assert event_messages[0].metadata.version == "v1" assert event_messages[0].metadata.sequence_id == "0.1" assert event_messages[1].metadata.id == f"user-{user.id}-0.2" - assert event_messages[1].metadata.type == "User.UserActivated.v1" + assert event_messages[1].metadata.type == "Test.UserActivated.v1" assert event_messages[1].metadata.version == "v1" assert event_messages[1].metadata.sequence_id == "0.2" diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index c7c04fc2..75ee4dc0 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -84,7 +84,7 @@ def test_event_metadata(): assert event.to_dict() == { "_metadata": { "id": f"user-{user.id}-0", - "type": "User.UserLoggedIn.v1", + "type": "Test.UserLoggedIn.v1", "kind": "EVENT", "stream_name": f"user-{user.id}", "origin_stream_name": None, diff --git a/tests/event/test_event_payload.py b/tests/event/test_event_payload.py index 23f07229..4ff0341e 100644 --- a/tests/event/test_event_payload.py +++ b/tests/event/test_event_payload.py @@ -37,7 +37,7 @@ def test_event_payload(): assert event.to_dict() == { "_metadata": { "id": f"user-{user_id}-0", - "type": "User.UserLoggedIn.v1", + "type": "Test.UserLoggedIn.v1", "kind": "EVENT", "stream_name": f"user-{user_id}", "origin_stream_name": None, From c2c56d5e1a462dbf0a235b656142aa9c728789ff Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Tue, 9 Jul 2024 16:18:42 -0700 Subject: [PATCH 03/17] Pass domain into factory methods --- docs/patterns/sharing-event-classes-across-domains.md | 0 src/protean/adapters/event_store/__init__.py | 2 +- src/protean/adapters/repository/__init__.py | 4 +++- src/protean/core/aggregate.py | 4 ++-- src/protean/core/application_service.py | 4 ++-- src/protean/core/command.py | 4 ++-- src/protean/core/command_handler.py | 4 ++-- src/protean/core/domain_service.py | 4 ++-- src/protean/core/email.py | 2 +- src/protean/core/entity.py | 4 ++-- src/protean/core/event.py | 4 ++-- src/protean/core/event_handler.py | 2 +- src/protean/core/event_sourced_aggregate.py | 2 +- src/protean/core/event_sourced_repository.py | 2 +- src/protean/core/model.py | 4 ++-- src/protean/core/repository.py | 2 +- src/protean/core/serializer.py | 4 ++-- src/protean/core/subscriber.py | 4 ++-- src/protean/core/value_object.py | 4 ++-- src/protean/core/view.py | 10 +++++----- src/protean/domain/__init__.py | 4 ++-- 21 files changed, 38 insertions(+), 36 deletions(-) create mode 100644 docs/patterns/sharing-event-classes-across-domains.md diff --git a/docs/patterns/sharing-event-classes-across-domains.md b/docs/patterns/sharing-event-classes-across-domains.md new file mode 100644 index 00000000..e69de29b diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index 9b315dc3..fb2c3562 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -89,7 +89,7 @@ def repository_for(self, part_of): part_of.__name__ + "Repository", (BaseEventSourcedRepository,), {} ) repository_cls = event_sourced_repository_factory( - repository_cls, part_of=part_of + repository_cls, self.domain, part_of=part_of ) return repository_cls(self.domain) diff --git a/src/protean/adapters/repository/__init__.py b/src/protean/adapters/repository/__init__.py index e4d52e99..9036a0eb 100644 --- a/src/protean/adapters/repository/__init__.py +++ b/src/protean/adapters/repository/__init__.py @@ -59,7 +59,9 @@ def __delitem__(self, key): def _construct_repository(self, part_of): repository_cls = type(part_of.__name__ + "Repository", (BaseRepository,), {}) - repository_cls = repository_factory(repository_cls, part_of=part_of) + repository_cls = repository_factory( + repository_cls, self.domain, part_of=part_of + ) return repository_cls def _register_repository(self, part_of, repository_cls): diff --git a/src/protean/core/aggregate.py b/src/protean/core/aggregate.py index 3a0c2225..d973a07a 100644 --- a/src/protean/core/aggregate.py +++ b/src/protean/core/aggregate.py @@ -143,8 +143,8 @@ def element_to_fact_event(element_cls): return event_cls -def aggregate_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseAggregate, **kwargs) +def aggregate_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseAggregate, **opts) # Iterate through methods marked as `@invariant` and record them for later use # `_invariants` is a dictionary initialized in BaseEntity.__init_subclass__ diff --git a/src/protean/core/application_service.py b/src/protean/core/application_service.py index f41af0dd..cc4418f8 100644 --- a/src/protean/core/application_service.py +++ b/src/protean/core/application_service.py @@ -31,5 +31,5 @@ def _default_options(cls): return [] -def application_service_factory(element_cls, **kwargs): - return derive_element_class(element_cls, BaseApplicationService, **kwargs) +def application_service_factory(element_cls, domain, **opts): + return derive_element_class(element_cls, BaseApplicationService, **opts) diff --git a/src/protean/core/command.py b/src/protean/core/command.py index f0380a81..148f3a91 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -113,8 +113,8 @@ def __track_id_field(subclass): pass -def command_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseCommand, **kwargs) +def command_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseCommand, **opts) if not element_cls.meta_.part_of and not element_cls.meta_.abstract: raise IncorrectUsageError( diff --git a/src/protean/core/command_handler.py b/src/protean/core/command_handler.py index f56a0263..b08a2cab 100644 --- a/src/protean/core/command_handler.py +++ b/src/protean/core/command_handler.py @@ -26,8 +26,8 @@ def __new__(cls, *args, **kwargs): return super().__new__(cls) -def command_handler_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseCommandHandler, **kwargs) +def command_handler_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseCommandHandler, **opts) if not element_cls.meta_.part_of: raise IncorrectUsageError( diff --git a/src/protean/core/domain_service.py b/src/protean/core/domain_service.py index a20044d4..2b9c9bd7 100644 --- a/src/protean/core/domain_service.py +++ b/src/protean/core/domain_service.py @@ -109,8 +109,8 @@ def wrapped_call(self, *args, **kwargs): return cls -def domain_service_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseDomainService, **kwargs) +def domain_service_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseDomainService, **opts) if not element_cls.meta_.part_of or len(element_cls.meta_.part_of) < 2: raise IncorrectUsageError( diff --git a/src/protean/core/email.py b/src/protean/core/email.py index 51b4780f..d7b1b560 100644 --- a/src/protean/core/email.py +++ b/src/protean/core/email.py @@ -85,5 +85,5 @@ def recipients(self): return [email for email in (self.to + self.cc + self.bcc) if email] -def email_factory(element_cls, **kwargs): +def email_factory(element_cls, domain, **opts): return derive_element_class(element_cls, BaseEmail) diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 46736303..b26c7f46 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -602,8 +602,8 @@ def _set_root_and_owner(self, root, owner): item._set_root_and_owner(self._root, self) -def entity_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseEntity, **kwargs) +def entity_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseEntity, **opts) if not element_cls.meta_.part_of: raise IncorrectUsageError( diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 36811806..52dc15e3 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -175,8 +175,8 @@ def to_dict(self): } -def domain_event_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseEvent, **kwargs) +def domain_event_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseEvent, **opts) if not element_cls.meta_.part_of and not element_cls.meta_.abstract: raise IncorrectUsageError( diff --git a/src/protean/core/event_handler.py b/src/protean/core/event_handler.py index c85e0b18..56560775 100644 --- a/src/protean/core/event_handler.py +++ b/src/protean/core/event_handler.py @@ -32,7 +32,7 @@ def _default_options(cls): ] -def event_handler_factory(element_cls, **opts): +def event_handler_factory(element_cls, domain, **opts): element_cls = derive_element_class(element_cls, BaseEventHandler, **opts) if not (element_cls.meta_.part_of or element_cls.meta_.stream_name): diff --git a/src/protean/core/event_sourced_aggregate.py b/src/protean/core/event_sourced_aggregate.py index c240ae60..2649eee0 100644 --- a/src/protean/core/event_sourced_aggregate.py +++ b/src/protean/core/event_sourced_aggregate.py @@ -164,7 +164,7 @@ def wrapper(*args): return wrapper -def event_sourced_aggregate_factory(element_cls, **opts): +def event_sourced_aggregate_factory(element_cls, domain, **opts): element_cls = derive_element_class(element_cls, BaseEventSourcedAggregate, **opts) # Iterate through methods marked as `@apply` and construct a projections map diff --git a/src/protean/core/event_sourced_repository.py b/src/protean/core/event_sourced_repository.py index 6aa03bd9..949109dc 100644 --- a/src/protean/core/event_sourced_repository.py +++ b/src/protean/core/event_sourced_repository.py @@ -101,7 +101,7 @@ def get(self, identifier: Identifier) -> BaseEventSourcedAggregate: return aggregate -def event_sourced_repository_factory(element_cls, **opts): +def event_sourced_repository_factory(element_cls, domain, **opts): element_cls = derive_element_class(element_cls, BaseEventSourcedRepository, **opts) if not element_cls.meta_.part_of: diff --git a/src/protean/core/model.py b/src/protean/core/model.py index eee21774..663f9e96 100644 --- a/src/protean/core/model.py +++ b/src/protean/core/model.py @@ -36,8 +36,8 @@ def to_entity(cls, *args, **kwargs): """Convert Model Object to Entity Object""" -def model_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseModel, **kwargs) +def model_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseModel, **opts) if not element_cls.meta_.part_of: raise IncorrectUsageError( diff --git a/src/protean/core/repository.py b/src/protean/core/repository.py index cc7d4af8..86add121 100644 --- a/src/protean/core/repository.py +++ b/src/protean/core/repository.py @@ -257,7 +257,7 @@ def get(self, identifier) -> BaseAggregate: return aggregate -def repository_factory(element_cls, **opts): +def repository_factory(element_cls, domain, **opts): element_cls = derive_element_class(element_cls, BaseRepository, **opts) if not element_cls.meta_.part_of: diff --git a/src/protean/core/serializer.py b/src/protean/core/serializer.py index b34421f3..b25e9981 100644 --- a/src/protean/core/serializer.py +++ b/src/protean/core/serializer.py @@ -200,5 +200,5 @@ def _default_options(cls): return [] -def serializer_factory(element_cls, **kwargs): - return derive_element_class(element_cls, BaseSerializer, **kwargs) +def serializer_factory(element_cls, domain, **opts): + return derive_element_class(element_cls, BaseSerializer, **opts) diff --git a/src/protean/core/subscriber.py b/src/protean/core/subscriber.py index a7d903b9..c5f70f21 100644 --- a/src/protean/core/subscriber.py +++ b/src/protean/core/subscriber.py @@ -34,8 +34,8 @@ def __call__(self, event: BaseEvent) -> Optional[Any]: raise NotImplementedError -def subscriber_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseSubscriber, **kwargs) +def subscriber_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseSubscriber, **opts) if not element_cls.meta_.event: raise IncorrectUsageError( diff --git a/src/protean/core/value_object.py b/src/protean/core/value_object.py index 3986bc53..bd864821 100644 --- a/src/protean/core/value_object.py +++ b/src/protean/core/value_object.py @@ -201,8 +201,8 @@ def _postcheck(self, return_errors=False): raise ValidationError(errors) -def value_object_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseValueObject, **kwargs) +def value_object_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseValueObject, **opts) # Iterate through methods marked as `@invariant` and record them for later use methods = inspect.getmembers(element_cls, predicate=inspect.isroutine) diff --git a/src/protean/core/view.py b/src/protean/core/view.py index 0bb4c7e9..c1bfff55 100644 --- a/src/protean/core/view.py +++ b/src/protean/core/view.py @@ -105,8 +105,8 @@ def __hash__(self): return hash(getattr(self, id_field(self).field_name)) -def view_factory(element_cls, **kwargs): - element_cls = derive_element_class(element_cls, BaseView, **kwargs) +def view_factory(element_cls, domain, **opts): + element_cls = derive_element_class(element_cls, BaseView, **opts) if not element_cls.meta_.abstract and not hasattr(element_cls, _ID_FIELD_NAME): raise IncorrectUsageError( @@ -118,17 +118,17 @@ def view_factory(element_cls, **kwargs): ) element_cls.meta_.provider = ( - kwargs.pop("provider", None) + opts.pop("provider", None) or (hasattr(element_cls, "meta_") and element_cls.meta_.provider) or "default" ) element_cls.meta_.cache = ( - kwargs.pop("cache", None) + opts.pop("cache", None) or (hasattr(element_cls, "meta_") and element_cls.meta_.cache) or None ) element_cls.meta_.model = ( - kwargs.pop("model", None) + opts.pop("model", None) or (hasattr(element_cls, "meta_") and element_cls.meta_.model) or None ) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 77b28b6c..d665f11c 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -436,7 +436,7 @@ def factory_for(self, domain_object_type): return factories[domain_object_type.value] - def _register_element(self, element_type, element_cls, **kwargs): # noqa: C901 + def _register_element(self, element_type, element_cls, **opts): # noqa: C901 """Register class into the domain""" # Check if `element_cls` is already a subclass of the Element Type # which would be the case in an explicit declaration like `class Account(BaseEntity):` @@ -450,7 +450,7 @@ def _register_element(self, element_type, element_cls, **kwargs): # noqa: C901 # ``` factory = self.factory_for(element_type) - new_cls = factory(element_cls, **kwargs) + new_cls = factory(element_cls, self, **opts) if element_type == DomainObjects.MODEL: # Remember model association with aggregate/entity class, for easy fetching From a1b7301619f91f150c9c91d218932ce5cc332e22 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Wed, 10 Jul 2024 07:08:42 -0700 Subject: [PATCH 04/17] Construct and store event type on event registration This is done on registration because `domain.name` is a part of the event type string. Ex. `Customer.Registered.v1`. --- src/protean/container.py | 12 +++++----- src/protean/core/entity.py | 15 +++++------- src/protean/core/event.py | 21 +++++++++++++++-- .../test_event_association_with_aggregate.py | 23 ++++++++++++++++++- tests/event/test_event_metadata.py | 4 +++- tests/event/test_serialization.py | 9 +++++++- tests/event/tests.py | 18 ++++++++++++--- .../test_uow_around_event_handlers.py | 5 +++- .../test_event_association_with_aggregate.py | 2 +- 9 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/protean/container.py b/src/protean/container.py index fe9a2e52..622a559f 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -406,18 +406,18 @@ def raise_(self, event, fact_event=False) -> None: # Set Fact Event stream to be `-fact` if event.__class__.__name__.endswith("FactEvent"): - stream_name = f"{self.meta_.stream_name}-fact" + stream_name = f"{self.meta_.stream_name}-fact-{identifier}" else: - stream_name = self.meta_.stream_name + stream_name = f"{self.meta_.stream_name}-{identifier}" event_with_metadata = event.__class__( event.to_dict(), _expected_version=self._event_position, _metadata={ - "id": (f"{stream_name}-{identifier}-{self._version}"), - "type": f"{current_domain.name}.{event.__class__.__name__}.{event._metadata.version}", - "kind": "EVENT", - "stream_name": f"{stream_name}-{identifier}", + "id": (f"{stream_name}-{self._version}"), + "type": event._metadata.type, + "kind": event._metadata.kind, + "stream_name": stream_name, "origin_stream_name": event._metadata.origin_stream_name, "timestamp": event._metadata.timestamp, "version": event._metadata.version, diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index b26c7f46..899d03e9 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -16,7 +16,6 @@ ) from protean.fields import Auto, HasMany, Reference, ValueObject from protean.fields.association import Association -from protean.globals import current_domain from protean.reflection import ( _FIELDS, attributes, @@ -460,20 +459,18 @@ def raise_(self, event) -> None: # Set Fact Event stream to be `-fact` if event.__class__.__name__.endswith("FactEvent"): - stream_name = f"{self._root.meta_.stream_name}-fact" + stream_name = f"{self._root.meta_.stream_name}-fact-{identifier}" else: - stream_name = self._root.meta_.stream_name + stream_name = f"{self._root.meta_.stream_name}-{identifier}" event_with_metadata = event.__class__( event.to_dict(), _expected_version=self._root._event_position, _metadata={ - "id": ( - f"{stream_name}-{identifier}-{aggregate_version}.{event_number}" - ), - "type": f"{current_domain.name}.{event.__class__.__name__}.{event._metadata.version}", - "kind": "EVENT", - "stream_name": f"{stream_name}-{identifier}", + "id": (f"{stream_name}-{aggregate_version}.{event_number}"), + "type": event._metadata.type, + "kind": event._metadata.kind, + "stream_name": stream_name, "origin_stream_name": event._metadata.origin_stream_name, "timestamp": event._metadata.timestamp, "version": event._metadata.version, diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 52dc15e3..84e583f7 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -4,7 +4,11 @@ from protean.container import BaseContainer, OptionsMixin from protean.core.value_object import BaseValueObject -from protean.exceptions import IncorrectUsageError, NotSupportedError +from protean.exceptions import ( + ConfigurationError, + IncorrectUsageError, + NotSupportedError, +) from protean.fields import DateTime, Field, Integer, String, ValueObject from protean.globals import g from protean.reflection import _ID_FIELD_NAME, declared_fields, fields @@ -121,6 +125,11 @@ def __track_id_field(subclass): def __init__(self, *args, **kwargs): super().__init__(*args, finalize=False, **kwargs) + if not hasattr(self.__class__, "__type__"): + raise ConfigurationError( + f"Event `{self.__class__.__name__}` should be registered with a domain" + ) + # Store the expected version temporarily for use during persistence self._expected_version = kwargs.pop("_expected_version", -1) @@ -134,7 +143,8 @@ def __init__(self, *args, **kwargs): # Value Objects are immutable, so we create a clone/copy and associate it self._metadata = Metadata( - self._metadata.to_dict(), # Template + self._metadata.to_dict(), # Template from old Metadata + type=self.__class__.__type__, kind="EVENT", origin_stream_name=origin_stream_name, version=self.__class__.__version__, # Was set in `__init_subclass__` @@ -187,4 +197,11 @@ def domain_event_factory(element_cls, domain, **opts): } ) + # Set the event type for the event class + setattr( + element_cls, + "__type__", + f"{domain.name}.{element_cls.__name__}.{element_cls.__version__}", + ) + return element_cls diff --git a/tests/aggregate/events/test_event_association_with_aggregate.py b/tests/aggregate/events/test_event_association_with_aggregate.py index f80250a7..b28db345 100644 --- a/tests/aggregate/events/test_event_association_with_aggregate.py +++ b/tests/aggregate/events/test_event_association_with_aggregate.py @@ -43,6 +43,14 @@ def change_name(self, name): self.raise_(UserRenamed(user_id=self.user_id, name=name)) +class User2(User): + pass + + +class UserUnknownEvent(BaseEvent): + user_id = Identifier(required=True) + + @pytest.fixture(autouse=True) def register_elements(test_domain): test_domain.register(User) @@ -56,6 +64,19 @@ def test_an_unassociated_event_throws_error(test_domain): with pytest.raises(ConfigurationError) as exc: user.raise_(UserArchived(user_id=user.user_id)) + assert ( + exc.value.args[0] == "Event `UserArchived` should be registered with a domain" + ) + + +def test_that_event_associated_with_another_aggregate_throws_error(test_domain): + test_domain.register(User2) + test_domain.register(UserUnknownEvent, part_of=User2) + + user = User.register(user_id="1", name="", email="") + with pytest.raises(ConfigurationError) as exc: + user.raise_(UserUnknownEvent(user_id=user.user_id)) + assert exc.value.args[0] == ( - "Event `UserArchived` is not associated with aggregate `User`" + "Event `UserUnknownEvent` is not associated with aggregate `User`" ) diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index 75ee4dc0..ea62e85d 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -58,11 +58,13 @@ def test_event_metadata_version_default(self): event = UserLoggedIn(user_id=str(uuid4())) assert event._metadata.version == "v1" - def test_overridden_version(self): + def test_overridden_version(self, test_domain): class UserLoggedIn(BaseEvent): __version__ = "v2" user_id = Identifier(identifier=True) + test_domain.register(UserLoggedIn, part_of=User) + event = UserLoggedIn(user_id=str(uuid4())) assert event._metadata.version == "v2" diff --git a/tests/event/test_serialization.py b/tests/event/test_serialization.py index 6c80019f..c42279a5 100644 --- a/tests/event/test_serialization.py +++ b/tests/event/test_serialization.py @@ -2,7 +2,14 @@ import pytest -from tests.event.elements import PersonAdded +from tests.event.elements import Person, PersonAdded + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(Person) + test_domain.register(PersonAdded, part_of=Person) + test_domain.init(traverse=False) def test_that_message_has_unique_identifier(): diff --git a/tests/event/tests.py b/tests/event/tests.py index e8b7a9b6..0eb37942 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -57,7 +57,7 @@ class UserAdded(BaseEvent): == { "_metadata": { "id": None, # ID is none because the event is not being raised in the proper way (with `_raise`) - "type": None, # Type is none here because of the same reason as above + "type": "Test.UserAdded.v1", "kind": "EVENT", "stream_name": None, # Type is none here because of the same reason as above "origin_stream_name": None, @@ -74,14 +74,23 @@ class UserAdded(BaseEvent): } ) - def test_that_domain_event_can_be_reconstructed_from_dict_enclosing_vo(self): + def test_that_domain_event_can_be_reconstructed_from_dict_enclosing_vo( + self, test_domain + ): class Email(BaseValueObject): address = String(max_length=255) + class User(BaseAggregate): + email = ValueObject(Email, required=True) + name = String(max_length=50) + class UserAdded(BaseEvent): email = ValueObject(Email, required=True) name = String(max_length=50) + test_domain.register(User) + test_domain.register(UserAdded, part_of=User) + assert UserAdded( { "email": { @@ -97,7 +106,10 @@ def test_that_base_domain_event_class_cannot_be_instantiated(self): with pytest.raises(NotSupportedError): BaseEvent() - def test_that_domain_event_can_be_instantiated(self): + def test_that_domain_event_can_be_instantiated(self, test_domain): + test_domain.register(Person) + test_domain.register(PersonAdded, part_of=Person) + service = PersonAdded(id=uuid.uuid4(), first_name="John", last_name="Doe") assert service is not None diff --git a/tests/event_handler/test_uow_around_event_handlers.py b/tests/event_handler/test_uow_around_event_handlers.py index 5ec2c0e7..44e25428 100644 --- a/tests/event_handler/test_uow_around_event_handlers.py +++ b/tests/event_handler/test_uow_around_event_handlers.py @@ -27,7 +27,10 @@ def send_email_notification(self, event: Registered) -> None: @mock.patch("protean.utils.mixins.UnitOfWork.__enter__") @mock.patch("tests.event_handler.test_uow_around_event_handlers.dummy") @mock.patch("protean.utils.mixins.UnitOfWork.__exit__") -def test_that_method_is_enclosed_in_uow(mock_exit, mock_dummy, mock_enter): +def test_that_method_is_enclosed_in_uow(mock_exit, mock_dummy, mock_enter, test_domain): + test_domain.register(User) + test_domain.register(Registered, part_of=User) + mock_parent = mock.Mock() mock_parent.attach_mock(mock_enter, "m1") diff --git a/tests/event_sourced_aggregates/test_event_association_with_aggregate.py b/tests/event_sourced_aggregates/test_event_association_with_aggregate.py index e4a66817..13ff688d 100644 --- a/tests/event_sourced_aggregates/test_event_association_with_aggregate.py +++ b/tests/event_sourced_aggregates/test_event_association_with_aggregate.py @@ -110,5 +110,5 @@ def test_an_unassociated_event_throws_error(test_domain): user.raise_(UserArchived(user_id=user.user_id)) assert exc.value.args[0] == ( - "Event `UserArchived` is not associated with aggregate `User`" + "Event `UserArchived` should be registered with a domain" ) From e4c88366ebf165d2fd2a62070e784f51e061e2fb Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 11 Jul 2024 07:57:20 -0700 Subject: [PATCH 05/17] Enhance commands with `version` and `type` metadata --- src/protean/core/command.py | 11 +++++ tests/command/test_command_metadata.py | 58 ++++++++++++++++++++++++++ tests/event/test_event_metadata.py | 17 +++++++- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/command/test_command_metadata.py diff --git a/src/protean/core/command.py b/src/protean/core/command.py index 148f3a91..4fd7621e 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -35,6 +35,10 @@ def __init_subclass__(subclass) -> None: if not subclass.meta_.abstract: subclass.__track_id_field() + # Use explicit version if specified, else default to "v1" + if not hasattr(subclass, "__version__"): + setattr(subclass, "__version__", "v1") + def __init__(self, *args, **kwargs): try: super().__init__(*args, finalize=False, **kwargs) @@ -125,4 +129,11 @@ def command_factory(element_cls, domain, **opts): } ) + # Set the command type for the command class + setattr( + element_cls, + "__type__", + f"{domain.name}.{element_cls.__name__}.{element_cls.__version__}", + ) + return element_cls diff --git a/tests/command/test_command_metadata.py b/tests/command/test_command_metadata.py new file mode 100644 index 00000000..4d594a2c --- /dev/null +++ b/tests/command/test_command_metadata.py @@ -0,0 +1,58 @@ +from uuid import uuid4 + +import pytest + +from protean import BaseAggregate, BaseCommand +from protean.fields import Identifier, String +from protean.reflection import fields + + +class User(BaseAggregate): + id = Identifier(identifier=True) + email = String() + name = String() + + +class Login(BaseCommand): + user_id = Identifier(identifier=True) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User) + test_domain.register(Login, part_of=User) + test_domain.init(traverse=False) + + +class TestMetadataType: + def test_metadata_has_type_field(self): + metadata_field = fields(Login)["_metadata"] + assert hasattr(metadata_field.value_object_cls, "type") + + def test_command_metadata_type_default(self): + assert hasattr(Login, "__type__") + assert Login.__type__ == "Test.Login.v1" + + def test_type_value_in_metadata(self, test_domain): + command = test_domain._enrich_command(Login(user_id=str(uuid4()))) + assert command._metadata.type == "Test.Login.v1" + + +class TestMetadataVersion: + def test_metadata_has_command_version(self): + metadata_field = fields(Login)["_metadata"] + assert hasattr(metadata_field.value_object_cls, "version") + + def test_command_metadata_version_default(self): + command = Login(user_id=str(uuid4())) + assert command._metadata.version == "v1" + + def test_overridden_version(self, test_domain): + class Login(BaseCommand): + __version__ = "v2" + user_id = Identifier(identifier=True) + + test_domain.register(Login, part_of=User) + + command = Login(user_id=str(uuid4())) + assert command._metadata.version == "v2" diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index ea62e85d..4d90cd7f 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -49,7 +49,22 @@ def test_metadata_can_be_overridden(): assert event._metadata.timestamp == now_timestamp -class TestEventMetadataVersion: +class TestMetadataType: + def test_metadata_has_type_field(self): + metadata_field = fields(UserLoggedIn)["_metadata"] + assert hasattr(metadata_field.value_object_cls, "type") + + def test_command_metadata_type_default(self): + assert hasattr(UserLoggedIn, "__type__") + assert UserLoggedIn.__type__ == "Test.UserLoggedIn.v1" + + def test_type_value_in_metadata(self, test_domain): + user = User(id=str(uuid4()), email="john.doe@gmail.com", name="John Doe") + user.raise_(UserLoggedIn(user_id=user.id)) + assert user._events[0]._metadata.type == "Test.UserLoggedIn.v1" + + +class TestMetadataVersion: def test_metadata_has_event_version(self): metadata_field = fields(UserLoggedIn)["_metadata"] assert hasattr(metadata_field.value_object_cls, "version") From 5709fff15ee7b3c817326d39005c59844dee2663 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 11 Jul 2024 12:40:32 -0700 Subject: [PATCH 06/17] Manage handlers by event/command type We were basing the logic on retrieving handlers on the fully qualified name of the event or command class. This commit moves the logic to be on the `__type__` attribute of event/command classes. This is one of the steps necessary to be able to handle events/ messages from other systems, where we may not have an event registered in the domain. It is also necessary to be able to support multiple versions of event and command classes. --- src/protean/adapters/event_store/__init__.py | 8 ++-- src/protean/adapters/event_store/memory.py | 6 ++- src/protean/container.py | 2 +- src/protean/core/command.py | 14 ++++++- src/protean/core/command_handler.py | 37 ++++++++++--------- src/protean/core/entity.py | 1 + src/protean/core/event.py | 16 ++++++-- src/protean/core/event_handler.py | 11 ++++-- src/protean/core/unit_of_work.py | 4 +- src/protean/domain/__init__.py | 24 ++++++++---- src/protean/utils/mixins.py | 9 ++--- tests/adapters/broker/redis_broker/tests.py | 4 +- ...ams.py => test_aggregate_event_streams.py} | 0 tests/command/test_command_metadata.py | 25 +++++++++++++ ...st_handle_decorator_in_command_handlers.py | 15 ++++---- .../test_inline_command_processing.py | 25 ++++++++++--- .../test_retrieving_handlers_by_command.py | 2 +- tests/event/test_event_metadata.py | 2 + tests/event/test_event_payload.py | 2 + tests/event/tests.py | 1 + ...test_handle_decorator_in_event_handlers.py | 22 +++++------ ...t_raising_events_from_within_aggregates.py | 13 +++++-- ...tiple_events_for_one_aggregate_in_a_uow.py | 7 ++-- tests/event_store/test_reading_messages.py | 3 +- .../test_streams_initialization.py | 3 ++ tests/message/test_object_to_message.py | 18 ++++----- tests/server/test_command_handling.py | 3 +- .../server/test_event_handler_subscription.py | 3 ++ ...st_message_filtering_with_origin_stream.py | 2 +- .../subscription/test_no_message_filtering.py | 4 +- tests/test_commands.py | 8 ++-- .../test_inline_event_processing.py | 4 +- 32 files changed, 199 insertions(+), 99 deletions(-) rename tests/aggregate/events/{test_aggregate_streams.py => test_aggregate_event_streams.py} (100%) diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index fb2c3562..90b6a92d 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -104,7 +104,7 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: ) configured_stream_handlers = set() for stream_handler in stream_handlers: - if fqn(event.__class__) in stream_handler._handlers: + if event.__class__.__type__ in stream_handler._handlers: configured_stream_handlers.add(stream_handler) return set.union(configured_stream_handlers, all_stream_handlers) @@ -129,7 +129,7 @@ def command_handler_for(self, command: BaseCommand) -> Optional[BaseCommandHandl for handler_cls in handler_classes: try: handler_method = next( - iter(handler_cls._handlers[fqn(command.__class__)]) + iter(handler_cls._handlers[command.__class__.__type__]) ) handler_methods.add((handler_cls, handler_method)) except StopIteration: @@ -149,7 +149,7 @@ def last_event_of_type( events = [ event for event in self.domain.event_store.store._read(stream_name) - if event["type"] == fqn(event_cls) + if event["type"] == event_cls.__type__ ] return Message.from_dict(events[-1]).to_object() if len(events) > 0 else None @@ -175,5 +175,5 @@ def events_of_type( return [ Message.from_dict(event).to_object() for event in self.domain.event_store.store._read(stream_name) - if event["type"] == fqn(event_cls) + if event["type"] == event_cls.__type__ ] diff --git a/src/protean/adapters/event_store/memory.py b/src/protean/adapters/event_store/memory.py index aa558b5c..677f705f 100644 --- a/src/protean/adapters/event_store/memory.py +++ b/src/protean/adapters/event_store/memory.py @@ -76,7 +76,11 @@ def read( if stream_name == "$all": pass # Don't filter on stream name elif self.is_category(stream_name): - q = q.filter(stream_name__contains=stream_name) + # If filtering on category, ensure the supplied stream name + # is the only thing in the category. + # Eg. If stream_name is 'user', then only 'user' should be in the category, + # and not even `user:command` + q = q.filter(stream_name__contains=f"{stream_name}-") else: q = q.filter(stream_name=stream_name) diff --git a/src/protean/container.py b/src/protean/container.py index 622a559f..fd7e7357 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -14,7 +14,6 @@ ValidationError, ) from protean.fields import Auto, Field, FieldBase, ValueObject -from protean.globals import current_domain from protean.reflection import id_field from protean.utils import generate_identity @@ -416,6 +415,7 @@ def raise_(self, event, fact_event=False) -> None: _metadata={ "id": (f"{stream_name}-{self._version}"), "type": event._metadata.type, + "fqn": event._metadata.fqn, "kind": event._metadata.kind, "stream_name": stream_name, "origin_stream_name": event._metadata.origin_stream_name, diff --git a/src/protean/core/command.py b/src/protean/core/command.py index 4fd7621e..10e26b34 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -9,7 +9,7 @@ from protean.fields import Field, ValueObject from protean.globals import g from protean.reflection import _ID_FIELD_NAME, declared_fields, fields -from protean.utils import DomainObjects, derive_element_class +from protean.utils import DomainObjects, derive_element_class, fqn class BaseCommand(BaseContainer, OptionsMixin): @@ -58,6 +58,7 @@ def __init__(self, *args, **kwargs): self._metadata = Metadata( self._metadata.to_dict(), # Template kind="COMMAND", + fqn=fqn(self.__class__), origin_stream_name=origin_stream_name, version=version, ) @@ -116,6 +117,17 @@ def __track_id_field(subclass): # No Identity fields declared pass + def to_dict(self): + """Return data as a dictionary. + + We need to override this method in Command, because `to_dict()` of `BaseContainer` + eliminates `_metadata`. + """ + return { + field_name: field_obj.as_dict(getattr(self, field_name, None)) + for field_name, field_obj in fields(self).items() + } + def command_factory(element_cls, domain, **opts): element_cls = derive_element_class(element_cls, BaseCommand, **opts) diff --git a/src/protean/core/command_handler.py b/src/protean/core/command_handler.py index b08a2cab..2d024bd9 100644 --- a/src/protean/core/command_handler.py +++ b/src/protean/core/command_handler.py @@ -3,7 +3,7 @@ from protean.container import Element, OptionsMixin from protean.core.command import BaseCommand from protean.exceptions import IncorrectUsageError, NotSupportedError -from protean.utils import DomainObjects, derive_element_class, fully_qualified_name +from protean.utils import DomainObjects, derive_element_class from protean.utils.mixins import HandlerMixin @@ -45,23 +45,6 @@ def command_handler_factory(element_cls, domain, **opts): if not ( method_name.startswith("__") and method_name.endswith("__") ) and hasattr(method, "_target_cls"): - # Do not allow multiple handlers per command - if ( - fully_qualified_name(method._target_cls) in element_cls._handlers - and len( - element_cls._handlers[fully_qualified_name(method._target_cls)] - ) - != 0 - ): - raise NotSupportedError( - f"Command {method._target_cls.__name__} cannot be handled by multiple handlers" - ) - - # `_handlers` maps the command to its handler method - element_cls._handlers[fully_qualified_name(method._target_cls)].add( - method - ) - # Throw error if target_cls is not a Command if not inspect.isclass(method._target_cls) or not issubclass( method._target_cls, BaseCommand @@ -96,6 +79,24 @@ def command_handler_factory(element_cls, domain, **opts): } ) + command_type = ( + method._target_cls.__type__ + if issubclass(method._target_cls, BaseCommand) + else method._target_cls + ) + + # Do not allow multiple handlers per command + if ( + command_type in element_cls._handlers + and len(element_cls._handlers[command_type]) != 0 + ): + raise NotSupportedError( + f"Command {method._target_cls.__name__} cannot be handled by multiple handlers" + ) + + # `_handlers` maps the command to its handler method + element_cls._handlers[command_type].add(method) + # Associate Command with the handler's stream # Order of preference: # 1. Stream name defined in command diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 899d03e9..1acc7fd3 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -469,6 +469,7 @@ def raise_(self, event) -> None: _metadata={ "id": (f"{stream_name}-{aggregate_version}.{event_number}"), "type": event._metadata.type, + "fqn": event._metadata.fqn, "kind": event._metadata.kind, "stream_name": stream_name, "origin_stream_name": event._metadata.origin_stream_name, diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 84e583f7..947b57d2 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -12,20 +12,26 @@ from protean.fields import DateTime, Field, Integer, String, ValueObject from protean.globals import g from protean.reflection import _ID_FIELD_NAME, declared_fields, fields -from protean.utils import DomainObjects, derive_element_class +from protean.utils import DomainObjects, derive_element_class, fqn logger = logging.getLogger(__name__) class Metadata(BaseValueObject): - # Unique identifier of the Event - # Format is .... + # Unique identifier of the event/command + # + # FIXME Fix the format documentation + # Event Format is .... + # Command Format is .. id = String() # Type of the event # Format is .. type = String() + # Fully Qualified Name of the event/command + fqn = String() + # Kind of the object # Can be one of "EVENT", "COMMAND" kind = String() @@ -40,9 +46,10 @@ class Metadata(BaseValueObject): timestamp = DateTime(default=lambda: datetime.now(timezone.utc)) # Version of the event - # Can be overridden with `__version__` class attr in Event class definition + # Can be overridden with `__version__` class attr in event/command class definition version = String(default="v1") + # Applies to Events only # Sequence of the event in the aggregate # This is the version of the aggregate as it will be *after* persistence. # @@ -146,6 +153,7 @@ def __init__(self, *args, **kwargs): self._metadata.to_dict(), # Template from old Metadata type=self.__class__.__type__, kind="EVENT", + fqn=fqn(self.__class__), origin_stream_name=origin_stream_name, version=self.__class__.__version__, # Was set in `__init_subclass__` ) diff --git a/src/protean/core/event_handler.py b/src/protean/core/event_handler.py index 56560775..c3b3cfbd 100644 --- a/src/protean/core/event_handler.py +++ b/src/protean/core/event_handler.py @@ -2,8 +2,9 @@ import logging from protean.container import Element, OptionsMixin +from protean.core.event import BaseEvent from protean.exceptions import IncorrectUsageError, NotSupportedError -from protean.utils import DomainObjects, derive_element_class, fully_qualified_name +from protean.utils import DomainObjects, derive_element_class from protean.utils.mixins import HandlerMixin logger = logging.getLogger(__name__) @@ -59,8 +60,12 @@ def event_handler_factory(element_cls, domain, **opts): # can have only one `$any` handler method. element_cls._handlers["$any"] = {method} else: - element_cls._handlers[fully_qualified_name(method._target_cls)].add( - method + # Target could be an event or an event type string + event_type = ( + method._target_cls.__type__ + if issubclass(method._target_cls, BaseEvent) + else method._target_cls ) + element_cls._handlers[event_type].add(method) return element_cls diff --git a/src/protean/core/unit_of_work.py b/src/protean/core/unit_of_work.py index 50151fc0..0ce03109 100644 --- a/src/protean/core/unit_of_work.py +++ b/src/protean/core/unit_of_work.py @@ -8,7 +8,7 @@ ) from protean.globals import _uow_context_stack, current_domain from protean.reflection import id_field -from protean.utils import EventProcessing, fqn +from protean.utils import EventProcessing logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ def commit(self): # noqa: C901 handler_classes = current_domain.handlers_for(event) for handler_cls in handler_classes: handler_methods = ( - handler_cls._handlers[fqn(event.__class__)] + handler_cls._handlers[event.__class__.__type__] or handler_cls._handlers["$any"] ) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index d665f11c..cb8ab43f 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -910,7 +910,7 @@ def publish(self, events: Union[BaseEvent, List[BaseEvent]]) -> None: handler_classes = self.handlers_for(event) for handler_cls in handler_classes: handler_methods = ( - handler_cls._handlers[fqn(event.__class__)] + handler_cls._handlers[event.__class__.__type__] or handler_cls._handlers["$any"] ) @@ -939,11 +939,9 @@ def _enrich_command(self, command: BaseCommand) -> BaseCommand: command_with_metadata = command.__class__( command.to_dict(), _metadata={ - "id": (str(uuid4())), - "type": ( - f"{command.meta_.part_of.__class__.__name__}.{command.__class__.__name__}." - f"{command._metadata.version}" - ), + "id": identifier, # FIXME Double check command ID format and construction + "type": command.__class__.__type__, + "fqn": command._metadata.fqn, "kind": "EVENT", "stream_name": stream_name, "origin_stream_name": origin_stream_name, @@ -976,6 +974,18 @@ def process(self, command: BaseCommand, asynchronous: bool = True) -> Optional[A Returns: Optional[Any]: Returns either the command handler's return value or nothing, based on preference. """ + if ( + fqn(command.__class__) + not in self.registry._elements[DomainObjects.COMMAND.value] + ): + raise IncorrectUsageError( + { + "element": [ + f"Element {command.__class__.__name__} is not registered in domain {self.name}" + ] + } + ) + command_with_metadata = self._enrich_command(command) position = self.event_store.store.append(command_with_metadata) @@ -986,7 +996,7 @@ def process(self, command: BaseCommand, asynchronous: bool = True) -> Optional[A handler_class = self.command_handler_for(command) if handler_class: handler_method = next( - iter(handler_class._handlers[fqn(command.__class__)]) + iter(handler_class._handlers[command.__class__.__type__]) ) handler_method(handler_class(), command) diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index aaa2968f..1bf0fc04 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -13,7 +13,6 @@ from protean.core.unit_of_work import UnitOfWork from protean.exceptions import ConfigurationError, InvalidDataError from protean.globals import current_domain -from protean.utils import fully_qualified_name logger = logging.getLogger(__name__) @@ -82,9 +81,9 @@ def from_dict(cls, message: Dict) -> Message: def to_object(self) -> Union[BaseEvent, BaseCommand]: if self.metadata.kind == MessageType.EVENT.value: - element_record = current_domain.registry.events[self.type] + element_record = current_domain.registry.events[self.metadata.fqn] elif self.metadata.kind == MessageType.COMMAND.value: - element_record = current_domain.registry.commands[self.type] + element_record = current_domain.registry.commands[self.metadata.fqn] else: raise InvalidDataError( {"_message": ["Message type is not supported for deserialization"]} @@ -110,7 +109,7 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: return cls( stream_name=message_object._metadata.stream_name, - type=fully_qualified_name(message_object.__class__), + type=message_object.__class__.__type__, data=message_object.to_dict(), metadata=message_object._metadata, expected_version=expected_version, @@ -162,7 +161,7 @@ def __init_subclass__(subclass) -> None: @classmethod def _handle(cls, message: Message) -> None: # Use Event-specific handlers if available, or fallback on `$any` if defined - handlers = cls._handlers[message.type] or cls._handlers["$any"] + handlers = cls._handlers[message.metadata.type] or cls._handlers["$any"] for handler_method in handlers: handler_method(cls(), message.to_object()) diff --git a/tests/adapters/broker/redis_broker/tests.py b/tests/adapters/broker/redis_broker/tests.py index 3838a9be..9a5e6b7e 100644 --- a/tests/adapters/broker/redis_broker/tests.py +++ b/tests/adapters/broker/redis_broker/tests.py @@ -60,7 +60,9 @@ def test_event_message_structure(self, test_domain): "metadata", ] ) - assert json_message["type"] == "redis_broker.elements.PersonAdded" + assert ( + json_message["type"] == "Redis Broker Tests.PersonAdded.v1" + ) # FIXME Normalize Domain Name assert json_message["metadata"]["kind"] == "EVENT" diff --git a/tests/aggregate/events/test_aggregate_streams.py b/tests/aggregate/events/test_aggregate_event_streams.py similarity index 100% rename from tests/aggregate/events/test_aggregate_streams.py rename to tests/aggregate/events/test_aggregate_event_streams.py diff --git a/tests/command/test_command_metadata.py b/tests/command/test_command_metadata.py index 4d594a2c..60783b6c 100644 --- a/tests/command/test_command_metadata.py +++ b/tests/command/test_command_metadata.py @@ -5,6 +5,7 @@ from protean import BaseAggregate, BaseCommand from protean.fields import Identifier, String from protean.reflection import fields +from protean.utils import fqn class User(BaseAggregate): @@ -56,3 +57,27 @@ class Login(BaseCommand): command = Login(user_id=str(uuid4())) assert command._metadata.version == "v2" + + +def test_command_metadata(test_domain): + identifier = str(uuid4()) + command = test_domain._enrich_command(Login(user_id=identifier)) + + assert ( + command.to_dict() + == { + "_metadata": { + "id": f"{identifier}", # FIXME Double-check command identifier format and construction + "type": "Test.Login.v1", + "fqn": fqn(Login), + "kind": "COMMAND", + "stream_name": f"user:command-{identifier}", + "origin_stream_name": None, + "timestamp": str(command._metadata.timestamp), + "version": "v1", + "sequence_id": None, + "payload_hash": command._metadata.payload_hash, + }, + "user_id": command.user_id, + } + ) diff --git a/tests/command_handler/test_handle_decorator_in_command_handlers.py b/tests/command_handler/test_handle_decorator_in_command_handlers.py index 3c5a6e85..26a97e66 100644 --- a/tests/command_handler/test_handle_decorator_in_command_handlers.py +++ b/tests/command_handler/test_handle_decorator_in_command_handlers.py @@ -4,7 +4,6 @@ from protean.core.command_handler import BaseCommandHandler from protean.exceptions import NotSupportedError from protean.fields import Identifier, String -from protean.utils import fully_qualified_name class User(BaseAggregate): @@ -32,7 +31,7 @@ def register(self, command: Register) -> None: test_domain.register(Register, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) - assert fully_qualified_name(Register) in UserCommandHandlers._handlers + assert Register.__type__ in UserCommandHandlers._handlers def test_that_multiple_handlers_can_be_recorded_against_command_handler(test_domain): @@ -55,19 +54,19 @@ def update_billing_address(self, event: ChangeAddress) -> None: assert all( handle_name in UserCommandHandlers._handlers for handle_name in [ - fully_qualified_name(Register), - fully_qualified_name(ChangeAddress), + Register.__type__, + ChangeAddress.__type__, ] ) - assert len(UserCommandHandlers._handlers[fully_qualified_name(Register)]) == 1 - assert len(UserCommandHandlers._handlers[fully_qualified_name(ChangeAddress)]) == 1 + assert len(UserCommandHandlers._handlers[Register.__type__]) == 1 + assert len(UserCommandHandlers._handlers[ChangeAddress.__type__]) == 1 assert ( - next(iter(UserCommandHandlers._handlers[fully_qualified_name(Register)])) + next(iter(UserCommandHandlers._handlers[Register.__type__])) == UserCommandHandlers.register ) assert ( - next(iter(UserCommandHandlers._handlers[fully_qualified_name(ChangeAddress)])) + next(iter(UserCommandHandlers._handlers[ChangeAddress.__type__])) == UserCommandHandlers.update_billing_address ) diff --git a/tests/command_handler/test_inline_command_processing.py b/tests/command_handler/test_inline_command_processing.py index baf3ed32..f7da4f4e 100644 --- a/tests/command_handler/test_inline_command_processing.py +++ b/tests/command_handler/test_inline_command_processing.py @@ -1,7 +1,10 @@ from uuid import uuid4 +import pytest + from protean import BaseAggregate, BaseCommand, handle from protean.core.command_handler import BaseCommandHandler +from protean.exceptions import IncorrectUsageError from protean.fields import Identifier, String from protean.utils import CommandProcessing @@ -19,6 +22,10 @@ class Register(BaseCommand): email = String() +class Login(BaseCommand): + user_id = Identifier() + + class UserCommandHandlers(BaseCommandHandler): @handle(Register) def register(self, event: Register) -> None: @@ -26,24 +33,32 @@ def register(self, event: Register) -> None: counter += 1 -def test_that_command_can_be_processed_inline(test_domain): +@pytest.fixture(autouse=True) +def register(test_domain): test_domain.register(User) test_domain.register(Register, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) test_domain.init(traverse=False) - assert test_domain.config["command_processing"] == CommandProcessing.SYNC.value - test_domain.process(Register(user_id=str(uuid4()), email="john.doe@gmail.com")) - assert counter == 1 +def test_unregistered_command_raises_error(test_domain): + with pytest.raises(IncorrectUsageError): + test_domain.process(Login(user_id=str(uuid4()))) -def test_that_command_is_persisted_in_message_store(test_domain): +def test_that_command_can_be_processed_inline(test_domain): test_domain.register(User) test_domain.register(Register, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) test_domain.init(traverse=False) + assert test_domain.config["command_processing"] == CommandProcessing.SYNC.value + + test_domain.process(Register(user_id=str(uuid4()), email="john.doe@gmail.com")) + assert counter == 1 + + +def test_that_command_is_persisted_in_message_store(test_domain): identifier = str(uuid4()) test_domain.process(Register(user_id=identifier, email="john.doe@gmail.com")) diff --git a/tests/command_handler/test_retrieving_handlers_by_command.py b/tests/command_handler/test_retrieving_handlers_by_command.py index 3e4962f2..c08e9013 100644 --- a/tests/command_handler/test_retrieving_handlers_by_command.py +++ b/tests/command_handler/test_retrieving_handlers_by_command.py @@ -78,7 +78,7 @@ def test_for_no_errors_when_no_handler_method_has_not_been_defined_for_a_command test_domain.register(UserCommandHandlers, part_of=User) test_domain.init(traverse=False) - assert test_domain.command_handler_for(ChangeAddress) is None + assert test_domain.command_handler_for(ChangeAddress()) is None def test_retrieving_handlers_for_unknown_command(test_domain): diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index 4d90cd7f..b6f9c5ce 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -7,6 +7,7 @@ from protean.fields import String, ValueObject from protean.fields.basic import Identifier from protean.reflection import fields +from protean.utils import fqn class User(BaseEventSourcedAggregate): @@ -102,6 +103,7 @@ def test_event_metadata(): "_metadata": { "id": f"user-{user.id}-0", "type": "Test.UserLoggedIn.v1", + "fqn": fqn(UserLoggedIn), "kind": "EVENT", "stream_name": f"user-{user.id}", "origin_stream_name": None, diff --git a/tests/event/test_event_payload.py b/tests/event/test_event_payload.py index 4ff0341e..2bf83ba0 100644 --- a/tests/event/test_event_payload.py +++ b/tests/event/test_event_payload.py @@ -5,6 +5,7 @@ from protean import BaseEvent, BaseEventSourcedAggregate from protean.fields import String from protean.fields.basic import Identifier +from protean.utils import fqn class User(BaseEventSourcedAggregate): @@ -38,6 +39,7 @@ def test_event_payload(): "_metadata": { "id": f"user-{user_id}-0", "type": "Test.UserLoggedIn.v1", + "fqn": fqn(UserLoggedIn), "kind": "EVENT", "stream_name": f"user-{user_id}", "origin_stream_name": None, diff --git a/tests/event/tests.py b/tests/event/tests.py index 0eb37942..759c2271 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -58,6 +58,7 @@ class UserAdded(BaseEvent): "_metadata": { "id": None, # ID is none because the event is not being raised in the proper way (with `_raise`) "type": "Test.UserAdded.v1", + "fqn": fully_qualified_name(UserAdded), "kind": "EVENT", "stream_name": None, # Type is none here because of the same reason as above "origin_stream_name": None, diff --git a/tests/event_handler/test_handle_decorator_in_event_handlers.py b/tests/event_handler/test_handle_decorator_in_event_handlers.py index ba0e308e..068726af 100644 --- a/tests/event_handler/test_handle_decorator_in_event_handlers.py +++ b/tests/event_handler/test_handle_decorator_in_event_handlers.py @@ -2,7 +2,6 @@ from protean import BaseAggregate, BaseEvent, BaseEventHandler, handle from protean.fields import Identifier, String -from protean.utils import fully_qualified_name class User(BaseAggregate): @@ -27,9 +26,10 @@ def send_email_notification(self, event: Registered) -> None: pass test_domain.register(User) + test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandlers, part_of=User) - assert fully_qualified_name(Registered) in UserEventHandlers._handlers + assert Registered.__type__ in UserEventHandlers._handlers def test_that_multiple_handlers_can_be_recorded_against_event_handler(test_domain): @@ -43,25 +43,27 @@ def updated_billing_address(self, event: AddressChanged) -> None: pass test_domain.register(User) + test_domain.register(Registered, part_of=User) + test_domain.register(AddressChanged, part_of=User) test_domain.register(UserEventHandlers, part_of=User) assert len(UserEventHandlers._handlers) == 2 assert all( handle_name in UserEventHandlers._handlers for handle_name in [ - fully_qualified_name(Registered), - fully_qualified_name(AddressChanged), + Registered.__type__, + AddressChanged.__type__, ] ) - assert len(UserEventHandlers._handlers[fully_qualified_name(Registered)]) == 1 - assert len(UserEventHandlers._handlers[fully_qualified_name(AddressChanged)]) == 1 + assert len(UserEventHandlers._handlers[Registered.__type__]) == 1 + assert len(UserEventHandlers._handlers[AddressChanged.__type__]) == 1 assert ( - next(iter(UserEventHandlers._handlers[fully_qualified_name(Registered)])) + next(iter(UserEventHandlers._handlers[Registered.__type__])) == UserEventHandlers.send_email_notification ) assert ( - next(iter(UserEventHandlers._handlers[fully_qualified_name(AddressChanged)])) + next(iter(UserEventHandlers._handlers[AddressChanged.__type__])) == UserEventHandlers.updated_billing_address ) @@ -81,9 +83,7 @@ def provision_user_accounts(self, event: Registered) -> None: assert len(UserEventHandlers._handlers) == 1 # Against Registered Event - handlers_for_registered = UserEventHandlers._handlers[ - fully_qualified_name(Registered) - ] + handlers_for_registered = UserEventHandlers._handlers[Registered.__type__] assert len(handlers_for_registered) == 2 assert all( handler_method in handlers_for_registered diff --git a/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py b/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py index 92460ee3..72864a13 100644 --- a/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py +++ b/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py @@ -9,11 +9,10 @@ from protean.core.event_sourced_aggregate import apply from protean.fields import Identifier, String from protean.globals import current_domain -from protean.utils import fqn class Register(BaseCommand): - id = Identifier() + id = Identifier(identifier=True) email = String() name = String() password_hash = String() @@ -75,7 +74,7 @@ def register_elements(test_domain): @pytest.mark.eventstore def test_that_events_can_be_raised_from_within_aggregates(test_domain): identifier = str(uuid4()) - UserCommandHandler().register_user( + test_domain.process( Register( id=identifier, email="john.doe@example.com", @@ -88,4 +87,10 @@ def test_that_events_can_be_raised_from_within_aggregates(test_domain): assert len(messages) == 1 assert messages[0]["stream_name"] == f"user-{identifier}" - assert messages[0]["type"] == f"{fqn(Registered)}" + assert messages[0]["type"] == Registered.__type__ + + messages = test_domain.event_store.store._read("user:command") + + assert len(messages) == 1 + assert messages[0]["stream_name"] == f"user:command-{identifier}" + assert messages[0]["type"] == Register.__type__ diff --git a/tests/event_sourced_aggregates/test_raising_multiple_events_for_one_aggregate_in_a_uow.py b/tests/event_sourced_aggregates/test_raising_multiple_events_for_one_aggregate_in_a_uow.py index 8a75fe1c..c3548e80 100644 --- a/tests/event_sourced_aggregates/test_raising_multiple_events_for_one_aggregate_in_a_uow.py +++ b/tests/event_sourced_aggregates/test_raising_multiple_events_for_one_aggregate_in_a_uow.py @@ -14,7 +14,6 @@ ) from protean.fields import Identifier, String from protean.globals import current_domain -from protean.utils import fqn class Register(BaseCommand): @@ -100,6 +99,6 @@ def test_that_multiple_events_are_raised_per_aggregate_in_the_same_uow(test_doma messages = test_domain.event_store.store._read("user") assert len(messages) == 3 - assert messages[0]["type"] == f"{fqn(Registered)}" - assert messages[1]["type"] == f"{fqn(Renamed)}" - assert messages[2]["type"] == f"{fqn(Renamed)}" + assert messages[0]["type"] == Registered.__type__ + assert messages[1]["type"] == Renamed.__type__ + assert messages[2]["type"] == Renamed.__type__ diff --git a/tests/event_store/test_reading_messages.py b/tests/event_store/test_reading_messages.py index 38d58281..999a43b9 100644 --- a/tests/event_store/test_reading_messages.py +++ b/tests/event_store/test_reading_messages.py @@ -7,7 +7,6 @@ from protean import BaseEvent, BaseEventSourcedAggregate from protean.fields import String from protean.fields.basic import Identifier -from protean.utils import fqn from protean.utils.mixins import Message @@ -136,5 +135,5 @@ def test_reading_messages_by_category(test_domain, activated_user): def test_reading_last_message(test_domain, renamed_user): # Reading by stream message = test_domain.event_store.store.read_last_message(f"user-{renamed_user.id}") - assert message.type == fqn(Renamed) + assert message.type == Renamed.__type__ assert message.data["name"] == "John Doe 9" diff --git a/tests/event_store/test_streams_initialization.py b/tests/event_store/test_streams_initialization.py index 03b5ff10..2b648a96 100644 --- a/tests/event_store/test_streams_initialization.py +++ b/tests/event_store/test_streams_initialization.py @@ -57,7 +57,10 @@ def record_sent_email(self, event: Sent) -> None: @pytest.fixture(autouse=True) def register(test_domain): test_domain.register(User) + test_domain.register(Registered, part_of=User) + test_domain.register(Activated, part_of=User) test_domain.register(Email) + test_domain.register(Sent, part_of=Email) test_domain.register(UserEventHandler, part_of=User) test_domain.register(EmailEventHandler, part_of=Email) test_domain.init(traverse=False) diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index c208a464..b8b6c7f7 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -5,7 +5,6 @@ from protean import BaseCommand, BaseEvent, BaseEventSourcedAggregate from protean.exceptions import ConfigurationError from protean.fields import Identifier, String -from protean.utils import fully_qualified_name from protean.utils.mixins import Message @@ -64,7 +63,7 @@ def test_construct_message_from_event(test_domain): assert type(message) is Message # Verify Message Content - assert message.type == fully_qualified_name(Registered) + assert message.type == Registered.__type__ assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == user._events[-1].to_dict() @@ -74,7 +73,7 @@ def test_construct_message_from_event(test_domain): # Verify Message Dict message_dict = message.to_dict() - assert message_dict["type"] == fully_qualified_name(Registered) + assert message_dict["type"] == Registered.__type__ assert message_dict["metadata"]["kind"] == "EVENT" assert message_dict["stream_name"] == f"{User.meta_.stream_name}-{identifier}" assert message_dict["data"] == user._events[-1].to_dict() @@ -87,6 +86,7 @@ def test_construct_message_from_event(test_domain): def test_construct_message_from_command(test_domain): identifier = str(uuid4()) command = Register(id=identifier, email="john.doe@gmail.com", name="John Doe") + command_with_metadata = test_domain._enrich_command(command) test_domain.process(command) messages = test_domain.event_store.store.read("user:command") @@ -96,20 +96,20 @@ def test_construct_message_from_command(test_domain): assert type(message) is Message # Verify Message Content - assert message.type == fully_qualified_name(Register) + assert message.type == Register.__type__ assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" assert message.metadata.kind == "COMMAND" - assert message.data == command.to_dict() + assert message.data == command_with_metadata.to_dict() assert message.time is not None # Verify Message Dict message_dict = message.to_dict() - assert message_dict["type"] == fully_qualified_name(Register) + assert message_dict["type"] == Register.__type__ assert message_dict["metadata"]["kind"] == "COMMAND" assert ( message_dict["stream_name"] == f"{User.meta_.stream_name}:command-{identifier}" ) - assert message_dict["data"] == command.to_dict() + assert message_dict["data"] == command_with_metadata.to_dict() assert message_dict["time"] is not None @@ -147,7 +147,7 @@ def test_construct_message_from_either_event_or_command(test_domain): assert type(message) is Message # Verify Message Content - assert message.type == fully_qualified_name(Register) + assert message.type == Register.__type__ assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command.to_dict() @@ -163,7 +163,7 @@ def test_construct_message_from_either_event_or_command(test_domain): assert type(message) is Message # Verify Message Content - assert message.type == fully_qualified_name(Registered) + assert message.type == Registered.__type__ assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == event.to_dict() diff --git a/tests/server/test_command_handling.py b/tests/server/test_command_handling.py index bc532241..df1fba98 100644 --- a/tests/server/test_command_handling.py +++ b/tests/server/test_command_handling.py @@ -52,7 +52,8 @@ async def test_handler_invocation(test_domain): user_id=identifier, email="john.doe@example.com", ) - message = Message.to_message(command) + enriched_command = test_domain._enrich_command(command) + message = Message.to_message(enriched_command) engine = Engine(domain=test_domain, test_mode=True) await engine.handle_message(UserCommandHandler, message) diff --git a/tests/server/test_event_handler_subscription.py b/tests/server/test_event_handler_subscription.py index 3180b8f9..a503858c 100644 --- a/tests/server/test_event_handler_subscription.py +++ b/tests/server/test_event_handler_subscription.py @@ -73,6 +73,8 @@ def setup_event_loop(): def test_event_subscriptions(test_domain): test_domain.register(User) + test_domain.register(Registered, part_of=User) + test_domain.register(Activated, part_of=User) test_domain.register(UserEventHandler, part_of=User) engine = Engine(test_domain, test_mode=True) @@ -83,6 +85,7 @@ def test_event_subscriptions(test_domain): def test_origin_stream_name_in_subscription(test_domain): test_domain.register(User) + test_domain.register(Sent, part_of=User) test_domain.register(EmailEventHandler, part_of=User, source_stream="email") engine = Engine(test_domain, test_mode=True) diff --git a/tests/subscription/test_message_filtering_with_origin_stream.py b/tests/subscription/test_message_filtering_with_origin_stream.py index be3595c7..6e67f55d 100644 --- a/tests/subscription/test_message_filtering_with_origin_stream.py +++ b/tests/subscription/test_message_filtering_with_origin_stream.py @@ -113,4 +113,4 @@ async def test_message_filtering_for_event_handlers_with_defined_origin_stream( ) assert len(filtered_messages) == 1 - assert filtered_messages[0].type == fqn(Sent) + assert filtered_messages[0].type == Sent.__type__ diff --git a/tests/subscription/test_no_message_filtering.py b/tests/subscription/test_no_message_filtering.py index fffa869d..7065cef5 100644 --- a/tests/subscription/test_no_message_filtering.py +++ b/tests/subscription/test_no_message_filtering.py @@ -112,5 +112,5 @@ async def test_no_filtering_for_event_handlers_without_defined_origin_stream( ) assert len(filtered_messages) == 3 - assert filtered_messages[0].type == fqn(Registered) - assert filtered_messages[2].type == fqn(Sent) + assert filtered_messages[0].type == Registered.__type__ + assert filtered_messages[2].type == Sent.__type__ diff --git a/tests/test_commands.py b/tests/test_commands.py index b37aa540..9b7ac0eb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -15,7 +15,7 @@ class User(BaseAggregate): class UserRegistrationCommand(BaseCommand): - email = String(required=True, max_length=250) + email = String(required=True, identifier=True, max_length=250) username = String(required=True, max_length=50) password = String(required=True, max_length=255) age = Integer(default=21) @@ -83,7 +83,9 @@ def test_two_commands_with_equal_values_are_considered_equal(self): email="john.doe@gmail.com", username="john.doe", password="secret1!" ) - assert command1 == command2 + # The commands themselves will not be equal because their metadata, like + # timestamp, can differ. But their payloads should be equal + assert command1.payload == command2.payload def test_that_commands_are_immutable(self): command = UserRegistrationCommand( @@ -96,7 +98,7 @@ def test_output_to_dict(self): command = UserRegistrationCommand( email="john.doe@gmail.com", username="john.doe", password="secret1!" ) - assert command.to_dict() == { + assert command.payload == { "email": "john.doe@gmail.com", "username": "john.doe", "password": "secret1!", diff --git a/tests/unit_of_work/test_inline_event_processing.py b/tests/unit_of_work/test_inline_event_processing.py index f54eeca0..b8fb0a8c 100644 --- a/tests/unit_of_work/test_inline_event_processing.py +++ b/tests/unit_of_work/test_inline_event_processing.py @@ -93,13 +93,15 @@ def count_registrations(self, _: BaseEventHandler) -> None: @pytest.mark.eventstore def test_inline_event_processing_in_sync_mode(test_domain): test_domain.register(User) + test_domain.register(Register, part_of=User) test_domain.register(Registered, part_of=User) + test_domain.register(UserCommandHandler, part_of=User) test_domain.register(UserEventHandler, part_of=User) test_domain.register(UserMetrics, part_of=User) test_domain.init(traverse=False) identifier = str(uuid4()) - UserCommandHandler().register_user( + test_domain.process( Register( user_id=identifier, email="john.doe@example.com", From 68016100b256a1ecf2f1ae3f374336ab2a3a5715 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 11 Jul 2024 13:13:47 -0700 Subject: [PATCH 07/17] Refactor sync processing to use `HandlerMixin._handle` --- src/protean/adapters/event_store/__init__.py | 1 - src/protean/core/unit_of_work.py | 8 +------- src/protean/domain/__init__.py | 13 ++----------- src/protean/utils/mixins.py | 13 +++++++++---- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index 90b6a92d..5190c603 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -13,7 +13,6 @@ event_sourced_repository_factory, ) from protean.exceptions import ConfigurationError, NotSupportedError -from protean.utils import fqn from protean.utils.mixins import Message if TYPE_CHECKING: diff --git a/src/protean/core/unit_of_work.py b/src/protean/core/unit_of_work.py index 0ce03109..8d6410fa 100644 --- a/src/protean/core/unit_of_work.py +++ b/src/protean/core/unit_of_work.py @@ -89,13 +89,7 @@ def commit(self): # noqa: C901 for _, event in events: handler_classes = current_domain.handlers_for(event) for handler_cls in handler_classes: - handler_methods = ( - handler_cls._handlers[event.__class__.__type__] - or handler_cls._handlers["$any"] - ) - - for handler_method in handler_methods: - handler_method(handler_cls(), event) + handler_cls._handle(event) logger.debug("Commit Successful") except ValueError as exc: diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index cb8ab43f..f6fc2b70 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -909,13 +909,7 @@ def publish(self, events: Union[BaseEvent, List[BaseEvent]]) -> None: # Consume events right-away handler_classes = self.handlers_for(event) for handler_cls in handler_classes: - handler_methods = ( - handler_cls._handlers[event.__class__.__type__] - or handler_cls._handlers["$any"] - ) - - for handler_method in handler_methods: - handler_method(handler_cls(), event) + handler_cls._handle(event) ##################### # Handling Commands # @@ -995,10 +989,7 @@ def process(self, command: BaseCommand, asynchronous: bool = True) -> Optional[A ): handler_class = self.command_handler_for(command) if handler_class: - handler_method = next( - iter(handler_class._handlers[command.__class__.__type__]) - ) - handler_method(handler_class(), command) + handler_class._handle(command_with_metadata) return position diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 1bf0fc04..ff262c91 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -159,12 +159,17 @@ def __init_subclass__(subclass) -> None: setattr(subclass, "_handlers", defaultdict(set)) @classmethod - def _handle(cls, message: Message) -> None: - # Use Event-specific handlers if available, or fallback on `$any` if defined - handlers = cls._handlers[message.metadata.type] or cls._handlers["$any"] + def _handle(cls, item: Union[Message, BaseCommand, BaseEvent]) -> None: + """Handle a message or command/event.""" + + # Convert Message to object if necessary + item = item.to_object() if isinstance(item, Message) else item + + # Use specific handlers if available, or fallback on `$any` if defined + handlers = cls._handlers[item.__class__.__type__] or cls._handlers["$any"] for handler_method in handlers: - handler_method(cls(), message.to_object()) + handler_method(cls(), item) @classmethod def handle_error(cls, exc: Exception, message: Message) -> None: From 6a6c0c10f6d6b3124949ec2a91d08b70ac163a7b Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 11 Jul 2024 14:14:43 -0700 Subject: [PATCH 08/17] Bugfix - do not place metadata in message's data attribute's value --- src/protean/utils/mixins.py | 6 ++++-- tests/event_store/test_appending_events.py | 3 ++- tests/event_store/test_reading_messages.py | 15 ++++++++++----- tests/message/test_object_to_message.py | 12 ++++++------ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index ff262c91..ae0dbfd9 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -80,16 +80,18 @@ def from_dict(cls, message: Dict) -> Message: ) def to_object(self) -> Union[BaseEvent, BaseCommand]: + """Reconstruct the event/command object from the message data.""" if self.metadata.kind == MessageType.EVENT.value: element_record = current_domain.registry.events[self.metadata.fqn] elif self.metadata.kind == MessageType.COMMAND.value: element_record = current_domain.registry.commands[self.metadata.fqn] else: + # We are dealing with a malformed or unknown message raise InvalidDataError( {"_message": ["Message type is not supported for deserialization"]} ) - return element_record.cls(**self.data) + return element_record.cls(_metadata=self.metadata, **self.data) @classmethod def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: @@ -110,7 +112,7 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: return cls( stream_name=message_object._metadata.stream_name, type=message_object.__class__.__type__, - data=message_object.to_dict(), + data=message_object.payload, metadata=message_object._metadata, expected_version=expected_version, ) diff --git a/tests/event_store/test_appending_events.py b/tests/event_store/test_appending_events.py index 605f2db7..e447e9dd 100644 --- a/tests/event_store/test_appending_events.py +++ b/tests/event_store/test_appending_events.py @@ -38,4 +38,5 @@ def test_appending_raw_events(test_domain): assert message.stream_name == f"authentication-{identifier}" assert message.metadata.kind == "EVENT" - assert message.data == event.to_dict() + assert message.data == event.payload + assert message.metadata == event._metadata diff --git a/tests/event_store/test_reading_messages.py b/tests/event_store/test_reading_messages.py index 999a43b9..2d190742 100644 --- a/tests/event_store/test_reading_messages.py +++ b/tests/event_store/test_reading_messages.py @@ -75,7 +75,8 @@ def test_reading_a_message(test_domain, registered_user): assert isinstance(message, Message) assert message.stream_name == f"user-{registered_user.id}" assert message.metadata.kind == "EVENT" - assert message.data == registered_user._events[-1].to_dict() + assert message.data == registered_user._events[-1].payload + assert message.metadata == registered_user._events[-1]._metadata @pytest.mark.eventstore @@ -86,8 +87,10 @@ def test_reading_many_messages(test_domain, activated_user): assert messages[0].stream_name == f"user-{activated_user.id}" assert messages[0].metadata.kind == "EVENT" - assert messages[0].data == activated_user._events[0].to_dict() - assert messages[1].data == activated_user._events[1].to_dict() + assert messages[0].data == activated_user._events[0].payload + assert messages[0].metadata == activated_user._events[0]._metadata + assert messages[1].data == activated_user._events[1].payload + assert messages[1].metadata == activated_user._events[1]._metadata @pytest.mark.eventstore @@ -127,8 +130,10 @@ def test_reading_messages_by_category(test_domain, activated_user): assert messages[0].stream_name == f"user-{activated_user.id}" assert messages[0].metadata.kind == "EVENT" - assert messages[0].data == activated_user._events[0].to_dict() - assert messages[1].data == activated_user._events[1].to_dict() + assert messages[0].data == activated_user._events[0].payload + assert messages[0].metadata == activated_user._events[0]._metadata + assert messages[1].data == activated_user._events[1].payload + assert messages[1].metadata == activated_user._events[1]._metadata @pytest.mark.eventstore diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index b8b6c7f7..a2bee680 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -66,7 +66,7 @@ def test_construct_message_from_event(test_domain): assert message.type == Registered.__type__ assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" assert message.metadata.kind == "EVENT" - assert message.data == user._events[-1].to_dict() + assert message.data == user._events[-1].payload assert message.time is None assert message.expected_version == user._version - 1 @@ -76,7 +76,7 @@ def test_construct_message_from_event(test_domain): assert message_dict["type"] == Registered.__type__ assert message_dict["metadata"]["kind"] == "EVENT" assert message_dict["stream_name"] == f"{User.meta_.stream_name}-{identifier}" - assert message_dict["data"] == user._events[-1].to_dict() + assert message_dict["data"] == user._events[-1].payload assert message_dict["time"] is None assert ( message_dict["expected_version"] == user._version - 1 @@ -99,7 +99,7 @@ def test_construct_message_from_command(test_domain): assert message.type == Register.__type__ assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" assert message.metadata.kind == "COMMAND" - assert message.data == command_with_metadata.to_dict() + assert message.data == command_with_metadata.payload assert message.time is not None # Verify Message Dict @@ -109,7 +109,7 @@ def test_construct_message_from_command(test_domain): assert ( message_dict["stream_name"] == f"{User.meta_.stream_name}:command-{identifier}" ) - assert message_dict["data"] == command_with_metadata.to_dict() + assert message_dict["data"] == command_with_metadata.payload assert message_dict["time"] is not None @@ -150,7 +150,7 @@ def test_construct_message_from_either_event_or_command(test_domain): assert message.type == Register.__type__ assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" assert message.metadata.kind == "COMMAND" - assert message.data == command.to_dict() + assert message.data == command.payload user = User(id=identifier, email="john.doe@example.com", name="John Doe") user.raise_(Registered(id=identifier, email="john.doe@gmail.com", name="John Doe")) @@ -166,7 +166,7 @@ def test_construct_message_from_either_event_or_command(test_domain): assert message.type == Registered.__type__ assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" assert message.metadata.kind == "EVENT" - assert message.data == event.to_dict() + assert message.data == event.payload assert message.time is None From b782f13baca74a1034cf5cbfabe2c86c10e95d0e Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Fri, 12 Jul 2024 09:47:03 -0700 Subject: [PATCH 09/17] Introduce `normalized_name` property in Domain --- src/protean/domain/__init__.py | 17 ++++++++++ ...name.py => test_domain_name_derivation.py} | 2 +- tests/domain/tests.py | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) rename tests/domain/{test_domain_name.py => test_domain_name_derivation.py} (96%) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index f6fc2b70..50a62a5d 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -11,6 +11,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union from uuid import uuid4 +from inflection import parameterize, transliterate, underscore + from protean.adapters import Brokers, Caches, EmailProviders, Providers from protean.adapters.event_store import EventStore from protean.container import Element @@ -187,6 +189,21 @@ def __init__( # FIXME Should all protean elements be subclassed from a base element? self._pending_class_resolutions: dict[str, Any] = defaultdict(list) + @property + @lru_cache() + def normalized_name(self) -> str: + """Return the normalized name of the domain. + + The normalized name is the underscored version of the domain name. + Examples: + - `MyDomain` -> `my_domain` + - `My Domain` -> `my_domain` + - `My-Domain` -> `my_domain` + - `My Domain 1` -> `my_domain_1` + - `My Domain 1.0` -> `my_domain_1_0` + """ + return underscore(parameterize(transliterate(self.name))) + def init(self, traverse=True): # noqa: C901 """Parse the domain folder, and attach elements dynamically to the domain. diff --git a/tests/domain/test_domain_name.py b/tests/domain/test_domain_name_derivation.py similarity index 96% rename from tests/domain/test_domain_name.py rename to tests/domain/test_domain_name_derivation.py index af2b145d..07fb8bde 100644 --- a/tests/domain/test_domain_name.py +++ b/tests/domain/test_domain_name_derivation.py @@ -8,7 +8,7 @@ from tests.shared import change_working_directory_to -class TestDomainName: +class TestDomainNameDerivation: @pytest.fixture(autouse=True) def reset_path(self): """Reset sys.path after every test run""" diff --git a/tests/domain/tests.py b/tests/domain/tests.py index d3121f42..8285b337 100644 --- a/tests/domain/tests.py +++ b/tests/domain/tests.py @@ -13,12 +13,43 @@ from .elements import UserAggregate, UserEntity, UserFoo, UserVO +def test_domain_name(): + domain = Domain(__file__, "Foo", load_toml=False) + + assert domain.name == "Foo" + + def test_domain_name_string(): domain = Domain(__file__, "Foo", load_toml=False) assert str(domain) == "Domain: Foo" +def test_normalized_domain_name(): + # A few test cases to check if domain names are normalized correctly + # Each item is a tuple of (name, normalized_name) + data = [ + ("Foo", "foo"), + ("Foo20", "foo20"), + ("Foo 20", "foo_20"), + ("FooBar", "foobar"), + ("Foo Bar", "foo_bar"), + ("Foo Bar Baz", "foo_bar_baz"), + ("Foo-Bar", "foo_bar"), + ("Foo_Bar", "foo_bar"), + ("Foo Bar 20", "foo_bar_20"), + ("Donald E. Knuth", "donald_e_knuth"), + ("MyDomain", "mydomain"), + ("My Domain", "my_domain"), + ("My-Domain", "my_domain"), + ("My Domain 1", "my_domain_1"), + ("My Domain 1.0", "my_domain_1_0"), + ] + for name, result in data: + domain = Domain(__file__, name, load_toml=False) + assert domain.normalized_name == result, f"Failed for {name}" + + class TestElementRegistration: def test_that_only_recognized_element_types_can_be_registered(self, test_domain): with pytest.raises(NotSupportedError) as exc: From 260538785e1518bda9540278b6072893550d804b Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Fri, 12 Jul 2024 12:41:48 -0700 Subject: [PATCH 10/17] Introduce `camel_case_name` property in Domain --- src/protean/domain/__init__.py | 19 ++++++++++++++++++- tests/domain/tests.py | 27 ++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 50a62a5d..7f8644e2 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union from uuid import uuid4 -from inflection import parameterize, transliterate, underscore +from inflection import parameterize, titleize, transliterate, underscore from protean.adapters import Brokers, Caches, EmailProviders, Providers from protean.adapters.event_store import EventStore @@ -189,6 +189,23 @@ def __init__( # FIXME Should all protean elements be subclassed from a base element? self._pending_class_resolutions: dict[str, Any] = defaultdict(list) + @property + @lru_cache() + def camel_case_name(self) -> str: + """Return the CamelCase name of the domain. + + The CamelCase name is the name of the domain with the first letter capitalized. + Examples: + - `my_domain` -> `MyDomain` + - `my_domain_1` -> `MyDomain1` + - `my_domain_1_0` -> `MyDomain10` + """ + # Transliterating the name to remove any special characters and camelize + formatted_string = titleize(transliterate(self.name).replace("-", " ")) + + # Eliminate non-alphanumeric characters + return "".join(filter(str.isalnum, formatted_string)) + @property @lru_cache() def normalized_name(self) -> str: diff --git a/tests/domain/tests.py b/tests/domain/tests.py index 8285b337..149ae164 100644 --- a/tests/domain/tests.py +++ b/tests/domain/tests.py @@ -26,7 +26,7 @@ def test_domain_name_string(): def test_normalized_domain_name(): - # A few test cases to check if domain names are normalized correctly + # Test cases to check if domain names are normalized correctly # Each item is a tuple of (name, normalized_name) data = [ ("Foo", "foo"), @@ -50,6 +50,31 @@ def test_normalized_domain_name(): assert domain.normalized_name == result, f"Failed for {name}" +def test_camel_case_domain_name(): + # Test cases to check if domain names are converted to camel case correctly + # Each item is a tuple of (name, camel_case_name) + data = [ + ("My Domain", "MyDomain"), + ("my_domain", "MyDomain"), + ("my-domain", "MyDomain"), + ("foo", "Foo"), + ("foo_bar", "FooBar"), + ("foo_bar_baz", "FooBarBaz"), + ("foo-bar-baz", "FooBarBaz"), + ("foo-bar", "FooBar"), + ("foo_bar_baz", "FooBarBaz"), + ("foo-bar-baz", "FooBarBaz"), + ("donald_e_knuth", "DonaldEKnuth"), + ("donald-e-knuth", "DonaldEKnuth"), + ("Donald E. Knuth", "DonaldEKnuth"), + ("My Domain 1", "MyDomain1"), + ("My Domain 1.0", "MyDomain10"), + ] + for name, result in data: + domain = Domain(__file__, name, load_toml=False) + assert domain.camel_case_name == result, f"Failed for {name}" + + class TestElementRegistration: def test_that_only_recognized_element_types_can_be_registered(self, test_domain): with pytest.raises(NotSupportedError) as exc: From 711f0df73c4429271a86d79c4fc4538905def18a Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Fri, 12 Jul 2024 21:53:24 -0700 Subject: [PATCH 11/17] Move command and event handler setup to `Domain.init()` Factory methods in command handler and event handler were parsing the methods marked with `handle()` and setting up command and event structure for later processing. This has now been moved to `Domain.init()` method because events and commands are not yet ready when command and event handlers are registered. --- src/protean/core/command.py | 7 -- src/protean/core/command_handler.py | 71 ----------- src/protean/core/event.py | 9 +- src/protean/core/event_handler.py | 25 ---- src/protean/domain/__init__.py | 116 ++++++++++++++++++ src/protean/utils/__init__.py | 2 +- .../test_event_association_with_aggregate.py | 2 + .../test_automatic_stream_association.py | 111 ----------------- tests/command_handler/test_basics.py | 12 +- ...st_handle_decorator_in_command_handlers.py | 9 +- tests/domain/test_init.py | 30 +++++ tests/event/test_event_metadata.py | 1 + tests/event/test_raising_events.py | 1 + tests/event/tests.py | 9 ++ tests/event_handler/test_any_event_handler.py | 2 + ...test_handle_decorator_in_event_handlers.py | 3 + .../test_retrieving_handlers_by_event.py | 6 +- .../test_uow_around_event_handlers.py | 1 + tests/event_sourced_aggregates/test_apply.py | 6 +- tests/message/test_message_to_object.py | 1 + tests/server/test_any_event_handler.py | 1 + tests/server/test_command_handling.py | 1 + tests/server/test_error_handling.py | 1 + tests/server/test_event_handling.py | 1 + tests/server/test_handling_all_events.py | 1 + ...st_message_filtering_with_origin_stream.py | 1 + .../test_message_handover_to_engine.py | 1 + .../subscription/test_no_message_filtering.py | 1 + .../test_read_position_updates.py | 1 + 29 files changed, 197 insertions(+), 236 deletions(-) delete mode 100644 tests/command/test_automatic_stream_association.py diff --git a/src/protean/core/command.py b/src/protean/core/command.py index 10e26b34..ee0a77d9 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -141,11 +141,4 @@ def command_factory(element_cls, domain, **opts): } ) - # Set the command type for the command class - setattr( - element_cls, - "__type__", - f"{domain.name}.{element_cls.__name__}.{element_cls.__version__}", - ) - return element_cls diff --git a/src/protean/core/command_handler.py b/src/protean/core/command_handler.py index 2d024bd9..460f24d3 100644 --- a/src/protean/core/command_handler.py +++ b/src/protean/core/command_handler.py @@ -1,7 +1,4 @@ -import inspect - from protean.container import Element, OptionsMixin -from protean.core.command import BaseCommand from protean.exceptions import IncorrectUsageError, NotSupportedError from protean.utils import DomainObjects, derive_element_class from protean.utils.mixins import HandlerMixin @@ -38,72 +35,4 @@ def command_handler_factory(element_cls, domain, **opts): } ) - # Iterate through methods marked as `@handle` and construct a handler map - if not element_cls._handlers: # Protect against re-registration - 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, "_target_cls"): - # Throw error if target_cls is not a Command - if not inspect.isclass(method._target_cls) or not issubclass( - method._target_cls, BaseCommand - ): - raise IncorrectUsageError( - { - "_command_handler": [ - f"Method `{method_name}` in Command Handler `{element_cls.__name__}` " - "is not associated with a command" - ] - } - ) - - # Throw error if target_cls is not associated with an aggregate - if not method._target_cls.meta_.part_of: - raise IncorrectUsageError( - { - "_command_handler": [ - f"Command `{method._target_cls.__name__}` in Command Handler `{element_cls.__name__}` " - "is not associated with an aggregate" - ] - } - ) - - if method._target_cls.meta_.part_of != element_cls.meta_.part_of: - raise IncorrectUsageError( - { - "_command_handler": [ - f"Command `{method._target_cls.__name__}` in Command Handler `{element_cls.__name__}` " - "is not associated with the same aggregate as the Command Handler" - ] - } - ) - - command_type = ( - method._target_cls.__type__ - if issubclass(method._target_cls, BaseCommand) - else method._target_cls - ) - - # Do not allow multiple handlers per command - if ( - command_type in element_cls._handlers - and len(element_cls._handlers[command_type]) != 0 - ): - raise NotSupportedError( - f"Command {method._target_cls.__name__} cannot be handled by multiple handlers" - ) - - # `_handlers` maps the command to its handler method - element_cls._handlers[command_type].add(method) - - # Associate Command with the handler's stream - # Order of preference: - # 1. Stream name defined in command - # 2. Stream name derived from aggregate associated with command handler - method._target_cls.meta_.stream_name = ( - method._target_cls.meta_.part_of.meta_.stream_name - or element_cls.meta_.part_of.meta_.stream_name - ) - return element_cls diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 947b57d2..7dd775b3 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -30,7 +30,7 @@ class Metadata(BaseValueObject): type = String() # Fully Qualified Name of the event/command - fqn = String() + fqn = String(sanitize=False) # Kind of the object # Can be one of "EVENT", "COMMAND" @@ -205,11 +205,4 @@ def domain_event_factory(element_cls, domain, **opts): } ) - # Set the event type for the event class - setattr( - element_cls, - "__type__", - f"{domain.name}.{element_cls.__name__}.{element_cls.__version__}", - ) - return element_cls diff --git a/src/protean/core/event_handler.py b/src/protean/core/event_handler.py index c3b3cfbd..645d6632 100644 --- a/src/protean/core/event_handler.py +++ b/src/protean/core/event_handler.py @@ -1,8 +1,6 @@ -import inspect import logging from protean.container import Element, OptionsMixin -from protean.core.event import BaseEvent from protean.exceptions import IncorrectUsageError, NotSupportedError from protean.utils import DomainObjects, derive_element_class from protean.utils.mixins import HandlerMixin @@ -45,27 +43,4 @@ def event_handler_factory(element_cls, domain, **opts): } ) - # Iterate through methods marked as `@handle` and construct a handler map - # - # Also, if `_target_cls` is an event, associate it with the event handler's - # aggregate or stream - 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, "_target_cls"): - # `_handlers` is a dictionary mapping the event to the handler method. - if method._target_cls == "$any": - # This replaces any existing `$any` handler, by design. An Event Handler - # can have only one `$any` handler method. - element_cls._handlers["$any"] = {method} - else: - # Target could be an event or an event type string - event_type = ( - method._target_cls.__type__ - if issubclass(method._target_cls, BaseEvent) - else method._target_cls - ) - element_cls._handlers[event_type].add(method) - return element_cls diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 7f8644e2..92a214a8 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -258,6 +258,15 @@ def init(self, traverse=True): # noqa: C901 # Generate Fact Event Classes self._generate_fact_event_classes() + # Generate and set event/command `__type__` value + self._set_event_and_command_type() + + # Parse and setup handler methods in Command Handlers + self._setup_command_handlers() + + # Parse and setup handler methods in Event Handlers + self._setup_event_handlers() + # Run Validations self._validate_domain() @@ -818,6 +827,113 @@ def _set_aggregate_cluster_options(self): element.cls.meta_.aggregate_cluster.meta_.provider, ) + def _set_event_and_command_type(self): + for element_type in [DomainObjects.EVENT, DomainObjects.COMMAND]: + for _, element in self.registry._elements[element_type.value].items(): + setattr( + element.cls, + "__type__", + ( + f"{self.name}." + # f"{element.cls.meta_.aggregate_cluster.__class__.__name__}." + f"{element.cls.__name__}." + f"{element.cls.__version__}" + ), + ) + + def _setup_command_handlers(self): + for element_type in [DomainObjects.COMMAND_HANDLER]: + for _, element in self.registry._elements[element_type.value].items(): + # Iterate through methods marked as `@handle` and construct a handler map + if not element.cls._handlers: # Protect against re-registration + 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, "_target_cls"): + # Throw error if target_cls is not a Command + if not inspect.isclass( + method._target_cls + ) or not issubclass(method._target_cls, BaseCommand): + raise IncorrectUsageError( + { + "_command_handler": [ + f"Method `{method_name}` in Command Handler `{element.cls.__name__}` " + "is not associated with a command" + ] + } + ) + + # Throw error if target_cls is not associated with an aggregate + if not method._target_cls.meta_.part_of: + raise IncorrectUsageError( + { + "_command_handler": [ + f"Command `{method._target_cls.__name__}` in Command Handler `{element.cls.__name__}` " + "is not associated with an aggregate" + ] + } + ) + + if ( + method._target_cls.meta_.part_of + != element.cls.meta_.part_of + ): + raise IncorrectUsageError( + { + "_command_handler": [ + f"Command `{method._target_cls.__name__}` in Command Handler `{element.cls.__name__}` " + "is not associated with the same aggregate as the Command Handler" + ] + } + ) + + command_type = ( + method._target_cls.__type__ + if issubclass(method._target_cls, BaseCommand) + else method._target_cls + ) + + # Do not allow multiple handlers per command + if ( + command_type in element.cls._handlers + and len(element.cls._handlers[command_type]) != 0 + ): + raise NotSupportedError( + f"Command {method._target_cls.__name__} cannot be handled by multiple handlers" + ) + + # `_handlers` maps the command to its handler method + element.cls._handlers[command_type].add(method) + + def _setup_event_handlers(self): + for element_type in [DomainObjects.EVENT_HANDLER]: + for _, element in self.registry._elements[element_type.value].items(): + # Iterate through methods marked as `@handle` and construct a handler map + # + # Also, if `_target_cls` is an event, associate it with the event handler's + # aggregate or stream + 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, "_target_cls"): + # `_handlers` is a dictionary mapping the event to the handler method. + if method._target_cls == "$any": + # This replaces any existing `$any` handler, by design. An Event Handler + # can have only one `$any` handler method. + element.cls._handlers["$any"] = {method} + else: + # Target could be an event or an event type string + event_type = ( + method._target_cls.__type__ + if issubclass(method._target_cls, BaseEvent) + else method._target_cls + ) + element.cls._handlers[event_type].add(method) + def _generate_fact_event_classes(self): """Generate FactEvent classes for all aggregates with `fact_events` enabled""" for element_type in [ diff --git a/src/protean/utils/__init__.py b/src/protean/utils/__init__.py index b4b92be0..b2b29712 100644 --- a/src/protean/utils/__init__.py +++ b/src/protean/utils/__init__.py @@ -83,7 +83,7 @@ def import_from_full_path(domain, path): def fully_qualified_name(cls): """Return Fully Qualified name along with module""" - return ".".join([cls.__module__, cls.__name__]) + return ".".join([cls.__module__, cls.__qualname__]) fqn = fully_qualified_name diff --git a/tests/aggregate/events/test_event_association_with_aggregate.py b/tests/aggregate/events/test_event_association_with_aggregate.py index b28db345..6508cadb 100644 --- a/tests/aggregate/events/test_event_association_with_aggregate.py +++ b/tests/aggregate/events/test_event_association_with_aggregate.py @@ -57,6 +57,7 @@ def register_elements(test_domain): test_domain.register(UserRegistered, part_of=User) test_domain.register(UserActivated, part_of=User) test_domain.register(UserRenamed, part_of=User) + test_domain.init(traverse=False) def test_an_unassociated_event_throws_error(test_domain): @@ -72,6 +73,7 @@ def test_an_unassociated_event_throws_error(test_domain): def test_that_event_associated_with_another_aggregate_throws_error(test_domain): test_domain.register(User2) test_domain.register(UserUnknownEvent, part_of=User2) + test_domain.init(traverse=False) user = User.register(user_id="1", name="", email="") with pytest.raises(ConfigurationError) as exc: diff --git a/tests/command/test_automatic_stream_association.py b/tests/command/test_automatic_stream_association.py deleted file mode 100644 index 83bb4183..00000000 --- a/tests/command/test_automatic_stream_association.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import pytest - -from protean import BaseCommand, BaseCommandHandler, BaseEventSourcedAggregate, handle -from protean.fields import DateTime, Identifier, String - - -class User(BaseEventSourcedAggregate): - email = String() - name = String() - password_hash = String() - - -class Email(BaseEventSourcedAggregate): - email = String() - sent_at = DateTime() - - -class Register(BaseCommand): - id = Identifier() - email = String() - name = String() - password_hash = String() - - -class Activate(BaseCommand): - id = Identifier() - activated_at = DateTime() - - -class Login(BaseCommand): - id = Identifier() - activated_at = DateTime() - - -class Subscribe(BaseCommand): - """An event generated by an external system in its own stream, - that is consumed and stored as part of the User aggregate. - """ - - id = Identifier() - - -class Send(BaseCommand): - email = String() - sent_at = DateTime() - - -class Recall(BaseCommand): - email = String() - sent_at = DateTime() - - -class UserCommandHandler(BaseCommandHandler): - @handle(Register) - def send_activation_email(self, _: Register) -> None: - pass - - @handle(Activate) - def provision_user(self, _: Activate) -> None: - pass - - @handle(Login) - def login(self, _: Login) -> None: - pass - - @handle(Subscribe) - def subscribe_for_notifications(self, _: Subscribe) -> None: - pass - - -class EmailCommandHandler(BaseCommandHandler): - @handle(Send) - def send_mail(self, _: Send) -> None: - pass - - @handle(Recall) - def recall(self, _: Recall) -> None: - pass - - -@pytest.fixture(autouse=True) -def register(test_domain): - test_domain.register(User) - test_domain.register(Login, part_of=User) - test_domain.register(Register, part_of=User) - test_domain.register(Activate, part_of=User) - test_domain.register(Subscribe, part_of=User) - test_domain.register(Email) - test_domain.register(Send, part_of=Email) - test_domain.register(Recall, part_of=Email) - test_domain.register(UserCommandHandler, part_of=User) - test_domain.register(EmailCommandHandler, part_of=Email) - - -def test_automatic_association_of_events_with_aggregate_and_stream(): - assert Register.meta_.part_of is User - assert Register.meta_.stream_name == "user" - - assert Activate.meta_.part_of is User - assert Activate.meta_.stream_name == "user" - - assert Subscribe.meta_.part_of is User - assert Subscribe.meta_.stream_name == "user" - - assert Send.meta_.part_of is Email - assert Send.meta_.stream_name == "email" - - assert Recall.meta_.part_of is Email - assert Recall.meta_.stream_name == "email" diff --git a/tests/command_handler/test_basics.py b/tests/command_handler/test_basics.py index 73eeb4c1..e1fea527 100644 --- a/tests/command_handler/test_basics.py +++ b/tests/command_handler/test_basics.py @@ -34,8 +34,10 @@ def something(self, _: Registered): pass test_domain.register(User) + test_domain.register(UserCommandHandlers, part_of=User) + with pytest.raises(IncorrectUsageError) as exc: - test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) assert exc.value.messages == { "_command_handler": [ @@ -51,8 +53,10 @@ def something(self, _: Register): pass test_domain.register(User) + test_domain.register(UserCommandHandlers, part_of=User) + with pytest.raises(IncorrectUsageError) as exc: - test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) assert exc.value.messages == { "_command_handler": [ @@ -76,8 +80,10 @@ class User2(BaseAggregate): test_domain.register(User) test_domain.register(User2) test_domain.register(Register, part_of=User) + test_domain.register(UserCommandHandlers, part_of=User2) + with pytest.raises(IncorrectUsageError) as exc: - test_domain.register(UserCommandHandlers, part_of=User2) + test_domain.init(traverse=False) assert exc.value.messages == { "_command_handler": [ diff --git a/tests/command_handler/test_handle_decorator_in_command_handlers.py b/tests/command_handler/test_handle_decorator_in_command_handlers.py index 26a97e66..fa7461e0 100644 --- a/tests/command_handler/test_handle_decorator_in_command_handlers.py +++ b/tests/command_handler/test_handle_decorator_in_command_handlers.py @@ -30,6 +30,7 @@ def register(self, command: Register) -> None: test_domain.register(User) test_domain.register(Register, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) assert Register.__type__ in UserCommandHandlers._handlers @@ -83,10 +84,12 @@ def register(self, event: Register) -> None: def provision_user_account(self, event: Register) -> None: pass + test_domain.register(User) + test_domain.register(Register, part_of=User) + test_domain.register(UserCommandHandlers, part_of=User) + with pytest.raises(NotSupportedError) as exc: - test_domain.register(User) - test_domain.register(Register, part_of=User) - test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) assert ( exc.value.args[0] == "Command Register cannot be handled by multiple handlers" diff --git a/tests/domain/test_init.py b/tests/domain/test_init.py index e63ff12d..669d1ecc 100644 --- a/tests/domain/test_init.py +++ b/tests/domain/test_init.py @@ -31,12 +31,42 @@ def test_domain_init_calls_resolve_references(self, test_domain): test_domain.init(traverse=False) mock_resolve_references.assert_called_once() + def test_domain_init_assigns_aggregate_clusters(self, test_domain): + mock_assign_aggregate_clusters = Mock() + test_domain._assign_aggregate_clusters = mock_assign_aggregate_clusters + test_domain.init(traverse=False) + mock_assign_aggregate_clusters.assert_called_once() + + def test_domain_init_sets_aggregate_cluster_options(self, test_domain): + mock_set_aggregate_cluster_options = Mock() + test_domain._set_aggregate_cluster_options = mock_set_aggregate_cluster_options + test_domain.init(traverse=False) + mock_set_aggregate_cluster_options.assert_called_once() + def test_domain_init_constructs_fact_events(self, test_domain): mock_generate_fact_event_classes = Mock() test_domain._generate_fact_event_classes = mock_generate_fact_event_classes test_domain.init(traverse=False) mock_generate_fact_event_classes.assert_called_once() + def test_domain_init_sets_event_command_types(self, test_domain): + mock_set_event_and_command_type = Mock() + test_domain._set_event_and_command_type = mock_set_event_and_command_type + test_domain.init(traverse=False) + mock_set_event_and_command_type.assert_called_once() + + def test_domain_init_sets_up_command_handlers(self, test_domain): + mock_setup_command_handlers = Mock() + test_domain._setup_command_handlers = mock_setup_command_handlers + test_domain.init(traverse=False) + mock_setup_command_handlers.assert_called_once() + + def test_domain_init_sets_up_event_handlers(self, test_domain): + mock_setup_event_handlers = Mock() + test_domain._setup_event_handlers = mock_setup_event_handlers + test_domain.init(traverse=False) + mock_setup_event_handlers.assert_called_once() + class TestDomainInitializationCalls: @patch.object(Providers, "_initialize") diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index b6f9c5ce..36975a57 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -80,6 +80,7 @@ class UserLoggedIn(BaseEvent): user_id = Identifier(identifier=True) test_domain.register(UserLoggedIn, part_of=User) + test_domain.init(traverse=False) event = UserLoggedIn(user_id=str(uuid4())) assert event._metadata.version == "v2" diff --git a/tests/event/test_raising_events.py b/tests/event/test_raising_events.py index b524c56e..2ae10c0d 100644 --- a/tests/event/test_raising_events.py +++ b/tests/event/test_raising_events.py @@ -21,6 +21,7 @@ class UserLoggedIn(BaseEvent): def test_raising_event(test_domain): test_domain.register(User, stream_name="authentication") test_domain.register(UserLoggedIn, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) user = User(id=identifier, email="test@example.com", name="Test User") diff --git a/tests/event/tests.py b/tests/event/tests.py index 759c2271..335e8c9a 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -40,6 +40,7 @@ class UserAdded(BaseEvent): name = String(max_length=50) test_domain.register(UserAdded, part_of=User) + test_domain.init(traverse=False) user = User( id=str(uuid.uuid4()), @@ -91,6 +92,7 @@ class UserAdded(BaseEvent): test_domain.register(User) test_domain.register(UserAdded, part_of=User) + test_domain.init(traverse=False) assert UserAdded( { @@ -110,6 +112,7 @@ def test_that_base_domain_event_class_cannot_be_instantiated(self): def test_that_domain_event_can_be_instantiated(self, test_domain): test_domain.register(Person) test_domain.register(PersonAdded, part_of=Person) + test_domain.init(traverse=False) service = PersonAdded(id=uuid.uuid4(), first_name="John", last_name="Doe") assert service is not None @@ -131,6 +134,12 @@ def special_method(self): class TestDomainEventEquivalence: + @pytest.fixture(autouse=True) + def register_elements(self, test_domain): + test_domain.register(Person) + test_domain.register(PersonAdded, part_of=Person) + test_domain.init(traverse=False) + 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") diff --git a/tests/event_handler/test_any_event_handler.py b/tests/event_handler/test_any_event_handler.py index 2031824a..e0f9d0c6 100644 --- a/tests/event_handler/test_any_event_handler.py +++ b/tests/event_handler/test_any_event_handler.py @@ -19,6 +19,7 @@ def handler2(self, event: BaseEvent) -> None: def test_any_handler(test_domain): test_domain.register(AllEventHandler, stream_name="$all") + test_domain.init(traverse=False) len(AllEventHandler._handlers) == 1 assert AllEventHandler._handlers["$any"] == {AllEventHandler.universal_handler} @@ -26,6 +27,7 @@ def test_any_handler(test_domain): def test_that_there_can_be_only_one_any_handler_method_per_event_handler(test_domain): test_domain.register(MultipleAnyEventHandler, stream_name="$all") + test_domain.init(traverse=False) assert len(MultipleAnyEventHandler._handlers["$any"]) == 1 assert MultipleAnyEventHandler._handlers["$any"] == { diff --git a/tests/event_handler/test_handle_decorator_in_event_handlers.py b/tests/event_handler/test_handle_decorator_in_event_handlers.py index 068726af..ea9e3a76 100644 --- a/tests/event_handler/test_handle_decorator_in_event_handlers.py +++ b/tests/event_handler/test_handle_decorator_in_event_handlers.py @@ -28,6 +28,7 @@ def send_email_notification(self, event: Registered) -> None: test_domain.register(User) test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandlers, part_of=User) + test_domain.init(traverse=False) assert Registered.__type__ in UserEventHandlers._handlers @@ -46,6 +47,7 @@ def updated_billing_address(self, event: AddressChanged) -> None: test_domain.register(Registered, part_of=User) test_domain.register(AddressChanged, part_of=User) test_domain.register(UserEventHandlers, part_of=User) + test_domain.init(traverse=False) assert len(UserEventHandlers._handlers) == 2 assert all( @@ -80,6 +82,7 @@ def provision_user_accounts(self, event: Registered) -> None: test_domain.register(User) test_domain.register(UserEventHandlers, part_of=User) + test_domain.init(traverse=False) assert len(UserEventHandlers._handlers) == 1 # Against Registered Event diff --git a/tests/event_handler/test_retrieving_handlers_by_event.py b/tests/event_handler/test_retrieving_handlers_by_event.py index df33f74c..bfee2ac0 100644 --- a/tests/event_handler/test_retrieving_handlers_by_event.py +++ b/tests/event_handler/test_retrieving_handlers_by_event.py @@ -85,20 +85,20 @@ def register_elements(test_domain): def test_retrieving_handler_by_event(test_domain): - test_domain._initialize() + test_domain.init(traverse=False) assert test_domain.handlers_for(Registered()) == {UserEventHandler, UserMetrics} assert test_domain.handlers_for(Sent()) == {EmailEventHandler} def test_that_all_streams_handler_is_returned(test_domain): test_domain.register(AllEventsHandler, stream_name="$all") - test_domain._initialize() + test_domain.init(traverse=False) assert test_domain.handlers_for(Renamed()) == {AllEventsHandler} def test_that_all_streams_handler_is_always_returned_with_other_handlers(test_domain): test_domain.register(AllEventsHandler, stream_name="$all") - test_domain._initialize() + test_domain.init(traverse=False) assert test_domain.handlers_for(Registered()) == { UserEventHandler, diff --git a/tests/event_handler/test_uow_around_event_handlers.py b/tests/event_handler/test_uow_around_event_handlers.py index 44e25428..755befbc 100644 --- a/tests/event_handler/test_uow_around_event_handlers.py +++ b/tests/event_handler/test_uow_around_event_handlers.py @@ -30,6 +30,7 @@ def send_email_notification(self, event: Registered) -> None: def test_that_method_is_enclosed_in_uow(mock_exit, mock_dummy, mock_enter, test_domain): test_domain.register(User) test_domain.register(Registered, part_of=User) + test_domain.init(traverse=False) mock_parent = mock.Mock() diff --git a/tests/event_sourced_aggregates/test_apply.py b/tests/event_sourced_aggregates/test_apply.py index ae6cc058..26a98b27 100644 --- a/tests/event_sourced_aggregates/test_apply.py +++ b/tests/event_sourced_aggregates/test_apply.py @@ -159,13 +159,11 @@ class UserArchived(BaseEvent): user_id = Identifier(required=True) test_domain.register(UserArchived, part_of=User) + test_domain.init(traverse=False) user = User(user_id=str(uuid4()), name="", email="") with pytest.raises(NotImplementedError) as exc: user._apply(UserArchived(user_id=user.user_id)) - assert ( - exc.value.args[0] - == "No handler registered for event `tests.event_sourced_aggregates.test_apply.UserArchived` in `User`" - ) + assert exc.value.args[0].startswith("No handler registered for event") diff --git a/tests/message/test_message_to_object.py b/tests/message/test_message_to_object.py index ae0eb16d..df8dab64 100644 --- a/tests/message/test_message_to_object.py +++ b/tests/message/test_message_to_object.py @@ -49,6 +49,7 @@ def register(test_domain): test_domain.register(Registered, part_of=User) test_domain.register(SendEmail) test_domain.register(SendEmailCommand, part_of=SendEmail) + test_domain.init(traverse=False) def test_construct_event_from_message(): diff --git a/tests/server/test_any_event_handler.py b/tests/server/test_any_event_handler.py index 36a48b96..8333b223 100644 --- a/tests/server/test_any_event_handler.py +++ b/tests/server/test_any_event_handler.py @@ -39,6 +39,7 @@ async def test_that_an_event_handler_can_be_associated_with_an_all_stream(test_d test_domain.register(User) test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandler, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) user = User( diff --git a/tests/server/test_command_handling.py b/tests/server/test_command_handling.py index df1fba98..94ccc758 100644 --- a/tests/server/test_command_handling.py +++ b/tests/server/test_command_handling.py @@ -46,6 +46,7 @@ async def test_handler_invocation(test_domain): test_domain.register(Register, part_of=User) test_domain.register(Activate, part_of=User) test_domain.register(UserCommandHandler, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) command = Register( diff --git a/tests/server/test_error_handling.py b/tests/server/test_error_handling.py index 8f2fca26..9552968d 100644 --- a/tests/server/test_error_handling.py +++ b/tests/server/test_error_handling.py @@ -51,6 +51,7 @@ async def test_that_exception_is_raised(test_domain): test_domain.register(User) test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandler, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) user = User( diff --git a/tests/server/test_event_handling.py b/tests/server/test_event_handling.py index 53a1c3ec..c42718a2 100644 --- a/tests/server/test_event_handling.py +++ b/tests/server/test_event_handling.py @@ -41,6 +41,7 @@ async def test_handler_invocation(test_domain): test_domain.register(User) test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandler, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) user = User( diff --git a/tests/server/test_handling_all_events.py b/tests/server/test_handling_all_events.py index a66fbaba..87f37bd2 100644 --- a/tests/server/test_handling_all_events.py +++ b/tests/server/test_handling_all_events.py @@ -53,6 +53,7 @@ async def test_that_any_message_can_be_handled_with_any_handler(test_domain): test_domain.register(Post) test_domain.register(Created, part_of=Post) test_domain.register(SystemMetrics, stream_name="$all") + test_domain.init(traverse=False) identifier = str(uuid4()) user = User( diff --git a/tests/subscription/test_message_filtering_with_origin_stream.py b/tests/subscription/test_message_filtering_with_origin_stream.py index 6e67f55d..9c4da356 100644 --- a/tests/subscription/test_message_filtering_with_origin_stream.py +++ b/tests/subscription/test_message_filtering_with_origin_stream.py @@ -76,6 +76,7 @@ def register_elements(test_domain): test_domain.register(Email) test_domain.register(Sent, part_of=Email) test_domain.register(EmailEventHandler, stream_name="email", source_stream="user") + test_domain.init(traverse=False) @pytest.mark.asyncio diff --git a/tests/subscription/test_message_handover_to_engine.py b/tests/subscription/test_message_handover_to_engine.py index 0672512f..062fa384 100644 --- a/tests/subscription/test_message_handover_to_engine.py +++ b/tests/subscription/test_message_handover_to_engine.py @@ -57,6 +57,7 @@ async def test_that_subscription_invokes_engine_handler_on_message( test_domain.register(User) test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandler, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) user = User.register( diff --git a/tests/subscription/test_no_message_filtering.py b/tests/subscription/test_no_message_filtering.py index 7065cef5..e43e19fa 100644 --- a/tests/subscription/test_no_message_filtering.py +++ b/tests/subscription/test_no_message_filtering.py @@ -75,6 +75,7 @@ def register_elements(test_domain): test_domain.register(Email) test_domain.register(Sent, part_of=Email) test_domain.register(EmailEventHandler, stream_name="email") + test_domain.init(traverse=False) @pytest.mark.asyncio diff --git a/tests/subscription/test_read_position_updates.py b/tests/subscription/test_read_position_updates.py index 9179e20e..09f6c692 100644 --- a/tests/subscription/test_read_position_updates.py +++ b/tests/subscription/test_read_position_updates.py @@ -72,6 +72,7 @@ def register_elements(test_domain): test_domain.register(Sent, part_of=Email) test_domain.register(UserEventHandler, part_of=User) test_domain.register(EmailEventHandler, stream_name="email") + test_domain.init(traverse=False) @pytest.mark.asyncio From e7f23474fd41fe1cc4a3b8de98695a9beb34bb9d Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 13 Jul 2024 07:59:49 -0700 Subject: [PATCH 12/17] Rename aggregate's `stream_name` option to `stream_category` --- docs/guides/change-state/commands.md | 1 + .../compose-a-domain/register-elements.md | 4 +-- docs/guides/consume-state/event-handlers.md | 12 ++++--- docs/guides/domain-definition/aggregates.md | 11 ++++++ docs/patterns/creating-identities-early.md | 6 ++++ src/protean/adapters/event_store/__init__.py | 35 +++++++++---------- src/protean/adapters/event_store/memory.py | 2 +- src/protean/container.py | 4 +-- src/protean/core/aggregate.py | 2 +- src/protean/core/entity.py | 4 +-- src/protean/core/event_handler.py | 4 +-- src/protean/core/event_sourced_aggregate.py | 2 +- src/protean/core/repository.py | 2 +- src/protean/domain/__init__.py | 4 ++- src/protean/port/event_store.py | 8 ++--- src/protean/server/engine.py | 6 ++-- src/protean/server/subscription.py | 10 +++--- .../events/test_aggregate_event_streams.py | 2 +- tests/command/test_command_meta.py | 2 +- tests/event/test_event_part_of_resolution.py | 6 ++-- tests/event/test_raising_events.py | 2 +- tests/event/test_stream_name_derivation.py | 10 +++--- tests/event_handler/test_any_event_handler.py | 4 +-- .../test_event_handler_options.py | 16 ++++----- .../test_retrieving_handlers_by_event.py | 4 +-- .../test_event_sourced_aggregate_options.py | 20 +++++------ tests/event_store/test_appending_events.py | 2 +- ...test_inline_event_processing_on_publish.py | 4 +-- .../test_streams_initialization.py | 4 +-- tests/message/test_object_to_message.py | 15 ++++---- .../test_command_handler_subscription.py | 2 +- tests/server/test_engine_run.py | 4 +-- .../server/test_event_handler_subscription.py | 10 +++--- tests/server/test_handling_all_events.py | 2 +- ...st_message_filtering_with_origin_stream.py | 4 ++- .../subscription/test_no_message_filtering.py | 2 +- .../test_read_position_updates.py | 2 +- .../test_nested_inline_event_processing.py | 2 +- 38 files changed, 130 insertions(+), 106 deletions(-) create mode 100644 docs/patterns/creating-identities-early.md diff --git a/docs/guides/change-state/commands.md b/docs/guides/change-state/commands.md index cf321854..9dedc83d 100644 --- a/docs/guides/change-state/commands.md +++ b/docs/guides/change-state/commands.md @@ -9,6 +9,7 @@ carry intent and information necessary to perform a specific action. ## Key Facts +- Commands are unique throughout the domain. - Commands are typically named using imperative verbs that clearly describe the intended action or change. E.g. CreateOrder, UpdateCustomerAddress, ShipProduct, and CancelReservation. - Commands are typically related to an aggregate, because aggregates are the diff --git a/docs/guides/compose-a-domain/register-elements.md b/docs/guides/compose-a-domain/register-elements.md index 90ea19c0..62f7596a 100644 --- a/docs/guides/compose-a-domain/register-elements.md +++ b/docs/guides/compose-a-domain/register-elements.md @@ -21,8 +21,8 @@ depending upon the element being registered. {! docs_src/guides/composing-a-domain/015.py !} ``` -In the above example, the `User` aggregate's default stream name **`user`** is -customized to **`account`**. +In the above example, the `User` aggregate's default stream category **`user`** +is customized to **`account`**. Review the [object model](../object-model.md) to understand multiple ways to pass these options. Refer to each domain element's diff --git a/docs/guides/consume-state/event-handlers.md b/docs/guides/consume-state/event-handlers.md index 0b007185..c3c762ef 100644 --- a/docs/guides/consume-state/event-handlers.md +++ b/docs/guides/consume-state/event-handlers.md @@ -51,10 +51,14 @@ Out[8]: { ## Configuration Options - **`part_of`**: The aggregate to which the event handler is connected. -- **`stream_name`**: The event handler listens to events on this stream. -The stream name defaults to the aggregate's stream. This option comes handy -when the event handler belongs to an aggregate and needs to listen to another -aggregate's events. +- **`stream_category`**: The event handler listens to events on this stream +category. The stream category defaults to +[the category of the aggregate](../domain-definition/aggregates.md#stream_category) +associated with the handler. + +An Event Handler can be part of an aggregate, and have the stream category of +a different aggregate. This is the mechanism for an aggregate to listen to +another aggregate's events to sync its own state. - **`source_stream`**: When specified, the event handler only consumes events generated in response to events or commands from this original stream. For example, `EmailNotifications` event handler listening to `OrderShipped` diff --git a/docs/guides/domain-definition/aggregates.md b/docs/guides/domain-definition/aggregates.md index 8e3980f4..4ac0fc0c 100644 --- a/docs/guides/domain-definition/aggregates.md +++ b/docs/guides/domain-definition/aggregates.md @@ -182,6 +182,17 @@ the aggregate. `Customizing Persistence schemas` for more information. +### `stream_category` + +The stream to which the aggregate outpus events and processes commands from. +The category is automatically derived as the `underscore` version of the +aggregate's name, but can be overridden. E.g. `User` has `user` as the +automatic stream category, `OrderItem` will have `order_item`. + +The stream category is used by all elements in the aggregate's cluster, +including Command Handlers and Event Handlers to determine the event or command +stream to listen to. + ## Associations Protean provides multiple options for Aggregates to weave object graphs with diff --git a/docs/patterns/creating-identities-early.md b/docs/patterns/creating-identities-early.md new file mode 100644 index 00000000..cc22b8f4 --- /dev/null +++ b/docs/patterns/creating-identities-early.md @@ -0,0 +1,6 @@ +An identity can be created very early in the game. +Right in the Frontend. +Can be packaged into the API call +Identifier can be used when constructing the command. +This applies only to first time creation. +After the first time, every command after should contain the identifier anyway. \ No newline at end of file diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index 5190c603..0c19d7d1 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -71,15 +71,15 @@ def _initialize(self) -> None: def _initialize_event_streams(self): for _, record in self.domain.registry.event_handlers.items(): - stream_name = ( - record.cls.meta_.stream_name - or record.cls.meta_.part_of.meta_.stream_name + stream_category = ( + record.cls.meta_.stream_category + or record.cls.meta_.part_of.meta_.stream_category ) - self._event_streams[stream_name].add(record.cls) + self._event_streams[stream_category].add(record.cls) def _initialize_command_streams(self): for _, record in self.domain.registry.command_handlers.items(): - self._command_streams[record.cls.meta_.part_of.meta_.stream_name].add( + self._command_streams[record.cls.meta_.part_of.meta_.stream_category].add( record.cls ) @@ -99,7 +99,7 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: # Gather all handlers configured to run on this event stream_handlers = self._event_streams.get( - event.meta_.part_of.meta_.stream_name, set() + event.meta_.part_of.meta_.stream_category, set() ) configured_stream_handlers = set() for stream_handler in stream_handlers: @@ -114,9 +114,9 @@ def command_handler_for(self, command: BaseCommand) -> Optional[BaseCommandHandl f"Command `{command.__name__}` needs to be associated with an aggregate" ) - stream_name = command.meta_.part_of.meta_.stream_name + stream_category = command.meta_.part_of.meta_.stream_category - handler_classes = self._command_streams.get(stream_name, set()) + handler_classes = self._command_streams.get(stream_category, set()) # No command handlers have been configured to run this command if len(handler_classes) == 0: @@ -142,19 +142,19 @@ def command_handler_for(self, command: BaseCommand) -> Optional[BaseCommandHandl return next(iter(handler_methods))[0] if handler_methods else None def last_event_of_type( - self, event_cls: Type[BaseEvent], stream_name: str = None + self, event_cls: Type[BaseEvent], stream_category: str = None ) -> BaseEvent: - stream_name = stream_name or "$all" + stream_category = stream_category or "$all" events = [ event - for event in self.domain.event_store.store._read(stream_name) + for event in self.domain.event_store.store._read(stream_category) if event["type"] == event_cls.__type__ ] return Message.from_dict(events[-1]).to_object() if len(events) > 0 else None def events_of_type( - self, event_cls: Type[BaseEvent], stream_name: str = None + self, event_cls: Type[BaseEvent], stream_category: str = None ) -> List[BaseEvent]: """Read events of a specific type in a given stream. @@ -163,16 +163,13 @@ def events_of_type( If no stream is specified, events of the requested type will be retrieved from all streams. - :param event_cls: Class of the event type to be retrieved - :param stream_name: Stream from which events are to be retrieved - :type event_cls: BaseEvent Class - :type stream_name: String, optional, default is `None` + :param event_cls: Class of the event type to be retrieved. Subclass of `BaseEvent`. + :param stream_category: Stream from which events are to be retrieved. String, optional, default is `None` :return: A list of events of `event_cls` type - :rtype: list """ - stream_name = stream_name or "$all" + stream_category = stream_category or "$all" return [ Message.from_dict(event).to_object() - for event in self.domain.event_store.store._read(stream_name) + for event in self.domain.event_store.store._read(stream_category) if event["type"] == event_cls.__type__ ] diff --git a/src/protean/adapters/event_store/memory.py b/src/protean/adapters/event_store/memory.py index 677f705f..e1f7d696 100644 --- a/src/protean/adapters/event_store/memory.py +++ b/src/protean/adapters/event_store/memory.py @@ -74,7 +74,7 @@ def read( ) if stream_name == "$all": - pass # Don't filter on stream name + pass # Don't filter on stream name or category elif self.is_category(stream_name): # If filtering on category, ensure the supplied stream name # is the only thing in the category. diff --git a/src/protean/container.py b/src/protean/container.py index fd7e7357..13e3e7be 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -405,9 +405,9 @@ def raise_(self, event, fact_event=False) -> None: # Set Fact Event stream to be `-fact` if event.__class__.__name__.endswith("FactEvent"): - stream_name = f"{self.meta_.stream_name}-fact-{identifier}" + stream_name = f"{self.meta_.stream_category}-fact-{identifier}" else: - stream_name = f"{self.meta_.stream_name}-{identifier}" + stream_name = f"{self.meta_.stream_category}-{identifier}" event_with_metadata = event.__class__( event.to_dict(), diff --git a/src/protean/core/aggregate.py b/src/protean/core/aggregate.py index d973a07a..f36b6d59 100644 --- a/src/protean/core/aggregate.py +++ b/src/protean/core/aggregate.py @@ -76,7 +76,7 @@ def _default_options(cls): ("model", None), ("provider", "default"), ("schema_name", inflection.underscore(cls.__name__)), - ("stream_name", inflection.underscore(cls.__name__)), + ("stream_category", inflection.underscore(cls.__name__)), ] diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 1acc7fd3..a6a77613 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -459,9 +459,9 @@ def raise_(self, event) -> None: # Set Fact Event stream to be `-fact` if event.__class__.__name__.endswith("FactEvent"): - stream_name = f"{self._root.meta_.stream_name}-fact-{identifier}" + stream_name = f"{self._root.meta_.stream_category}-fact-{identifier}" else: - stream_name = f"{self._root.meta_.stream_name}-{identifier}" + stream_name = f"{self._root.meta_.stream_category}-{identifier}" event_with_metadata = event.__class__( event.to_dict(), diff --git a/src/protean/core/event_handler.py b/src/protean/core/event_handler.py index 645d6632..3537610d 100644 --- a/src/protean/core/event_handler.py +++ b/src/protean/core/event_handler.py @@ -27,14 +27,14 @@ def _default_options(cls): return [ ("part_of", None), ("source_stream", None), - ("stream_name", part_of.meta_.stream_name if part_of else None), + ("stream_category", part_of.meta_.stream_category if part_of else None), ] def event_handler_factory(element_cls, domain, **opts): element_cls = derive_element_class(element_cls, BaseEventHandler, **opts) - if not (element_cls.meta_.part_of or element_cls.meta_.stream_name): + if not (element_cls.meta_.part_of or element_cls.meta_.stream_category): raise IncorrectUsageError( { "_entity": [ diff --git a/src/protean/core/event_sourced_aggregate.py b/src/protean/core/event_sourced_aggregate.py index 2649eee0..37ab28e2 100644 --- a/src/protean/core/event_sourced_aggregate.py +++ b/src/protean/core/event_sourced_aggregate.py @@ -49,7 +49,7 @@ def _default_options(cls): ("aggregate_cluster", None), ("auto_add_id_field", True), ("fact_events", False), - ("stream_name", inflection.underscore(cls.__name__)), + ("stream_category", inflection.underscore(cls.__name__)), ] def __init_subclass__(subclass) -> None: diff --git a/src/protean/core/repository.py b/src/protean/core/repository.py index 86add121..be9790f4 100644 --- a/src/protean/core/repository.py +++ b/src/protean/core/repository.py @@ -249,7 +249,7 @@ def get(self, identifier) -> BaseAggregate: # Fetch and sync events version last_message = current_domain.event_store.store.read_last_message( - f"{aggregate.meta_.stream_name}-{identifier}" + f"{aggregate.meta_.stream_category}-{identifier}" ) if last_message: aggregate._event_position = last_message.position diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 92a214a8..d16c2dab 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -1073,7 +1073,9 @@ def _enrich_command(self, command: BaseCommand) -> BaseCommand: else: identifier = str(uuid4()) - stream_name = f"{command.meta_.part_of.meta_.stream_name}:command-{identifier}" + stream_name = ( + f"{command.meta_.part_of.meta_.stream_category}:command-{identifier}" + ) origin_stream_name = None if hasattr(g, "message_in_context"): diff --git a/src/protean/port/event_store.py b/src/protean/port/event_store.py index 57c870ce..09091d65 100644 --- a/src/protean/port/event_store.py +++ b/src/protean/port/event_store.py @@ -124,7 +124,7 @@ def load_aggregate( or None. """ snapshot_message = self._read_last_message( - f"{part_of.meta_.stream_name}:snapshot-{identifier}" + f"{part_of.meta_.stream_category}:snapshot-{identifier}" ) if snapshot_message: @@ -135,7 +135,7 @@ def load_aggregate( event_stream = deque( self._read( - f"{part_of.meta_.stream_name}-{identifier}", + f"{part_of.meta_.stream_category}-{identifier}", position=aggregate._version + 1, ) ) @@ -147,7 +147,7 @@ def load_aggregate( else: # No snapshot, so initialize aggregate from events event_stream = deque( - self._read(f"{part_of.meta_.stream_name}-{identifier}") + self._read(f"{part_of.meta_.stream_category}-{identifier}") ) if not event_stream: @@ -181,7 +181,7 @@ def load_aggregate( # and also avoids spurious data just to satisfy Metadata's structure # and conditions. self._write( - f"{part_of.meta_.stream_name}:snapshot-{identifier}", + f"{part_of.meta_.stream_category}:snapshot-{identifier}", "SNAPSHOT", aggregate.to_dict(), ) diff --git a/src/protean/server/engine.py b/src/protean/server/engine.py index 88253d2e..8298cf22 100644 --- a/src/protean/server/engine.py +++ b/src/protean/server/engine.py @@ -57,8 +57,8 @@ def __init__(self, domain, test_mode: bool = False, debug: bool = False) -> None self._subscriptions[handler_name] = Subscription( self, handler_name, - record.cls.meta_.stream_name - or record.cls.meta_.part_of.meta_.stream_name, + record.cls.meta_.stream_category + or record.cls.meta_.part_of.meta_.stream_category, record.cls, origin_stream_name=record.cls.meta_.source_stream, ) @@ -68,7 +68,7 @@ def __init__(self, domain, test_mode: bool = False, debug: bool = False) -> None self._subscriptions[handler_name] = Subscription( self, handler_name, - f"{record.cls.meta_.part_of.meta_.stream_name}:command", + f"{record.cls.meta_.part_of.meta_.stream_category}:command", record.cls, ) diff --git a/src/protean/server/subscription.py b/src/protean/server/subscription.py index a459a93a..f0efb509 100644 --- a/src/protean/server/subscription.py +++ b/src/protean/server/subscription.py @@ -27,7 +27,7 @@ def __init__( self, engine, subscriber_id: str, - stream_name: str, + stream_category: str, handler: Union[BaseEventHandler, BaseCommandHandler], messages_per_tick: int = 10, position_update_interval: int = 10, @@ -40,7 +40,7 @@ def __init__( Args: engine: The Protean engine instance. subscriber_id (str): The unique identifier for the subscriber. - stream_name (str): The name of the stream to subscribe to. + stream_category (str): The name of the stream to subscribe to. handler (Union[BaseEventHandler, BaseCommandHandler]): The event or command handler. messages_per_tick (int, optional): The number of messages to process per tick. Defaults to 10. position_update_interval (int, optional): The interval at which to update the current position. Defaults to 10. @@ -53,7 +53,7 @@ def __init__( self.loop = engine.loop self.subscriber_id = subscriber_id - self.stream_name = stream_name + self.stream_category = stream_category self.handler = handler self.messages_per_tick = messages_per_tick self.position_update_interval = position_update_interval @@ -218,7 +218,7 @@ def write_position(self, position: int) -> int: {"position": position}, metadata={ "kind": MessageType.READ_POSITION.value, - "origin_stream_name": self.stream_name, + "origin_stream_name": self.stream_category, }, ) @@ -259,7 +259,7 @@ async def get_next_batch_of_messages(self): List[Message]: The next batch of messages to process. """ messages = self.store.read( - self.stream_name, + self.stream_category, position=self.current_position + 1, no_of_messages=self.messages_per_tick, ) # FIXME Implement filtering diff --git a/tests/aggregate/events/test_aggregate_event_streams.py b/tests/aggregate/events/test_aggregate_event_streams.py index af11dff6..cbaecd59 100644 --- a/tests/aggregate/events/test_aggregate_event_streams.py +++ b/tests/aggregate/events/test_aggregate_event_streams.py @@ -53,7 +53,7 @@ def register_elements(test_domain): class TestDeltaEvents: def test_aggregate_stream_name(self): - assert User.meta_.stream_name == "user" + assert User.meta_.stream_category == "user" def test_event_metadata(self): user = User(name="John Doe", email="john.doe@example.com") diff --git a/tests/command/test_command_meta.py b/tests/command/test_command_meta.py index 8b5a250a..b252ff46 100644 --- a/tests/command/test_command_meta.py +++ b/tests/command/test_command_meta.py @@ -68,7 +68,7 @@ def test_command_associated_with_aggregate(test_domain): @pytest.mark.eventstore def test_command_associated_with_aggregate_with_custom_stream_name(test_domain): - test_domain.register(User, stream_name="foo") + test_domain.register(User, stream_category="foo") test_domain.register(Register, part_of=User) test_domain.init(traverse=False) diff --git a/tests/event/test_event_part_of_resolution.py b/tests/event/test_event_part_of_resolution.py index f8a4f15b..7d30a076 100644 --- a/tests/event/test_event_part_of_resolution.py +++ b/tests/event/test_event_part_of_resolution.py @@ -21,12 +21,12 @@ def register_elements(test_domain): test_domain.register(UserLoggedIn, part_of="User") -def test_event_does_not_have_stream_name_before_domain_init(): +def test_event_does_not_have_stream_category_before_domain_init(): assert isinstance(UserLoggedIn.meta_.part_of, str) -def test_event_has_stream_name_after_domain_init(test_domain): +def test_event_has_stream_category_after_domain_init(test_domain): test_domain.init(traverse=False) assert UserLoggedIn.meta_.part_of == User - assert UserLoggedIn.meta_.part_of.meta_.stream_name == "user" + assert UserLoggedIn.meta_.part_of.meta_.stream_category == "user" diff --git a/tests/event/test_raising_events.py b/tests/event/test_raising_events.py index 2ae10c0d..c2b5f895 100644 --- a/tests/event/test_raising_events.py +++ b/tests/event/test_raising_events.py @@ -19,7 +19,7 @@ class UserLoggedIn(BaseEvent): @pytest.mark.eventstore def test_raising_event(test_domain): - test_domain.register(User, stream_name="authentication") + test_domain.register(User, stream_category="authentication") test_domain.register(UserLoggedIn, part_of=User) test_domain.init(traverse=False) diff --git a/tests/event/test_stream_name_derivation.py b/tests/event/test_stream_name_derivation.py index 606addbb..6dd919b9 100644 --- a/tests/event/test_stream_name_derivation.py +++ b/tests/event/test_stream_name_derivation.py @@ -12,15 +12,15 @@ class UserLoggedIn(BaseEvent): user_id = Identifier(identifier=True) -def test_stream_name_from_part_of(test_domain): +def test_stream_category_from_part_of(test_domain): test_domain.register(User) test_domain.register(UserLoggedIn, part_of=User) - assert UserLoggedIn.meta_.part_of.meta_.stream_name == "user" + assert UserLoggedIn.meta_.part_of.meta_.stream_category == "user" -def test_stream_name_from_explicit_stream_name_in_aggregate(test_domain): - test_domain.register(User, stream_name="authentication") +def test_stream_category_from_explicit_stream_category_in_aggregate(test_domain): + test_domain.register(User, stream_category="authentication") test_domain.register(UserLoggedIn, part_of=User) - assert UserLoggedIn.meta_.part_of.meta_.stream_name == "authentication" + assert UserLoggedIn.meta_.part_of.meta_.stream_category == "authentication" diff --git a/tests/event_handler/test_any_event_handler.py b/tests/event_handler/test_any_event_handler.py index e0f9d0c6..00179c96 100644 --- a/tests/event_handler/test_any_event_handler.py +++ b/tests/event_handler/test_any_event_handler.py @@ -18,7 +18,7 @@ def handler2(self, event: BaseEvent) -> None: def test_any_handler(test_domain): - test_domain.register(AllEventHandler, stream_name="$all") + test_domain.register(AllEventHandler, stream_category="$all") test_domain.init(traverse=False) len(AllEventHandler._handlers) == 1 @@ -26,7 +26,7 @@ def test_any_handler(test_domain): def test_that_there_can_be_only_one_any_handler_method_per_event_handler(test_domain): - test_domain.register(MultipleAnyEventHandler, stream_name="$all") + test_domain.register(MultipleAnyEventHandler, stream_category="$all") test_domain.init(traverse=False) assert len(MultipleAnyEventHandler._handlers["$any"]) == 1 diff --git a/tests/event_handler/test_event_handler_options.py b/tests/event_handler/test_event_handler_options.py index e001c36d..67642d8b 100644 --- a/tests/event_handler/test_event_handler_options.py +++ b/tests/event_handler/test_event_handler_options.py @@ -46,30 +46,30 @@ class UserEventHandlers(BaseEventHandler): assert UserEventHandlers.meta_.part_of == User -def test_stream_name_option(test_domain): +def test_stream_category_option(test_domain): class UserEventHandlers(BaseEventHandler): pass - test_domain.register(UserEventHandlers, stream_name="user") - assert UserEventHandlers.meta_.stream_name == "user" + test_domain.register(UserEventHandlers, stream_category="user") + assert UserEventHandlers.meta_.stream_category == "user" def test_options_defined_at_different_levels(test_domain): class UserEventHandlers(BaseEventHandler): pass - test_domain.register(UserEventHandlers, part_of=User, stream_name="person") + test_domain.register(UserEventHandlers, part_of=User, stream_category="person") assert UserEventHandlers.meta_.part_of == User - assert UserEventHandlers.meta_.stream_name == "person" + assert UserEventHandlers.meta_.stream_category == "person" -def test_that_a_default_stream_name_is_derived_from_part_of(test_domain): +def test_that_a_default_stream_category_is_derived_from_part_of(test_domain): class UserEventHandlers(BaseEventHandler): pass test_domain.register(User) test_domain.register(UserEventHandlers, part_of=User) - assert UserEventHandlers.meta_.stream_name == "user" + assert UserEventHandlers.meta_.stream_category == "user" def test_source_stream_option(test_domain): @@ -80,7 +80,7 @@ class UserEventHandlers(BaseEventHandler): assert UserEventHandlers.meta_.source_stream == "email" -def test_that_aggregate_or_stream_name_has_to_be_specified(test_domain): +def test_that_aggregate_or_stream_category_has_to_be_specified(test_domain): class UserEventHandlers(BaseEventHandler): pass diff --git a/tests/event_handler/test_retrieving_handlers_by_event.py b/tests/event_handler/test_retrieving_handlers_by_event.py index bfee2ac0..49ac95b4 100644 --- a/tests/event_handler/test_retrieving_handlers_by_event.py +++ b/tests/event_handler/test_retrieving_handlers_by_event.py @@ -91,13 +91,13 @@ def test_retrieving_handler_by_event(test_domain): def test_that_all_streams_handler_is_returned(test_domain): - test_domain.register(AllEventsHandler, stream_name="$all") + test_domain.register(AllEventsHandler, stream_category="$all") test_domain.init(traverse=False) assert test_domain.handlers_for(Renamed()) == {AllEventsHandler} def test_that_all_streams_handler_is_always_returned_with_other_handlers(test_domain): - test_domain.register(AllEventsHandler, stream_name="$all") + test_domain.register(AllEventsHandler, stream_category="$all") test_domain.init(traverse=False) assert test_domain.handlers_for(Registered()) == { diff --git a/tests/event_sourced_aggregates/test_event_sourced_aggregate_options.py b/tests/event_sourced_aggregates/test_event_sourced_aggregate_options.py index 40464bc3..260291cf 100644 --- a/tests/event_sourced_aggregates/test_event_sourced_aggregate_options.py +++ b/tests/event_sourced_aggregates/test_event_sourced_aggregate_options.py @@ -22,20 +22,20 @@ class Person(BaseEventSourcedAggregate): def register_elements(test_domain): test_domain.register(User) test_domain.register(AdminUser) - test_domain.register(Person, stream_name="people") + test_domain.register(Person, stream_category="people") -def test_stream_name_option_of_an_event_sourced_aggregate(): - assert User.meta_.stream_name == "user" +def test_stream_category_option_of_an_event_sourced_aggregate(): + assert User.meta_.stream_category == "user" # Verify snake-casing the Aggregate name - assert AdminUser.meta_.stream_name == "admin_user" + assert AdminUser.meta_.stream_category == "admin_user" - # Verify manually set stream_name - assert Person.meta_.stream_name == "people" + # Verify manually set stream_category + assert Person.meta_.stream_category == "people" -def test_stream_name_option_of_an_event_sourced_aggregate_defined_via_annotation( +def test_stream_category_option_of_an_event_sourced_aggregate_defined_via_annotation( test_domain, ): @test_domain.event_sourced_aggregate @@ -43,11 +43,11 @@ class Adult(BaseEventSourcedAggregate): name = String() age = Integer() - assert Adult.meta_.stream_name == "adult" + assert Adult.meta_.stream_category == "adult" - @test_domain.event_sourced_aggregate(stream_name="children") + @test_domain.event_sourced_aggregate(stream_category="children") class Child(BaseEventSourcedAggregate): name = String() age = Integer() - assert Child.meta_.stream_name == "children" + assert Child.meta_.stream_category == "children" diff --git a/tests/event_store/test_appending_events.py b/tests/event_store/test_appending_events.py index e447e9dd..d606fb8d 100644 --- a/tests/event_store/test_appending_events.py +++ b/tests/event_store/test_appending_events.py @@ -19,7 +19,7 @@ class UserLoggedIn(BaseEvent): @pytest.mark.eventstore def test_appending_raw_events(test_domain): - test_domain.register(User, stream_name="authentication") + test_domain.register(User, stream_category="authentication") test_domain.register(UserLoggedIn, part_of=User) test_domain.init(traverse=False) diff --git a/tests/event_store/test_inline_event_processing_on_publish.py b/tests/event_store/test_inline_event_processing_on_publish.py index 0390ccd2..5d577fda 100644 --- a/tests/event_store/test_inline_event_processing_on_publish.py +++ b/tests/event_store/test_inline_event_processing_on_publish.py @@ -43,9 +43,9 @@ def registered(self, _: Registered) -> None: @pytest.mark.eventstore def test_inline_event_processing_on_publish_in_sync_mode(test_domain): - test_domain.register(User, stream_name="user") + test_domain.register(User, stream_category="user") test_domain.register(Registered, part_of=User) - test_domain.register(UserEventHandler, stream_name="user") + test_domain.register(UserEventHandler, stream_category="user") test_domain.init(traverse=False) user = User( diff --git a/tests/event_store/test_streams_initialization.py b/tests/event_store/test_streams_initialization.py index 2b648a96..3511e504 100644 --- a/tests/event_store/test_streams_initialization.py +++ b/tests/event_store/test_streams_initialization.py @@ -69,8 +69,8 @@ def register(test_domain): def test_streams_initialization(test_domain): assert len(test_domain.event_store._event_streams) == 2 assert all( - stream_name in test_domain.event_store._event_streams - for stream_name in ["user", "email"] + stream_category in test_domain.event_store._event_streams + for stream_category in ["user", "email"] ) assert test_domain.event_store._event_streams["user"] == {UserEventHandler} diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index a2bee680..50ce865d 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -64,7 +64,7 @@ def test_construct_message_from_event(test_domain): # Verify Message Content assert message.type == Registered.__type__ - assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == user._events[-1].payload assert message.time is None @@ -75,7 +75,7 @@ def test_construct_message_from_event(test_domain): assert message_dict["type"] == Registered.__type__ assert message_dict["metadata"]["kind"] == "EVENT" - assert message_dict["stream_name"] == f"{User.meta_.stream_name}-{identifier}" + assert message_dict["stream_name"] == f"{User.meta_.stream_category}-{identifier}" assert message_dict["data"] == user._events[-1].payload assert message_dict["time"] is None assert ( @@ -97,7 +97,7 @@ def test_construct_message_from_command(test_domain): # Verify Message Content assert message.type == Register.__type__ - assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command_with_metadata.payload assert message.time is not None @@ -107,7 +107,8 @@ def test_construct_message_from_command(test_domain): assert message_dict["type"] == Register.__type__ assert message_dict["metadata"]["kind"] == "COMMAND" assert ( - message_dict["stream_name"] == f"{User.meta_.stream_name}:command-{identifier}" + message_dict["stream_name"] + == f"{User.meta_.stream_category}:command-{identifier}" ) assert message_dict["data"] == command_with_metadata.payload assert message_dict["time"] is not None @@ -127,7 +128,7 @@ def test_construct_message_from_command_without_identifier(test_domain): message_dict = message.to_dict() identifier = message_dict["stream_name"].split( - f"{SendEmail.meta_.stream_name}:command-", 1 + f"{SendEmail.meta_.stream_category}:command-", 1 )[1] try: @@ -148,7 +149,7 @@ def test_construct_message_from_either_event_or_command(test_domain): # Verify Message Content assert message.type == Register.__type__ - assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command.payload @@ -164,7 +165,7 @@ def test_construct_message_from_either_event_or_command(test_domain): # Verify Message Content assert message.type == Registered.__type__ - assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == event.payload assert message.time is None diff --git a/tests/server/test_command_handler_subscription.py b/tests/server/test_command_handler_subscription.py index 1a057bf7..8cec2be3 100644 --- a/tests/server/test_command_handler_subscription.py +++ b/tests/server/test_command_handler_subscription.py @@ -53,6 +53,6 @@ def test_command_handler_subscriptions(engine): assert fully_qualified_name(UserCommandHandler) in engine._subscriptions assert ( - engine._subscriptions[fully_qualified_name(UserCommandHandler)].stream_name + engine._subscriptions[fully_qualified_name(UserCommandHandler)].stream_category == "user:command" ) diff --git a/tests/server/test_engine_run.py b/tests/server/test_engine_run.py index c130a68c..7a2bad80 100644 --- a/tests/server/test_engine_run.py +++ b/tests/server/test_engine_run.py @@ -46,9 +46,9 @@ def auto_set_and_close_loop(): @pytest.fixture(autouse=True) def register_elements(test_domain): test_domain.config["event_processing"] = EventProcessing.ASYNC.value - test_domain.register(User, stream_name="authentication") + test_domain.register(User, stream_category="authentication") test_domain.register(UserLoggedIn, part_of=User) - test_domain.register(UserEventHandler, stream_name="authentication") + test_domain.register(UserEventHandler, stream_category="authentication") test_domain.init(traverse=False) diff --git a/tests/server/test_event_handler_subscription.py b/tests/server/test_event_handler_subscription.py index a503858c..bb9b40c3 100644 --- a/tests/server/test_event_handler_subscription.py +++ b/tests/server/test_event_handler_subscription.py @@ -80,10 +80,10 @@ def test_event_subscriptions(test_domain): assert len(engine._subscriptions) == 1 assert fqn(UserEventHandler) in engine._subscriptions - assert engine._subscriptions[fqn(UserEventHandler)].stream_name == "user" + assert engine._subscriptions[fqn(UserEventHandler)].stream_category == "user" -def test_origin_stream_name_in_subscription(test_domain): +def test_origin_stream_category_in_subscription(test_domain): test_domain.register(User) test_domain.register(Sent, part_of=User) test_domain.register(EmailEventHandler, part_of=User, source_stream="email") @@ -91,7 +91,7 @@ def test_origin_stream_name_in_subscription(test_domain): engine = Engine(test_domain, test_mode=True) assert len(engine._subscriptions) == 1 - assert engine._subscriptions[fqn(EmailEventHandler)].stream_name == "user" + assert engine._subscriptions[fqn(EmailEventHandler)].stream_category == "user" assert engine._subscriptions[fqn(EmailEventHandler)].origin_stream_name == "email" @@ -101,12 +101,12 @@ def test_that_stream_name_overrides_the_derived_stream_name_from_owning_aggregat test_domain.register( EmailEventHandler, part_of=User, - stream_name="identity", + stream_category="identity", source_stream="email", ) engine = Engine(test_domain, test_mode=True) assert len(engine._subscriptions) == 1 - assert engine._subscriptions[fqn(EmailEventHandler)].stream_name == "identity" + assert engine._subscriptions[fqn(EmailEventHandler)].stream_category == "identity" assert engine._subscriptions[fqn(EmailEventHandler)].origin_stream_name == "email" diff --git a/tests/server/test_handling_all_events.py b/tests/server/test_handling_all_events.py index 87f37bd2..9dca5254 100644 --- a/tests/server/test_handling_all_events.py +++ b/tests/server/test_handling_all_events.py @@ -52,7 +52,7 @@ async def test_that_any_message_can_be_handled_with_any_handler(test_domain): test_domain.register(Registered, part_of=User) test_domain.register(Post) test_domain.register(Created, part_of=Post) - test_domain.register(SystemMetrics, stream_name="$all") + test_domain.register(SystemMetrics, stream_category="$all") test_domain.init(traverse=False) identifier = str(uuid4()) diff --git a/tests/subscription/test_message_filtering_with_origin_stream.py b/tests/subscription/test_message_filtering_with_origin_stream.py index 9c4da356..2ba5dc60 100644 --- a/tests/subscription/test_message_filtering_with_origin_stream.py +++ b/tests/subscription/test_message_filtering_with_origin_stream.py @@ -75,7 +75,9 @@ def register_elements(test_domain): test_domain.register(Email) test_domain.register(Sent, part_of=Email) - test_domain.register(EmailEventHandler, stream_name="email", source_stream="user") + test_domain.register( + EmailEventHandler, stream_category="email", source_stream="user" + ) test_domain.init(traverse=False) diff --git a/tests/subscription/test_no_message_filtering.py b/tests/subscription/test_no_message_filtering.py index e43e19fa..b5e94c27 100644 --- a/tests/subscription/test_no_message_filtering.py +++ b/tests/subscription/test_no_message_filtering.py @@ -74,7 +74,7 @@ def register_elements(test_domain): test_domain.register(UserEventHandler, part_of=User) test_domain.register(Email) test_domain.register(Sent, part_of=Email) - test_domain.register(EmailEventHandler, stream_name="email") + test_domain.register(EmailEventHandler, stream_category="email") test_domain.init(traverse=False) diff --git a/tests/subscription/test_read_position_updates.py b/tests/subscription/test_read_position_updates.py index 09f6c692..8fe7c578 100644 --- a/tests/subscription/test_read_position_updates.py +++ b/tests/subscription/test_read_position_updates.py @@ -71,7 +71,7 @@ def register_elements(test_domain): test_domain.register(Activated, part_of=User) test_domain.register(Sent, part_of=Email) test_domain.register(UserEventHandler, part_of=User) - test_domain.register(EmailEventHandler, stream_name="email") + test_domain.register(EmailEventHandler, stream_category="email") test_domain.init(traverse=False) diff --git a/tests/unit_of_work/test_nested_inline_event_processing.py b/tests/unit_of_work/test_nested_inline_event_processing.py index 55bbbe66..cdf0dc5a 100644 --- a/tests/unit_of_work/test_nested_inline_event_processing.py +++ b/tests/unit_of_work/test_nested_inline_event_processing.py @@ -98,7 +98,7 @@ def test_nested_uow_processing(test_domain): test_domain.register(Created, part_of=Post) test_domain.register(Published, part_of=Post) test_domain.register(PostEventHandler, part_of=Post) - test_domain.register(Metrics, stream_name="post") + test_domain.register(Metrics, stream_category="post") test_domain.init(traverse=False) identifier = str(uuid4()) From f606d054ef2083e65c93955ac3cfcdb360651012 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 13 Jul 2024 08:43:24 -0700 Subject: [PATCH 13/17] Rename `stream_name` to just `stream` --- src/protean/adapters/event_store/memory.py | 44 +++++++++---------- .../adapters/event_store/message_db.py | 14 +++--- src/protean/container.py | 12 ++--- src/protean/core/command.py | 6 +-- src/protean/core/entity.py | 10 ++--- src/protean/core/event.py | 12 ++--- src/protean/domain/__init__.py | 12 +++-- src/protean/port/event_store.py | 22 +++++----- src/protean/server/engine.py | 4 +- src/protean/server/subscription.py | 14 +++--- src/protean/utils/mixins.py | 6 +-- tests/adapters/broker/redis_broker/tests.py | 2 +- .../events/test_aggregate_event_streams.py | 2 +- tests/command/test_command_meta.py | 4 +- tests/command/test_command_metadata.py | 4 +- .../test_inline_command_processing.py | 2 +- tests/event/test_event_metadata.py | 4 +- tests/event/test_event_payload.py | 4 +- tests/event/test_raising_events.py | 2 +- tests/event/tests.py | 4 +- ...t_raising_events_from_within_aggregates.py | 4 +- tests/event_store/test_appending_commands.py | 2 +- tests/event_store/test_appending_events.py | 2 +- tests/event_store/test_reading_messages.py | 6 +-- tests/message/test_object_to_message.py | 15 +++---- .../test_origin_stream_name_in_metadata.py | 24 +++++----- .../server/test_event_handler_subscription.py | 4 +- ...st_message_filtering_with_origin_stream.py | 2 +- .../subscription/test_no_message_filtering.py | 2 +- tests/test_brokers.py | 6 +-- 30 files changed, 122 insertions(+), 129 deletions(-) diff --git a/src/protean/adapters/event_store/memory.py b/src/protean/adapters/event_store/memory.py index e1f7d696..384a846e 100644 --- a/src/protean/adapters/event_store/memory.py +++ b/src/protean/adapters/event_store/memory.py @@ -13,42 +13,40 @@ class MemoryMessage(BaseAggregate, MessageRecord): class MemoryMessageRepository(BaseRepository): - def is_category(self, stream_name: str) -> bool: - if not stream_name: + def is_category(self, stream: str) -> bool: + if not stream: return False - return "-" not in stream_name + return "-" not in stream - def stream_version(self, stream_name: str): + def stream_version(self, stream: str): repo = current_domain.repository_for(MemoryMessage) - results = ( - repo._dao.query.filter(stream_name=stream_name).order_by("-position").all() - ) + results = repo._dao.query.filter(stream=stream).order_by("-position").all() return results.first.position if results.items else -1 def write( self, - stream_name: str, + stream: str, message_type: str, data: Dict, metadata: Dict = None, expected_version: int = None, ) -> int: # Fetch stream version - _stream_version = self.stream_version(stream_name) + _stream_version = self.stream_version(stream) if expected_version is not None and expected_version != _stream_version: raise ValueError( f"Wrong expected version: {expected_version} " - f"(Stream: {stream_name}, Stream Version: {_stream_version})" + f"(Stream: {stream}, Stream Version: {_stream_version})" ) next_position = _stream_version + 1 self.add( MemoryMessage( - stream_name=stream_name, + stream=stream, position=next_position, type=message_type, data=data, @@ -61,7 +59,7 @@ def write( def read( self, - stream_name: str, + stream: str, sql: str = None, position: int = 0, no_of_messages: int = 1000, @@ -73,16 +71,16 @@ def read( .limit(no_of_messages) ) - if stream_name == "$all": + if stream == "$all": pass # Don't filter on stream name or category - elif self.is_category(stream_name): + elif self.is_category(stream): # If filtering on category, ensure the supplied stream name # is the only thing in the category. - # Eg. If stream_name is 'user', then only 'user' should be in the category, + # Eg. If stream is 'user', then only 'user' should be in the category, # and not even `user:command` - q = q.filter(stream_name__contains=f"{stream_name}-") + q = q.filter(stream__contains=f"{stream}-") else: - q = q.filter(stream_name=stream_name) + q = q.filter(stream=stream) items = q.all().items return [item.to_dict() for item in items] @@ -98,29 +96,29 @@ def __init__(self, domain, conn_info) -> None: def _write( self, - stream_name: str, + stream: str, message_type: str, data: Dict, metadata: Dict = None, expected_version: int = None, ) -> int: repo = self.domain.repository_for(MemoryMessage) - return repo.write(stream_name, message_type, data, metadata, expected_version) + return repo.write(stream, message_type, data, metadata, expected_version) def _read( self, - stream_name: str, + stream: str, sql: str = None, position: int = 0, no_of_messages: int = 1000, ) -> List[Dict[str, Any]]: repo = self.domain.repository_for(MemoryMessage) - return repo.read(stream_name, sql, position, no_of_messages) + return repo.read(stream, sql, position, no_of_messages) - def _read_last_message(self, stream_name) -> Dict[str, Any]: + def _read_last_message(self, stream) -> Dict[str, Any]: repo = self.domain.repository_for(MemoryMessage) - messages = repo.read(stream_name) + messages = repo.read(stream) return messages[-1] if messages else None def _data_reset(self) -> None: diff --git a/src/protean/adapters/event_store/message_db.py b/src/protean/adapters/event_store/message_db.py index 84291e48..edadebf9 100644 --- a/src/protean/adapters/event_store/message_db.py +++ b/src/protean/adapters/event_store/message_db.py @@ -29,32 +29,30 @@ def client(self): def _write( self, - stream_name: str, + stream: str, message_type: str, data: Dict, metadata: Dict | None = None, expected_version: int | None = None, ) -> int: """Write a message to the event store.""" - return self.client.write( - stream_name, message_type, data, metadata, expected_version - ) + return self.client.write(stream, message_type, data, metadata, expected_version) def _read( self, - stream_name: str, + stream: str, sql: str | None = None, position: int = 0, no_of_messages: int = 1000, ) -> List[Dict[str, Any]]: """Read messages from the event store.""" return self.client.read( - stream_name, position=position, no_of_messages=no_of_messages + stream, position=position, no_of_messages=no_of_messages ) - def _read_last_message(self, stream_name) -> Dict[str, Any]: + def _read_last_message(self, stream) -> Dict[str, Any]: """Read the last message from the event store.""" - return self.client.read_last_message(stream_name) + return self.client.read_last_message(stream) def _data_reset(self): """Utility function to empty messages, to be used only by test harness. diff --git a/src/protean/container.py b/src/protean/container.py index 13e3e7be..92c8b932 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -403,22 +403,22 @@ def raise_(self, event, fact_event=False) -> None: identifier = getattr(self, id_field(self).field_name) - # Set Fact Event stream to be `-fact` + # Set Fact Event stream to be `-fact` if event.__class__.__name__.endswith("FactEvent"): - stream_name = f"{self.meta_.stream_category}-fact-{identifier}" + stream = f"{self.meta_.stream_category}-fact-{identifier}" else: - stream_name = f"{self.meta_.stream_category}-{identifier}" + stream = f"{self.meta_.stream_category}-{identifier}" event_with_metadata = event.__class__( event.to_dict(), _expected_version=self._event_position, _metadata={ - "id": (f"{stream_name}-{self._version}"), + "id": (f"{stream}-{self._version}"), "type": event._metadata.type, "fqn": event._metadata.fqn, "kind": event._metadata.kind, - "stream_name": stream_name, - "origin_stream_name": event._metadata.origin_stream_name, + "stream": stream, + "origin_stream": event._metadata.origin_stream, "timestamp": event._metadata.timestamp, "version": event._metadata.version, "sequence_id": self._version, diff --git a/src/protean/core/command.py b/src/protean/core/command.py index ee0a77d9..dba36a21 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -49,17 +49,17 @@ def __init__(self, *args, **kwargs): else "v1" ) - origin_stream_name = None + origin_stream = None if hasattr(g, "message_in_context"): if g.message_in_context.metadata.kind == "EVENT": - origin_stream_name = g.message_in_context.stream_name + origin_stream = g.message_in_context.stream # Value Objects are immutable, so we create a clone/copy and associate it self._metadata = Metadata( self._metadata.to_dict(), # Template kind="COMMAND", fqn=fqn(self.__class__), - origin_stream_name=origin_stream_name, + origin_stream=origin_stream, version=version, ) diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index a6a77613..5964000c 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -459,20 +459,20 @@ def raise_(self, event) -> None: # Set Fact Event stream to be `-fact` if event.__class__.__name__.endswith("FactEvent"): - stream_name = f"{self._root.meta_.stream_category}-fact-{identifier}" + stream = f"{self._root.meta_.stream_category}-fact-{identifier}" else: - stream_name = f"{self._root.meta_.stream_category}-{identifier}" + stream = f"{self._root.meta_.stream_category}-{identifier}" event_with_metadata = event.__class__( event.to_dict(), _expected_version=self._root._event_position, _metadata={ - "id": (f"{stream_name}-{aggregate_version}.{event_number}"), + "id": (f"{stream}-{aggregate_version}.{event_number}"), "type": event._metadata.type, "fqn": event._metadata.fqn, "kind": event._metadata.kind, - "stream_name": stream_name, - "origin_stream_name": event._metadata.origin_stream_name, + "stream": stream, + "origin_stream": event._metadata.origin_stream, "timestamp": event._metadata.timestamp, "version": event._metadata.version, "sequence_id": f"{aggregate_version}.{event_number}", diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 7dd775b3..898276cf 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -37,10 +37,10 @@ class Metadata(BaseValueObject): kind = String() # Name of the stream to which the event/command is written - stream_name = String() + stream = String() # Name of the stream that originated this event/command - origin_stream_name = String() + origin_stream = String() # Time of event generation timestamp = DateTime(default=lambda: datetime.now(timezone.utc)) @@ -140,13 +140,13 @@ def __init__(self, *args, **kwargs): # Store the expected version temporarily for use during persistence self._expected_version = kwargs.pop("_expected_version", -1) - origin_stream_name = None + origin_stream = None if hasattr(g, "message_in_context"): if ( g.message_in_context.metadata.kind == "COMMAND" - and g.message_in_context.metadata.origin_stream_name is not None + and g.message_in_context.metadata.origin_stream is not None ): - origin_stream_name = g.message_in_context.metadata.origin_stream_name + origin_stream = g.message_in_context.metadata.origin_stream # Value Objects are immutable, so we create a clone/copy and associate it self._metadata = Metadata( @@ -154,7 +154,7 @@ def __init__(self, *args, **kwargs): type=self.__class__.__type__, kind="EVENT", fqn=fqn(self.__class__), - origin_stream_name=origin_stream_name, + origin_stream=origin_stream, version=self.__class__.__version__, # Was set in `__init_subclass__` ) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index d16c2dab..a1e7ff01 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -1073,14 +1073,12 @@ def _enrich_command(self, command: BaseCommand) -> BaseCommand: else: identifier = str(uuid4()) - stream_name = ( - f"{command.meta_.part_of.meta_.stream_category}:command-{identifier}" - ) + stream = f"{command.meta_.part_of.meta_.stream_category}:command-{identifier}" - origin_stream_name = None + origin_stream = None if hasattr(g, "message_in_context"): if g.message_in_context.metadata.kind == "EVENT": - origin_stream_name = g.message_in_context.stream_name + origin_stream = g.message_in_context.stream command_with_metadata = command.__class__( command.to_dict(), @@ -1089,8 +1087,8 @@ def _enrich_command(self, command: BaseCommand) -> BaseCommand: "type": command.__class__.__type__, "fqn": command._metadata.fqn, "kind": "EVENT", - "stream_name": stream_name, - "origin_stream_name": origin_stream_name, + "stream": stream, + "origin_stream": origin_stream, "timestamp": command._metadata.timestamp, "version": command._metadata.version, "sequence_id": None, diff --git a/src/protean/port/event_store.py b/src/protean/port/event_store.py index 09091d65..5d989621 100644 --- a/src/protean/port/event_store.py +++ b/src/protean/port/event_store.py @@ -25,7 +25,7 @@ def __init__( @abstractmethod def _write( self, - stream_name: str, + stream: str, message_type: str, data: Dict, metadata: Dict | None = None, @@ -41,7 +41,7 @@ def _write( @abstractmethod def _read( self, - stream_name: str, + stream_nae: str, sql: str | None = None, position: int = 0, no_of_messages: int = 1000, @@ -52,28 +52,28 @@ def _read( """ @abstractmethod - def _read_last_message(self, stream_name) -> Dict[str, Any]: + def _read_last_message(self, stream) -> Dict[str, Any]: """Read the last message from the event store. Implemented by the concrete event store adapter. """ - def category(self, stream_name: str) -> str: - if not stream_name: + def category(self, stream: str) -> str: + if not stream: return "" - stream_category, _, _ = stream_name.partition("-") + stream_category, _, _ = stream.partition("-") return stream_category def read( self, - stream_name: str, + stream: str, sql: str | None = None, position: int = 0, no_of_messages: int = 1000, ): raw_messages = self._read( - stream_name, sql=sql, position=position, no_of_messages=no_of_messages + stream, sql=sql, position=position, no_of_messages=no_of_messages ) messages = [] @@ -82,9 +82,9 @@ def read( return messages - def read_last_message(self, stream_name) -> Message: + def read_last_message(self, stream) -> Message: # FIXME Rename to read_last_stream_message - raw_message = self._read_last_message(stream_name) + raw_message = self._read_last_message(stream) if raw_message: return Message.from_dict(raw_message) @@ -94,7 +94,7 @@ def append(self, object: Union[BaseEvent, BaseCommand]) -> int: message = Message.to_message(object) position = self._write( - message.stream_name, + message.stream, message.type, message.data, metadata=message.metadata.to_dict(), diff --git a/src/protean/server/engine.py b/src/protean/server/engine.py index 8298cf22..0223131d 100644 --- a/src/protean/server/engine.py +++ b/src/protean/server/engine.py @@ -60,7 +60,7 @@ def __init__(self, domain, test_mode: bool = False, debug: bool = False) -> None record.cls.meta_.stream_category or record.cls.meta_.part_of.meta_.stream_category, record.cls, - origin_stream_name=record.cls.meta_.source_stream, + origin_stream=record.cls.meta_.source_stream, ) for handler_name, record in self.domain.registry.command_handlers.items(): @@ -107,7 +107,7 @@ async def handle_message( ) except Exception as exc: # Includes handling `ConfigurationError` logger.error( - f"Error handling message {message.stream_name}-{message.id} " + f"Error handling message {message.stream}-{message.id} " f"in {handler_cls.__name__}" ) logger.error(f"{str(exc)}") diff --git a/src/protean/server/subscription.py b/src/protean/server/subscription.py index f0efb509..f8d4bb70 100644 --- a/src/protean/server/subscription.py +++ b/src/protean/server/subscription.py @@ -31,7 +31,7 @@ def __init__( handler: Union[BaseEventHandler, BaseCommandHandler], messages_per_tick: int = 10, position_update_interval: int = 10, - origin_stream_name: str | None = None, + origin_stream: str | None = None, tick_interval: int = 1, ) -> None: """ @@ -44,7 +44,7 @@ def __init__( handler (Union[BaseEventHandler, BaseCommandHandler]): The event or command handler. messages_per_tick (int, optional): The number of messages to process per tick. Defaults to 10. position_update_interval (int, optional): The interval at which to update the current position. Defaults to 10. - origin_stream_name (str | None, optional): The name of the origin stream to filter messages. Defaults to None. + origin_stream (str | None, optional): The name of the origin stream to filter messages. Defaults to None. tick_interval (int, optional): The interval between ticks. Defaults to 1. """ self.engine = engine @@ -57,7 +57,7 @@ def __init__( self.handler = handler self.messages_per_tick = messages_per_tick self.position_update_interval = position_update_interval - self.origin_stream_name = origin_stream_name + self.origin_stream = origin_stream self.tick_interval = tick_interval self.subscriber_stream_name = f"position-${subscriber_id}" @@ -218,7 +218,7 @@ def write_position(self, position: int) -> int: {"position": position}, metadata={ "kind": MessageType.READ_POSITION.value, - "origin_stream_name": self.stream_category, + "origin_stream": self.stream_category, }, ) @@ -232,17 +232,17 @@ def filter_on_origin(self, messages: List[Message]) -> List[Message]: Returns: List[Message]: The filtered list of messages. """ - if not self.origin_stream_name: + if not self.origin_stream: return messages filtered_messages = [] for message in messages: origin_stream = message.metadata and self.store.category( - message.metadata.origin_stream_name + message.metadata.origin_stream ) - if self.origin_stream_name == origin_stream: + if self.origin_stream == origin_stream: filtered_messages.append(message) logger.debug(f"Filtered {len(filtered_messages)} out of {len(messages)}") diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index ae0dbfd9..80f67804 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -43,7 +43,7 @@ class MessageRecord(BaseContainer): id = fields.Auto() # Name of stream to which the message is written - stream_name = fields.String(max_length=255) + stream = fields.String(max_length=255) # The type of the message type = fields.String() @@ -69,7 +69,7 @@ class Message(MessageRecord, OptionsMixin): # FIXME Remove OptionsMixin @classmethod def from_dict(cls, message: Dict) -> Message: return Message( - stream_name=message["stream_name"], + stream=message["stream"], type=message["type"], data=message["data"], metadata=message["metadata"], @@ -110,7 +110,7 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: expected_version = message_object._expected_version return cls( - stream_name=message_object._metadata.stream_name, + stream=message_object._metadata.stream, type=message_object.__class__.__type__, data=message_object.payload, metadata=message_object._metadata, diff --git a/tests/adapters/broker/redis_broker/tests.py b/tests/adapters/broker/redis_broker/tests.py index 9a5e6b7e..1681e2d0 100644 --- a/tests/adapters/broker/redis_broker/tests.py +++ b/tests/adapters/broker/redis_broker/tests.py @@ -54,7 +54,7 @@ def test_event_message_structure(self, test_domain): "position", "time", "id", - "stream_name", + "stream", "type", "data", "metadata", diff --git a/tests/aggregate/events/test_aggregate_event_streams.py b/tests/aggregate/events/test_aggregate_event_streams.py index cbaecd59..69b243ea 100644 --- a/tests/aggregate/events/test_aggregate_event_streams.py +++ b/tests/aggregate/events/test_aggregate_event_streams.py @@ -77,7 +77,7 @@ def test_event_stream_name_in_message(self): message = Message.to_message(user._events[0]) - assert message.stream_name == f"user-{user.id}" + assert message.stream == f"user-{user.id}" def test_event_metadata_from_stream(self, test_domain): user = User(name="John Doe", email="john.doe@example.com") diff --git a/tests/command/test_command_meta.py b/tests/command/test_command_meta.py index b252ff46..d6e4ee87 100644 --- a/tests/command/test_command_meta.py +++ b/tests/command/test_command_meta.py @@ -63,7 +63,7 @@ def test_command_associated_with_aggregate(test_domain): messages = test_domain.event_store.store.read("user:command") assert len(messages) == 1 - messages[0].stream_name == f"user:command-{identifier}" + messages[0].stream == f"user:command-{identifier}" @pytest.mark.eventstore @@ -84,7 +84,7 @@ def test_command_associated_with_aggregate_with_custom_stream_name(test_domain): messages = test_domain.event_store.store.read("foo:command") assert len(messages) == 1 - messages[0].stream_name == f"foo:command-{identifier}" + messages[0].stream == f"foo:command-{identifier}" def test_aggregate_cluster_of_event(test_domain): diff --git a/tests/command/test_command_metadata.py b/tests/command/test_command_metadata.py index 60783b6c..8ed0217d 100644 --- a/tests/command/test_command_metadata.py +++ b/tests/command/test_command_metadata.py @@ -71,8 +71,8 @@ def test_command_metadata(test_domain): "type": "Test.Login.v1", "fqn": fqn(Login), "kind": "COMMAND", - "stream_name": f"user:command-{identifier}", - "origin_stream_name": None, + "stream": f"user:command-{identifier}", + "origin_stream": None, "timestamp": str(command._metadata.timestamp), "version": "v1", "sequence_id": None, diff --git a/tests/command_handler/test_inline_command_processing.py b/tests/command_handler/test_inline_command_processing.py index f7da4f4e..76ac90bc 100644 --- a/tests/command_handler/test_inline_command_processing.py +++ b/tests/command_handler/test_inline_command_processing.py @@ -65,4 +65,4 @@ def test_that_command_is_persisted_in_message_store(test_domain): messages = test_domain.event_store.store.read("user:command") assert len(messages) == 1 - messages[0].stream_name == f"user:command-{identifier}" + messages[0].stream == f"user:command-{identifier}" diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index 36975a57..bccfef8e 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -106,8 +106,8 @@ def test_event_metadata(): "type": "Test.UserLoggedIn.v1", "fqn": fqn(UserLoggedIn), "kind": "EVENT", - "stream_name": f"user-{user.id}", - "origin_stream_name": None, + "stream": f"user-{user.id}", + "origin_stream": None, "timestamp": str(event._metadata.timestamp), "version": "v1", "sequence_id": "0", diff --git a/tests/event/test_event_payload.py b/tests/event/test_event_payload.py index 2bf83ba0..79082fb2 100644 --- a/tests/event/test_event_payload.py +++ b/tests/event/test_event_payload.py @@ -41,8 +41,8 @@ def test_event_payload(): "type": "Test.UserLoggedIn.v1", "fqn": fqn(UserLoggedIn), "kind": "EVENT", - "stream_name": f"user-{user_id}", - "origin_stream_name": None, + "stream": f"user-{user_id}", + "origin_stream": None, "timestamp": str(event._metadata.timestamp), "version": "v1", "sequence_id": "0", diff --git a/tests/event/test_raising_events.py b/tests/event/test_raising_events.py index c2b5f895..beaec0df 100644 --- a/tests/event/test_raising_events.py +++ b/tests/event/test_raising_events.py @@ -32,4 +32,4 @@ def test_raising_event(test_domain): messages = test_domain.event_store.store.read("authentication") assert len(messages) == 1 - assert messages[0].stream_name == f"authentication-{identifier}" + assert messages[0].stream == f"authentication-{identifier}" diff --git a/tests/event/tests.py b/tests/event/tests.py index 335e8c9a..ac4ab602 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -61,8 +61,8 @@ class UserAdded(BaseEvent): "type": "Test.UserAdded.v1", "fqn": fully_qualified_name(UserAdded), "kind": "EVENT", - "stream_name": None, # Type is none here because of the same reason as above - "origin_stream_name": None, + "stream": None, # Stream is none here because of the same reason as above + "origin_stream": None, "timestamp": str(event._metadata.timestamp), "version": "v1", "sequence_id": None, # Sequence is unknown as event is not being raised as part of an aggregate diff --git a/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py b/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py index 72864a13..71b1f57c 100644 --- a/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py +++ b/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py @@ -86,11 +86,11 @@ def test_that_events_can_be_raised_from_within_aggregates(test_domain): messages = test_domain.event_store.store._read("user") assert len(messages) == 1 - assert messages[0]["stream_name"] == f"user-{identifier}" + assert messages[0]["stream"] == f"user-{identifier}" assert messages[0]["type"] == Registered.__type__ messages = test_domain.event_store.store._read("user:command") assert len(messages) == 1 - assert messages[0]["stream_name"] == f"user:command-{identifier}" + assert messages[0]["stream"] == f"user:command-{identifier}" assert messages[0]["type"] == Register.__type__ diff --git a/tests/event_store/test_appending_commands.py b/tests/event_store/test_appending_commands.py index c31092a9..cbb06a89 100644 --- a/tests/event_store/test_appending_commands.py +++ b/tests/event_store/test_appending_commands.py @@ -52,4 +52,4 @@ def test_command_submission(test_domain): messages = test_domain.event_store.store.read("user:command") assert len(messages) == 1 - messages[0].stream_name == f"user:command-{identifier}" + messages[0].stream == f"user:command-{identifier}" diff --git a/tests/event_store/test_appending_events.py b/tests/event_store/test_appending_events.py index d606fb8d..9c04278d 100644 --- a/tests/event_store/test_appending_events.py +++ b/tests/event_store/test_appending_events.py @@ -36,7 +36,7 @@ def test_appending_raw_events(test_domain): message = messages[0] assert isinstance(message, Message) - assert message.stream_name == f"authentication-{identifier}" + assert message.stream == f"authentication-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == event.payload assert message.metadata == event._metadata diff --git a/tests/event_store/test_reading_messages.py b/tests/event_store/test_reading_messages.py index 2d190742..16302318 100644 --- a/tests/event_store/test_reading_messages.py +++ b/tests/event_store/test_reading_messages.py @@ -73,7 +73,7 @@ def test_reading_a_message(test_domain, registered_user): message = messages[0] assert isinstance(message, Message) - assert message.stream_name == f"user-{registered_user.id}" + assert message.stream == f"user-{registered_user.id}" assert message.metadata.kind == "EVENT" assert message.data == registered_user._events[-1].payload assert message.metadata == registered_user._events[-1]._metadata @@ -85,7 +85,7 @@ def test_reading_many_messages(test_domain, activated_user): assert len(messages) == 2 - assert messages[0].stream_name == f"user-{activated_user.id}" + assert messages[0].stream == f"user-{activated_user.id}" assert messages[0].metadata.kind == "EVENT" assert messages[0].data == activated_user._events[0].payload assert messages[0].metadata == activated_user._events[0]._metadata @@ -128,7 +128,7 @@ def test_reading_messages_by_category(test_domain, activated_user): assert len(messages) == 2 - assert messages[0].stream_name == f"user-{activated_user.id}" + assert messages[0].stream == f"user-{activated_user.id}" assert messages[0].metadata.kind == "EVENT" assert messages[0].data == activated_user._events[0].payload assert messages[0].metadata == activated_user._events[0]._metadata diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index 50ce865d..1bf190e6 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -64,7 +64,7 @@ def test_construct_message_from_event(test_domain): # Verify Message Content assert message.type == Registered.__type__ - assert message.stream_name == f"{User.meta_.stream_category}-{identifier}" + assert message.stream == f"{User.meta_.stream_category}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == user._events[-1].payload assert message.time is None @@ -75,7 +75,7 @@ def test_construct_message_from_event(test_domain): assert message_dict["type"] == Registered.__type__ assert message_dict["metadata"]["kind"] == "EVENT" - assert message_dict["stream_name"] == f"{User.meta_.stream_category}-{identifier}" + assert message_dict["stream"] == f"{User.meta_.stream_category}-{identifier}" assert message_dict["data"] == user._events[-1].payload assert message_dict["time"] is None assert ( @@ -97,7 +97,7 @@ def test_construct_message_from_command(test_domain): # Verify Message Content assert message.type == Register.__type__ - assert message.stream_name == f"{User.meta_.stream_category}:command-{identifier}" + assert message.stream == f"{User.meta_.stream_category}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command_with_metadata.payload assert message.time is not None @@ -107,8 +107,7 @@ def test_construct_message_from_command(test_domain): assert message_dict["type"] == Register.__type__ assert message_dict["metadata"]["kind"] == "COMMAND" assert ( - message_dict["stream_name"] - == f"{User.meta_.stream_category}:command-{identifier}" + message_dict["stream"] == f"{User.meta_.stream_category}:command-{identifier}" ) assert message_dict["data"] == command_with_metadata.payload assert message_dict["time"] is not None @@ -127,7 +126,7 @@ def test_construct_message_from_command_without_identifier(test_domain): assert type(message) is Message message_dict = message.to_dict() - identifier = message_dict["stream_name"].split( + identifier = message_dict["stream"].split( f"{SendEmail.meta_.stream_category}:command-", 1 )[1] @@ -149,7 +148,7 @@ def test_construct_message_from_either_event_or_command(test_domain): # Verify Message Content assert message.type == Register.__type__ - assert message.stream_name == f"{User.meta_.stream_category}:command-{identifier}" + assert message.stream == f"{User.meta_.stream_category}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command.payload @@ -165,7 +164,7 @@ def test_construct_message_from_either_event_or_command(test_domain): # Verify Message Content assert message.type == Registered.__type__ - assert message.stream_name == f"{User.meta_.stream_category}-{identifier}" + assert message.stream == f"{User.meta_.stream_category}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == event.payload assert message.time is None diff --git a/tests/message/test_origin_stream_name_in_metadata.py b/tests/message/test_origin_stream_name_in_metadata.py index a20f882c..49b5fc2e 100644 --- a/tests/message/test_origin_stream_name_in_metadata.py +++ b/tests/message/test_origin_stream_name_in_metadata.py @@ -66,7 +66,7 @@ def registered_event_message(user_id): return Message.to_message(user._events[0]) -def test_origin_stream_name_in_event_from_command_without_origin_stream_name( +def test_origin_stream_in_event_from_command_without_origin_stream( user_id, register_command_message ): g.message_in_context = register_command_message @@ -80,16 +80,16 @@ def test_origin_stream_name_in_event_from_command_without_origin_stream_name( ) ) event_message = Message.to_message(user._events[-1]) - assert event_message.metadata.origin_stream_name is None + assert event_message.metadata.origin_stream is None -def test_origin_stream_name_in_event_from_command_with_origin_stream_name( +def test_origin_stream_in_event_from_command_with_origin_stream( user_id, register_command_message ): command_message = register_command_message command_message.metadata = Metadata( - command_message.metadata.to_dict(), origin_stream_name="foo" + command_message.metadata.to_dict(), origin_stream="foo" ) # Metadata is a VO and immutable, so creating a copy with updated value g.message_in_context = command_message @@ -103,10 +103,10 @@ def test_origin_stream_name_in_event_from_command_with_origin_stream_name( ) event_message = Message.to_message(user._events[-1]) - assert event_message.metadata.origin_stream_name == "foo" + assert event_message.metadata.origin_stream == "foo" -def test_origin_stream_name_in_aggregate_event_from_command_without_origin_stream_name( +def test_origin_stream_in_aggregate_event_from_command_without_origin_stream( user_id, register_command_message ): g.message_in_context = register_command_message @@ -124,16 +124,16 @@ def test_origin_stream_name_in_aggregate_event_from_command_without_origin_strea ) event_message = Message.to_message(user._events[-1]) - assert event_message.metadata.origin_stream_name is None + assert event_message.metadata.origin_stream is None -def test_origin_stream_name_in_aggregate_event_from_command_with_origin_stream_name( +def test_origin_stream_in_aggregate_event_from_command_with_origin_stream( user_id, register_command_message ): command_message = register_command_message command_message.metadata = Metadata( - command_message.metadata.to_dict(), origin_stream_name="foo" + command_message.metadata.to_dict(), origin_stream="foo" ) # Metadata is a VO and immutable, so creating a copy with updated value g.message_in_context = command_message @@ -151,10 +151,10 @@ def test_origin_stream_name_in_aggregate_event_from_command_with_origin_stream_n ) event_message = Message.to_message(user._events[-1]) - assert event_message.metadata.origin_stream_name == "foo" + assert event_message.metadata.origin_stream == "foo" -def test_origin_stream_name_in_command_from_event( +def test_origin_stream_in_command_from_event( user_id, test_domain, registered_event_message ): g.message_in_context = registered_event_message @@ -167,4 +167,4 @@ def test_origin_stream_name_in_command_from_event( enriched_command = test_domain._enrich_command(command) command_message = Message.to_message(enriched_command) - assert command_message.metadata.origin_stream_name == f"user-{user_id}" + assert command_message.metadata.origin_stream == f"user-{user_id}" diff --git a/tests/server/test_event_handler_subscription.py b/tests/server/test_event_handler_subscription.py index bb9b40c3..be70cc97 100644 --- a/tests/server/test_event_handler_subscription.py +++ b/tests/server/test_event_handler_subscription.py @@ -92,7 +92,7 @@ def test_origin_stream_category_in_subscription(test_domain): assert len(engine._subscriptions) == 1 assert engine._subscriptions[fqn(EmailEventHandler)].stream_category == "user" - assert engine._subscriptions[fqn(EmailEventHandler)].origin_stream_name == "email" + assert engine._subscriptions[fqn(EmailEventHandler)].origin_stream == "email" def test_that_stream_name_overrides_the_derived_stream_name_from_owning_aggregate( @@ -109,4 +109,4 @@ def test_that_stream_name_overrides_the_derived_stream_name_from_owning_aggregat assert len(engine._subscriptions) == 1 assert engine._subscriptions[fqn(EmailEventHandler)].stream_category == "identity" - assert engine._subscriptions[fqn(EmailEventHandler)].origin_stream_name == "email" + assert engine._subscriptions[fqn(EmailEventHandler)].origin_stream == "email" diff --git a/tests/subscription/test_message_filtering_with_origin_stream.py b/tests/subscription/test_message_filtering_with_origin_stream.py index 2ba5dc60..0a606d16 100644 --- a/tests/subscription/test_message_filtering_with_origin_stream.py +++ b/tests/subscription/test_message_filtering_with_origin_stream.py @@ -103,7 +103,7 @@ async def test_message_filtering_for_event_handlers_with_defined_origin_stream( ] messages[2].metadata = Metadata( - messages[2].metadata.to_dict(), origin_stream_name=f"user-{identifier}" + messages[2].metadata.to_dict(), origin_stream=f"user-{identifier}" ) # Metadata is a VO and immutable, so creating a copy with updated value # Mock `read` method and have it return the 3 messages diff --git a/tests/subscription/test_no_message_filtering.py b/tests/subscription/test_no_message_filtering.py index b5e94c27..b85f74c2 100644 --- a/tests/subscription/test_no_message_filtering.py +++ b/tests/subscription/test_no_message_filtering.py @@ -100,7 +100,7 @@ async def test_no_filtering_for_event_handlers_without_defined_origin_stream( ] messages[2].metadata = Metadata( - messages[2].metadata.to_dict(), origin_stream_name=f"user-{identifier}" + messages[2].metadata.to_dict(), origin_stream=f"user-{identifier}" ) # Metadata is a VO and immutable, so creating a copy with updated value # Mock `read` method and have it return the 3 messages diff --git a/tests/test_brokers.py b/tests/test_brokers.py index 41d4d441..e17b5ecc 100644 --- a/tests/test_brokers.py +++ b/tests/test_brokers.py @@ -117,7 +117,7 @@ def test_that_event_is_persisted_on_publish(self, mocker, test_domain): messages = test_domain.event_store.store.read("person") assert len(messages) == 1 - messages[0].stream_name == "person-1234" + messages[0].stream == "person-1234" @pytest.mark.eventstore def test_that_multiple_events_are_persisted_on_publish(self, mocker, test_domain): @@ -149,8 +149,8 @@ def test_that_multiple_events_are_persisted_on_publish(self, mocker, test_domain messages = test_domain.event_store.store.read("person") assert len(messages) == 2 - assert messages[0].stream_name == "person-1234" - assert messages[1].stream_name == "person-1235" + assert messages[0].stream == "person-1234" + assert messages[1].stream == "person-1235" class TestBrokerSubscriberInitialization: From f15c91c091dccf4c749934ffe349c2c075beb143 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 13 Jul 2024 09:45:46 -0700 Subject: [PATCH 14/17] Add domain name to stream name: `domain_name::aggregate_name` Also, retain `stream_name` as the field in Message and Event store implementations. We will change this when we push message construction and processing to each event store's implementation. --- src/protean/adapters/event_store/memory.py | 42 ++++++++++--------- .../adapters/event_store/message_db.py | 14 ++++--- src/protean/core/aggregate.py | 5 +++ src/protean/core/command.py | 2 +- src/protean/domain/__init__.py | 2 +- src/protean/port/event_store.py | 2 +- src/protean/server/engine.py | 2 +- src/protean/utils/mixins.py | 6 +-- tests/adapters/broker/redis_broker/tests.py | 2 +- .../events/test_aggregate_event_streams.py | 14 +++---- ..._event_regular_metadata_id_and_sequence.py | 26 ++++++++---- .../events/test_raising_fact_events.py | 4 +- tests/command/test_command_meta.py | 4 +- tests/command/test_command_metadata.py | 2 +- .../test_inline_command_processing.py | 2 +- tests/event/test_raising_events.py | 2 +- tests/event/test_stream_name_derivation.py | 4 +- .../test_event_handler_options.py | 12 +++--- ...t_raising_events_from_within_aggregates.py | 4 +- tests/event_store/test_appending_commands.py | 2 +- tests/event_store/test_appending_events.py | 2 +- tests/event_store/test_reading_messages.py | 6 +-- tests/message/test_object_to_message.py | 15 +++---- tests/test_brokers.py | 10 ++--- 24 files changed, 104 insertions(+), 82 deletions(-) diff --git a/src/protean/adapters/event_store/memory.py b/src/protean/adapters/event_store/memory.py index 384a846e..5d033eed 100644 --- a/src/protean/adapters/event_store/memory.py +++ b/src/protean/adapters/event_store/memory.py @@ -13,40 +13,42 @@ class MemoryMessage(BaseAggregate, MessageRecord): class MemoryMessageRepository(BaseRepository): - def is_category(self, stream: str) -> bool: - if not stream: + def is_category(self, stream_name: str) -> bool: + if not stream_name: return False - return "-" not in stream + return "-" not in stream_name - def stream_version(self, stream: str): + def stream_version(self, stream_name: str): repo = current_domain.repository_for(MemoryMessage) - results = repo._dao.query.filter(stream=stream).order_by("-position").all() + results = ( + repo._dao.query.filter(stream_name=stream_name).order_by("-position").all() + ) return results.first.position if results.items else -1 def write( self, - stream: str, + stream_name: str, message_type: str, data: Dict, metadata: Dict = None, expected_version: int = None, ) -> int: # Fetch stream version - _stream_version = self.stream_version(stream) + _stream_version = self.stream_version(stream_name) if expected_version is not None and expected_version != _stream_version: raise ValueError( f"Wrong expected version: {expected_version} " - f"(Stream: {stream}, Stream Version: {_stream_version})" + f"(Stream: {stream_name}, Stream Version: {_stream_version})" ) next_position = _stream_version + 1 self.add( MemoryMessage( - stream=stream, + stream_name=stream_name, position=next_position, type=message_type, data=data, @@ -59,7 +61,7 @@ def write( def read( self, - stream: str, + stream_name: str, sql: str = None, position: int = 0, no_of_messages: int = 1000, @@ -71,16 +73,16 @@ def read( .limit(no_of_messages) ) - if stream == "$all": + if stream_name == "$all": pass # Don't filter on stream name or category - elif self.is_category(stream): + elif self.is_category(stream_name): # If filtering on category, ensure the supplied stream name # is the only thing in the category. # Eg. If stream is 'user', then only 'user' should be in the category, # and not even `user:command` - q = q.filter(stream__contains=f"{stream}-") + q = q.filter(stream_name__contains=f"{stream_name}-") else: - q = q.filter(stream=stream) + q = q.filter(stream_name=stream_name) items = q.all().items return [item.to_dict() for item in items] @@ -96,29 +98,29 @@ def __init__(self, domain, conn_info) -> None: def _write( self, - stream: str, + stream_name: str, message_type: str, data: Dict, metadata: Dict = None, expected_version: int = None, ) -> int: repo = self.domain.repository_for(MemoryMessage) - return repo.write(stream, message_type, data, metadata, expected_version) + return repo.write(stream_name, message_type, data, metadata, expected_version) def _read( self, - stream: str, + stream_name: str, sql: str = None, position: int = 0, no_of_messages: int = 1000, ) -> List[Dict[str, Any]]: repo = self.domain.repository_for(MemoryMessage) - return repo.read(stream, sql, position, no_of_messages) + return repo.read(stream_name, sql, position, no_of_messages) - def _read_last_message(self, stream) -> Dict[str, Any]: + def _read_last_message(self, stream_name) -> Dict[str, Any]: repo = self.domain.repository_for(MemoryMessage) - messages = repo.read(stream) + messages = repo.read(stream_name) return messages[-1] if messages else None def _data_reset(self) -> None: diff --git a/src/protean/adapters/event_store/message_db.py b/src/protean/adapters/event_store/message_db.py index edadebf9..84291e48 100644 --- a/src/protean/adapters/event_store/message_db.py +++ b/src/protean/adapters/event_store/message_db.py @@ -29,30 +29,32 @@ def client(self): def _write( self, - stream: str, + stream_name: str, message_type: str, data: Dict, metadata: Dict | None = None, expected_version: int | None = None, ) -> int: """Write a message to the event store.""" - return self.client.write(stream, message_type, data, metadata, expected_version) + return self.client.write( + stream_name, message_type, data, metadata, expected_version + ) def _read( self, - stream: str, + stream_name: str, sql: str | None = None, position: int = 0, no_of_messages: int = 1000, ) -> List[Dict[str, Any]]: """Read messages from the event store.""" return self.client.read( - stream, position=position, no_of_messages=no_of_messages + stream_name, position=position, no_of_messages=no_of_messages ) - def _read_last_message(self, stream) -> Dict[str, Any]: + def _read_last_message(self, stream_name) -> Dict[str, Any]: """Read the last message from the event store.""" - return self.client.read_last_message(stream) + return self.client.read_last_message(stream_name) def _data_reset(self): """Utility function to empty messages, to be used only by test harness. diff --git a/src/protean/core/aggregate.py b/src/protean/core/aggregate.py index f36b6d59..65f83c81 100644 --- a/src/protean/core/aggregate.py +++ b/src/protean/core/aggregate.py @@ -155,6 +155,11 @@ def aggregate_factory(element_cls, domain, **opts): ) and hasattr(method, "_invariant"): element_cls._invariants[method._invariant][method_name] = method + # Set stream name to be `domain_name::aggregate_name` + element_cls.meta_.stream_category = ( + f"{domain.normalized_name}::{element_cls.meta_.stream_category}" + ) + return element_cls diff --git a/src/protean/core/command.py b/src/protean/core/command.py index dba36a21..c8c8b9e0 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -52,7 +52,7 @@ def __init__(self, *args, **kwargs): origin_stream = None if hasattr(g, "message_in_context"): if g.message_in_context.metadata.kind == "EVENT": - origin_stream = g.message_in_context.stream + origin_stream = g.message_in_context.stream_name # Value Objects are immutable, so we create a clone/copy and associate it self._metadata = Metadata( diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index a1e7ff01..3df917b4 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -1078,7 +1078,7 @@ def _enrich_command(self, command: BaseCommand) -> BaseCommand: origin_stream = None if hasattr(g, "message_in_context"): if g.message_in_context.metadata.kind == "EVENT": - origin_stream = g.message_in_context.stream + origin_stream = g.message_in_context.stream_name command_with_metadata = command.__class__( command.to_dict(), diff --git a/src/protean/port/event_store.py b/src/protean/port/event_store.py index 5d989621..586dcc1d 100644 --- a/src/protean/port/event_store.py +++ b/src/protean/port/event_store.py @@ -94,7 +94,7 @@ def append(self, object: Union[BaseEvent, BaseCommand]) -> int: message = Message.to_message(object) position = self._write( - message.stream, + message.stream_name, message.type, message.data, metadata=message.metadata.to_dict(), diff --git a/src/protean/server/engine.py b/src/protean/server/engine.py index 0223131d..aec991f2 100644 --- a/src/protean/server/engine.py +++ b/src/protean/server/engine.py @@ -107,7 +107,7 @@ async def handle_message( ) except Exception as exc: # Includes handling `ConfigurationError` logger.error( - f"Error handling message {message.stream}-{message.id} " + f"Error handling message {message.stream_name}-{message.id} " f"in {handler_cls.__name__}" ) logger.error(f"{str(exc)}") diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 80f67804..2ba501fe 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -43,7 +43,7 @@ class MessageRecord(BaseContainer): id = fields.Auto() # Name of stream to which the message is written - stream = fields.String(max_length=255) + stream_name = fields.String(max_length=255) # The type of the message type = fields.String() @@ -69,7 +69,7 @@ class Message(MessageRecord, OptionsMixin): # FIXME Remove OptionsMixin @classmethod def from_dict(cls, message: Dict) -> Message: return Message( - stream=message["stream"], + stream_name=message["stream_name"], type=message["type"], data=message["data"], metadata=message["metadata"], @@ -110,7 +110,7 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: expected_version = message_object._expected_version return cls( - stream=message_object._metadata.stream, + stream_name=message_object._metadata.stream, type=message_object.__class__.__type__, data=message_object.payload, metadata=message_object._metadata, diff --git a/tests/adapters/broker/redis_broker/tests.py b/tests/adapters/broker/redis_broker/tests.py index 1681e2d0..9a5e6b7e 100644 --- a/tests/adapters/broker/redis_broker/tests.py +++ b/tests/adapters/broker/redis_broker/tests.py @@ -54,7 +54,7 @@ def test_event_message_structure(self, test_domain): "position", "time", "id", - "stream", + "stream_name", "type", "data", "metadata", diff --git a/tests/aggregate/events/test_aggregate_event_streams.py b/tests/aggregate/events/test_aggregate_event_streams.py index 69b243ea..f69e0745 100644 --- a/tests/aggregate/events/test_aggregate_event_streams.py +++ b/tests/aggregate/events/test_aggregate_event_streams.py @@ -53,7 +53,7 @@ def register_elements(test_domain): class TestDeltaEvents: def test_aggregate_stream_name(self): - assert User.meta_.stream_category == "user" + assert User.meta_.stream_category == "test::user" def test_event_metadata(self): user = User(name="John Doe", email="john.doe@example.com") @@ -61,12 +61,12 @@ def test_event_metadata(self): user.activate() assert len(user._events) == 2 - assert user._events[0]._metadata.id == f"user-{user.id}-0.1" + assert user._events[0]._metadata.id == f"test::user-{user.id}-0.1" assert user._events[0]._metadata.type == "Test.UserRenamed.v1" assert user._events[0]._metadata.version == "v1" assert user._events[0]._metadata.sequence_id == "0.1" - assert user._events[1]._metadata.id == f"user-{user.id}-0.2" + assert user._events[1]._metadata.id == f"test::user-{user.id}-0.2" assert user._events[1]._metadata.type == "Test.UserActivated.v1" assert user._events[1]._metadata.version == "v1" assert user._events[1]._metadata.sequence_id == "0.2" @@ -77,7 +77,7 @@ def test_event_stream_name_in_message(self): message = Message.to_message(user._events[0]) - assert message.stream == f"user-{user.id}" + assert message.stream_name == f"test::user-{user.id}" def test_event_metadata_from_stream(self, test_domain): user = User(name="John Doe", email="john.doe@example.com") @@ -86,15 +86,15 @@ def test_event_metadata_from_stream(self, test_domain): test_domain.repository_for(User).add(user) - event_messages = test_domain.event_store.store.read(f"user-{user.id}") + event_messages = test_domain.event_store.store.read(f"test::user-{user.id}") assert len(event_messages) == 2 - assert event_messages[0].metadata.id == f"user-{user.id}-0.1" + assert event_messages[0].metadata.id == f"test::user-{user.id}-0.1" assert event_messages[0].metadata.type == "Test.UserRenamed.v1" assert event_messages[0].metadata.version == "v1" assert event_messages[0].metadata.sequence_id == "0.1" - assert event_messages[1].metadata.id == f"user-{user.id}-0.2" + assert event_messages[1].metadata.id == f"test::user-{user.id}-0.2" assert event_messages[1].metadata.type == "Test.UserActivated.v1" assert event_messages[1].metadata.version == "v1" assert event_messages[1].metadata.sequence_id == "0.2" diff --git a/tests/aggregate/events/test_event_regular_metadata_id_and_sequence.py b/tests/aggregate/events/test_event_regular_metadata_id_and_sequence.py index 4b5453bd..293329ff 100644 --- a/tests/aggregate/events/test_event_regular_metadata_id_and_sequence.py +++ b/tests/aggregate/events/test_event_regular_metadata_id_and_sequence.py @@ -56,7 +56,7 @@ def test_initialization_with_first_event(): user = User(name="John Doe", email="john.doe@example.com") user.activate() - assert user._events[0]._metadata.id == f"user-{user.id}-0.1" + assert user._events[0]._metadata.id == f"test::user-{user.id}-0.1" assert user._events[0]._metadata.sequence_id == "0.1" @@ -65,9 +65,9 @@ def test_initialization_with_multiple_events(): user.activate() user.change_name("Jane Doe") - assert user._events[0]._metadata.id == f"user-{user.id}-0.1" + assert user._events[0]._metadata.id == f"test::user-{user.id}-0.1" assert user._events[0]._metadata.sequence_id == "0.1" - assert user._events[1]._metadata.id == f"user-{user.id}-0.2" + assert user._events[1]._metadata.id == f"test::user-{user.id}-0.2" assert user._events[1]._metadata.sequence_id == "0.2" @@ -79,7 +79,9 @@ def test_one_event_after_persistence(test_domain): refreshed_user = test_domain.repository_for(User).get(user.id) refreshed_user.change_name("Jane Doe") - assert refreshed_user._events[0]._metadata.id == f"user-{refreshed_user.id}-1.1" + assert ( + refreshed_user._events[0]._metadata.id == f"test::user-{refreshed_user.id}-1.1" + ) assert refreshed_user._events[0]._metadata.sequence_id == "1.1" @@ -92,9 +94,13 @@ def test_multiple_events_after_persistence(test_domain): refreshed_user.change_name("Jane Doe") refreshed_user.change_name("Baby Doe") - assert refreshed_user._events[0]._metadata.id == f"user-{refreshed_user.id}-1.1" + assert ( + refreshed_user._events[0]._metadata.id == f"test::user-{refreshed_user.id}-1.1" + ) assert refreshed_user._events[0]._metadata.sequence_id == "1.1" - assert refreshed_user._events[1]._metadata.id == f"user-{refreshed_user.id}-1.2" + assert ( + refreshed_user._events[1]._metadata.id == f"test::user-{refreshed_user.id}-1.2" + ) assert refreshed_user._events[1]._metadata.sequence_id == "1.2" @@ -112,7 +118,11 @@ def test_multiple_events_after_multiple_persistence(test_domain): refreshed_user.change_name("Ark Doe") refreshed_user.change_name("Zing Doe") - assert refreshed_user._events[0]._metadata.id == f"user-{refreshed_user.id}-2.1" + assert ( + refreshed_user._events[0]._metadata.id == f"test::user-{refreshed_user.id}-2.1" + ) assert refreshed_user._events[0]._metadata.sequence_id == "2.1" - assert refreshed_user._events[1]._metadata.id == f"user-{refreshed_user.id}-2.2" + assert ( + refreshed_user._events[1]._metadata.id == f"test::user-{refreshed_user.id}-2.2" + ) assert refreshed_user._events[1]._metadata.sequence_id == "2.2" diff --git a/tests/aggregate/events/test_raising_fact_events.py b/tests/aggregate/events/test_raising_fact_events.py index e8952276..359477de 100644 --- a/tests/aggregate/events/test_raising_fact_events.py +++ b/tests/aggregate/events/test_raising_fact_events.py @@ -23,7 +23,7 @@ def event(test_domain): test_domain.repository_for(User).add(user) # Read event from event store - event_messages = test_domain.event_store.store.read(f"user-fact-{user.id}") + event_messages = test_domain.event_store.store.read(f"test::user-fact-{user.id}") assert len(event_messages) == 1 # Deserialize event @@ -54,7 +54,7 @@ def test_fact_event_version_metadata_after_second_edit(test_domain): test_domain.repository_for(User).add(refreshed_user) # Read event from event store - event_messages = test_domain.event_store.store.read(f"user-fact-{user.id}") + event_messages = test_domain.event_store.store.read(f"test::user-fact-{user.id}") assert len(event_messages) == 2 # Deserialize event diff --git a/tests/command/test_command_meta.py b/tests/command/test_command_meta.py index d6e4ee87..b252ff46 100644 --- a/tests/command/test_command_meta.py +++ b/tests/command/test_command_meta.py @@ -63,7 +63,7 @@ def test_command_associated_with_aggregate(test_domain): messages = test_domain.event_store.store.read("user:command") assert len(messages) == 1 - messages[0].stream == f"user:command-{identifier}" + messages[0].stream_name == f"user:command-{identifier}" @pytest.mark.eventstore @@ -84,7 +84,7 @@ def test_command_associated_with_aggregate_with_custom_stream_name(test_domain): messages = test_domain.event_store.store.read("foo:command") assert len(messages) == 1 - messages[0].stream == f"foo:command-{identifier}" + messages[0].stream_name == f"foo:command-{identifier}" def test_aggregate_cluster_of_event(test_domain): diff --git a/tests/command/test_command_metadata.py b/tests/command/test_command_metadata.py index 8ed0217d..fd483172 100644 --- a/tests/command/test_command_metadata.py +++ b/tests/command/test_command_metadata.py @@ -71,7 +71,7 @@ def test_command_metadata(test_domain): "type": "Test.Login.v1", "fqn": fqn(Login), "kind": "COMMAND", - "stream": f"user:command-{identifier}", + "stream": f"test::user:command-{identifier}", "origin_stream": None, "timestamp": str(command._metadata.timestamp), "version": "v1", diff --git a/tests/command_handler/test_inline_command_processing.py b/tests/command_handler/test_inline_command_processing.py index 76ac90bc..f7da4f4e 100644 --- a/tests/command_handler/test_inline_command_processing.py +++ b/tests/command_handler/test_inline_command_processing.py @@ -65,4 +65,4 @@ def test_that_command_is_persisted_in_message_store(test_domain): messages = test_domain.event_store.store.read("user:command") assert len(messages) == 1 - messages[0].stream == f"user:command-{identifier}" + messages[0].stream_name == f"user:command-{identifier}" diff --git a/tests/event/test_raising_events.py b/tests/event/test_raising_events.py index beaec0df..c2b5f895 100644 --- a/tests/event/test_raising_events.py +++ b/tests/event/test_raising_events.py @@ -32,4 +32,4 @@ def test_raising_event(test_domain): messages = test_domain.event_store.store.read("authentication") assert len(messages) == 1 - assert messages[0].stream == f"authentication-{identifier}" + assert messages[0].stream_name == f"authentication-{identifier}" diff --git a/tests/event/test_stream_name_derivation.py b/tests/event/test_stream_name_derivation.py index 6dd919b9..7346e58b 100644 --- a/tests/event/test_stream_name_derivation.py +++ b/tests/event/test_stream_name_derivation.py @@ -16,11 +16,11 @@ def test_stream_category_from_part_of(test_domain): test_domain.register(User) test_domain.register(UserLoggedIn, part_of=User) - assert UserLoggedIn.meta_.part_of.meta_.stream_category == "user" + assert UserLoggedIn.meta_.part_of.meta_.stream_category == "test::user" def test_stream_category_from_explicit_stream_category_in_aggregate(test_domain): test_domain.register(User, stream_category="authentication") test_domain.register(UserLoggedIn, part_of=User) - assert UserLoggedIn.meta_.part_of.meta_.stream_category == "authentication" + assert UserLoggedIn.meta_.part_of.meta_.stream_category == "test::authentication" diff --git a/tests/event_handler/test_event_handler_options.py b/tests/event_handler/test_event_handler_options.py index 67642d8b..b8c1ce2e 100644 --- a/tests/event_handler/test_event_handler_options.py +++ b/tests/event_handler/test_event_handler_options.py @@ -50,17 +50,19 @@ def test_stream_category_option(test_domain): class UserEventHandlers(BaseEventHandler): pass - test_domain.register(UserEventHandlers, stream_category="user") - assert UserEventHandlers.meta_.stream_category == "user" + test_domain.register(UserEventHandlers, stream_category="test::user") + assert UserEventHandlers.meta_.stream_category == "test::user" def test_options_defined_at_different_levels(test_domain): class UserEventHandlers(BaseEventHandler): pass - test_domain.register(UserEventHandlers, part_of=User, stream_category="person") + test_domain.register( + UserEventHandlers, part_of=User, stream_category="test::person" + ) assert UserEventHandlers.meta_.part_of == User - assert UserEventHandlers.meta_.stream_category == "person" + assert UserEventHandlers.meta_.stream_category == "test::person" def test_that_a_default_stream_category_is_derived_from_part_of(test_domain): @@ -69,7 +71,7 @@ class UserEventHandlers(BaseEventHandler): test_domain.register(User) test_domain.register(UserEventHandlers, part_of=User) - assert UserEventHandlers.meta_.stream_category == "user" + assert UserEventHandlers.meta_.stream_category == "test::user" def test_source_stream_option(test_domain): diff --git a/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py b/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py index 71b1f57c..72864a13 100644 --- a/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py +++ b/tests/event_sourced_aggregates/test_raising_events_from_within_aggregates.py @@ -86,11 +86,11 @@ def test_that_events_can_be_raised_from_within_aggregates(test_domain): messages = test_domain.event_store.store._read("user") assert len(messages) == 1 - assert messages[0]["stream"] == f"user-{identifier}" + assert messages[0]["stream_name"] == f"user-{identifier}" assert messages[0]["type"] == Registered.__type__ messages = test_domain.event_store.store._read("user:command") assert len(messages) == 1 - assert messages[0]["stream"] == f"user:command-{identifier}" + assert messages[0]["stream_name"] == f"user:command-{identifier}" assert messages[0]["type"] == Register.__type__ diff --git a/tests/event_store/test_appending_commands.py b/tests/event_store/test_appending_commands.py index cbb06a89..c31092a9 100644 --- a/tests/event_store/test_appending_commands.py +++ b/tests/event_store/test_appending_commands.py @@ -52,4 +52,4 @@ def test_command_submission(test_domain): messages = test_domain.event_store.store.read("user:command") assert len(messages) == 1 - messages[0].stream == f"user:command-{identifier}" + messages[0].stream_name == f"user:command-{identifier}" diff --git a/tests/event_store/test_appending_events.py b/tests/event_store/test_appending_events.py index 9c04278d..d606fb8d 100644 --- a/tests/event_store/test_appending_events.py +++ b/tests/event_store/test_appending_events.py @@ -36,7 +36,7 @@ def test_appending_raw_events(test_domain): message = messages[0] assert isinstance(message, Message) - assert message.stream == f"authentication-{identifier}" + assert message.stream_name == f"authentication-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == event.payload assert message.metadata == event._metadata diff --git a/tests/event_store/test_reading_messages.py b/tests/event_store/test_reading_messages.py index 16302318..2d190742 100644 --- a/tests/event_store/test_reading_messages.py +++ b/tests/event_store/test_reading_messages.py @@ -73,7 +73,7 @@ def test_reading_a_message(test_domain, registered_user): message = messages[0] assert isinstance(message, Message) - assert message.stream == f"user-{registered_user.id}" + assert message.stream_name == f"user-{registered_user.id}" assert message.metadata.kind == "EVENT" assert message.data == registered_user._events[-1].payload assert message.metadata == registered_user._events[-1]._metadata @@ -85,7 +85,7 @@ def test_reading_many_messages(test_domain, activated_user): assert len(messages) == 2 - assert messages[0].stream == f"user-{activated_user.id}" + assert messages[0].stream_name == f"user-{activated_user.id}" assert messages[0].metadata.kind == "EVENT" assert messages[0].data == activated_user._events[0].payload assert messages[0].metadata == activated_user._events[0]._metadata @@ -128,7 +128,7 @@ def test_reading_messages_by_category(test_domain, activated_user): assert len(messages) == 2 - assert messages[0].stream == f"user-{activated_user.id}" + assert messages[0].stream_name == f"user-{activated_user.id}" assert messages[0].metadata.kind == "EVENT" assert messages[0].data == activated_user._events[0].payload assert messages[0].metadata == activated_user._events[0]._metadata diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index 1bf190e6..50ce865d 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -64,7 +64,7 @@ def test_construct_message_from_event(test_domain): # Verify Message Content assert message.type == Registered.__type__ - assert message.stream == f"{User.meta_.stream_category}-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == user._events[-1].payload assert message.time is None @@ -75,7 +75,7 @@ def test_construct_message_from_event(test_domain): assert message_dict["type"] == Registered.__type__ assert message_dict["metadata"]["kind"] == "EVENT" - assert message_dict["stream"] == f"{User.meta_.stream_category}-{identifier}" + assert message_dict["stream_name"] == f"{User.meta_.stream_category}-{identifier}" assert message_dict["data"] == user._events[-1].payload assert message_dict["time"] is None assert ( @@ -97,7 +97,7 @@ def test_construct_message_from_command(test_domain): # Verify Message Content assert message.type == Register.__type__ - assert message.stream == f"{User.meta_.stream_category}:command-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command_with_metadata.payload assert message.time is not None @@ -107,7 +107,8 @@ def test_construct_message_from_command(test_domain): assert message_dict["type"] == Register.__type__ assert message_dict["metadata"]["kind"] == "COMMAND" assert ( - message_dict["stream"] == f"{User.meta_.stream_category}:command-{identifier}" + message_dict["stream_name"] + == f"{User.meta_.stream_category}:command-{identifier}" ) assert message_dict["data"] == command_with_metadata.payload assert message_dict["time"] is not None @@ -126,7 +127,7 @@ def test_construct_message_from_command_without_identifier(test_domain): assert type(message) is Message message_dict = message.to_dict() - identifier = message_dict["stream"].split( + identifier = message_dict["stream_name"].split( f"{SendEmail.meta_.stream_category}:command-", 1 )[1] @@ -148,7 +149,7 @@ def test_construct_message_from_either_event_or_command(test_domain): # Verify Message Content assert message.type == Register.__type__ - assert message.stream == f"{User.meta_.stream_category}:command-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command.payload @@ -164,7 +165,7 @@ def test_construct_message_from_either_event_or_command(test_domain): # Verify Message Content assert message.type == Registered.__type__ - assert message.stream == f"{User.meta_.stream_category}-{identifier}" + assert message.stream_name == f"{User.meta_.stream_category}-{identifier}" assert message.metadata.kind == "EVENT" assert message.data == event.payload assert message.time is None diff --git a/tests/test_brokers.py b/tests/test_brokers.py index e17b5ecc..70805bab 100644 --- a/tests/test_brokers.py +++ b/tests/test_brokers.py @@ -114,10 +114,10 @@ def test_that_event_is_persisted_on_publish(self, mocker, test_domain): ) test_domain.publish(person._events[0]) - messages = test_domain.event_store.store.read("person") + messages = test_domain.event_store.store.read("test::person") assert len(messages) == 1 - messages[0].stream == "person-1234" + messages[0].stream_name == "person-1234" @pytest.mark.eventstore def test_that_multiple_events_are_persisted_on_publish(self, mocker, test_domain): @@ -146,11 +146,11 @@ def test_that_multiple_events_are_persisted_on_publish(self, mocker, test_domain ] ) - messages = test_domain.event_store.store.read("person") + messages = test_domain.event_store.store.read("test::person") assert len(messages) == 2 - assert messages[0].stream == "person-1234" - assert messages[1].stream == "person-1235" + assert messages[0].stream_name == "test::person-1234" + assert messages[1].stream_name == "test::person-1235" class TestBrokerSubscriberInitialization: From 34f4bdb6e317a6c8275ec736049316a4ec4f0b71 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Mon, 15 Jul 2024 10:06:09 -0700 Subject: [PATCH 15/17] Miscellaneous Bugfixes - Do not allow associations and references in Events and Commands - Use camelcase domain name when constructing event/command type - Validate ValueObject field is connected to a value object - Refactor repository methods to indicate support for both aggregates and views --- src/protean/core/command.py | 17 +++++++ src/protean/core/event.py | 17 +++++++ src/protean/core/repository.py | 54 +++++++++++---------- src/protean/domain/__init__.py | 2 +- src/protean/fields/embedded.py | 24 ++++++++- tests/adapters/broker/redis_broker/tests.py | 4 +- tests/command/test_command_field_types.py | 38 +++++++++++++++ tests/event/test_event_field_types.py | 38 +++++++++++++++ tests/event/test_event_metadata.py | 35 +++++++++++++ tests/field/test_vo.py | 32 ++++++++++++ 10 files changed, 231 insertions(+), 30 deletions(-) create mode 100644 tests/command/test_command_field_types.py create mode 100644 tests/event/test_event_field_types.py create mode 100644 tests/field/test_vo.py diff --git a/src/protean/core/command.py b/src/protean/core/command.py index c8c8b9e0..86002f1e 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -7,6 +7,7 @@ ValidationError, ) from protean.fields import Field, ValueObject +from protean.fields.association import Association, Reference from protean.globals import g from protean.reflection import _ID_FIELD_NAME, declared_fields, fields from protean.utils import DomainObjects, derive_element_class, fqn @@ -39,6 +40,22 @@ def __init_subclass__(subclass) -> None: if not hasattr(subclass, "__version__"): setattr(subclass, "__version__", "v1") + subclass.__validate_for_basic_field_types() + + @classmethod + def __validate_for_basic_field_types(subclass): + for field_name, field_obj in fields(subclass).items(): + # Value objects can hold all kinds of fields, except associations + if isinstance(field_obj, (Reference, Association)): + raise IncorrectUsageError( + { + "_event": [ + f"Commands cannot have associations. " + f"Remove {field_name} ({field_obj.__class__.__name__}) from class {subclass.__name__}" + ] + } + ) + def __init__(self, *args, **kwargs): try: super().__init__(*args, finalize=False, **kwargs) diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 898276cf..d87d3708 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -10,6 +10,7 @@ NotSupportedError, ) from protean.fields import DateTime, Field, Integer, String, ValueObject +from protean.fields.association import Association, Reference from protean.globals import g from protean.reflection import _ID_FIELD_NAME, declared_fields, fields from protean.utils import DomainObjects, derive_element_class, fqn @@ -90,6 +91,22 @@ def __init_subclass__(subclass) -> None: if not hasattr(subclass, "__version__"): setattr(subclass, "__version__", "v1") + subclass.__validate_for_basic_field_types() + + @classmethod + def __validate_for_basic_field_types(subclass): + for field_name, field_obj in fields(subclass).items(): + # Value objects can hold all kinds of fields, except associations + if isinstance(field_obj, (Reference, Association)): + raise IncorrectUsageError( + { + "_event": [ + f"Events cannot have associations. " + f"Remove {field_name} ({field_obj.__class__.__name__}) from class {subclass.__name__}" + ] + } + ) + def __setattr__(self, name, value): if not hasattr(self, "_initialized") or not self._initialized: return super().__setattr__(name, value) diff --git a/src/protean/core/repository.py b/src/protean/core/repository.py index be9790f4..c0a08bfa 100644 --- a/src/protean/core/repository.py +++ b/src/protean/core/repository.py @@ -2,11 +2,12 @@ import logging from functools import lru_cache -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from protean.container import Element, OptionsMixin from protean.core.aggregate import BaseAggregate from protean.core.unit_of_work import UnitOfWork +from protean.core.view import BaseView from protean.exceptions import IncorrectUsageError, NotSupportedError from protean.fields import HasMany, HasOne from protean.globals import current_domain, current_uow @@ -95,10 +96,12 @@ def _dao(self) -> BaseDAO: # Fixate on Model class at the domain level because an explicit model may have been registered return self._provider.get_dao(self.meta_.part_of, self._model) - def add(self, aggregate: BaseAggregate) -> BaseAggregate: # noqa: C901 - """This method helps persist or update aggregates into the persistence store. + def add( + self, item: Union[BaseAggregate, BaseView] + ) -> Union[BaseAggregate, BaseView]: # noqa: C901 + """This method helps persist or update aggregates or views into the persistence store. - Returns the persisted aggregate. + Returns the persisted item. Protean adopts a collection-oriented design pattern to handle persistence. What this means is that the Repository interface does not hint in any way that there is an underlying persistence mechanism, @@ -124,31 +127,31 @@ def add(self, aggregate: BaseAggregate) -> BaseAggregate: # noqa: C901 own_current_uow.start() # If there are HasMany/HasOne fields in the aggregate, sync child objects added/removed, - if has_association_fields(aggregate): - self._sync_children(aggregate) + if has_association_fields(item): + self._sync_children(item) - # Persist only if the aggregate object is new, or it has changed since last persistence - if (not aggregate.state_.is_persisted) or ( - aggregate.state_.is_persisted and aggregate.state_.is_changed + # Persist only if the item object is new, or it has changed since last persistence + if (not item.state_.is_persisted) or ( + item.state_.is_persisted and item.state_.is_changed ): - self._dao.save(aggregate) + self._dao.save(item) # If Aggregate has signed up Fact Events, raise them now - if aggregate.meta_.fact_events: - payload = aggregate.to_dict() + if item.element_type == DomainObjects.AGGREGATE and item.meta_.fact_events: + payload = item.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) + fact_event = item._fact_event_cls(**payload) + item.raise_(fact_event) # If we started a UnitOfWork, commit it now if own_current_uow: own_current_uow.commit() - return aggregate + return item def _sync_children(self, entity): """Recursively sync child entities to the persistence store""" @@ -232,11 +235,11 @@ def _sync_children(self, entity): entity._temp_cache[field_name]["change"] = None entity._temp_cache[field_name]["old_value"] = None - def get(self, identifier) -> BaseAggregate: + def get(self, identifier) -> Union[BaseAggregate, BaseView]: """This is a utility method to fetch data from the persistence store by its key identifier. All child objects, including enclosed entities, are returned as part of this call. - Returns the fetched aggregate. + Returns the fetched object. All other data filtering capabilities can be implemented by using the underlying DAO's :meth:`BaseDAO.filter` method. @@ -245,16 +248,17 @@ def get(self, identifier) -> BaseAggregate: `find_residents_of_area(zipcode)`, etc. It is also possible to make use of more complicated, domain-friendly design patterns like the `Specification` pattern. """ - aggregate = self._dao.get(identifier) + item = self._dao.get(identifier) - # Fetch and sync events version - last_message = current_domain.event_store.store.read_last_message( - f"{aggregate.meta_.stream_category}-{identifier}" - ) - if last_message: - aggregate._event_position = last_message.position + if item.element_type == DomainObjects.AGGREGATE: + # Fetch and sync events version + last_message = current_domain.event_store.store.read_last_message( + f"{item.meta_.stream_category}-{identifier}" + ) + if last_message: + item._event_position = last_message.position - return aggregate + return item def repository_factory(element_cls, domain, **opts): diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 3df917b4..cabaf1a3 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -834,7 +834,7 @@ def _set_event_and_command_type(self): element.cls, "__type__", ( - f"{self.name}." + f"{self.camel_case_name}." # f"{element.cls.meta_.aggregate_cluster.__class__.__name__}." f"{element.cls.__name__}." f"{element.cls.__version__}" diff --git a/src/protean/fields/embedded.py b/src/protean/fields/embedded.py index 4e7ad003..8a15b923 100644 --- a/src/protean/fields/embedded.py +++ b/src/protean/fields/embedded.py @@ -2,6 +2,7 @@ from functools import lru_cache +from protean.exceptions import IncorrectUsageError from protean.fields import Field from protean.reflection import declared_fields @@ -57,16 +58,37 @@ class ValueObject(Field): def __init__(self, value_object_cls, *args, **kwargs): super().__init__(*args, **kwargs) + + if not isinstance(value_object_cls, str): + # Validate the class being passed is a subclass of BaseValueObject + self._validate_value_object_cls(value_object_cls) + self._value_object_cls = value_object_cls self._embedded_fields = {} + def _validate_value_object_cls(self, value_object_cls): + """Validate that the value object class is a subclass of BaseValueObject""" + from protean.core.value_object import BaseValueObject + + if not issubclass(value_object_cls, BaseValueObject): + raise IncorrectUsageError( + { + "_value_object": [ + f"`{value_object_cls.__name__}` is not a valid Value Object" + ] + } + ) + @property def value_object_cls(self): return self._value_object_cls def _resolve_to_cls(self, domain, value_object_cls, owner_cls): - assert isinstance(self.value_object_cls, str) + assert isinstance(self._value_object_cls, str) + + # Validate the class being passed is a subclass of BaseValueObject + self._validate_value_object_cls(value_object_cls) self._value_object_cls = value_object_cls diff --git a/tests/adapters/broker/redis_broker/tests.py b/tests/adapters/broker/redis_broker/tests.py index 9a5e6b7e..9c73d5a5 100644 --- a/tests/adapters/broker/redis_broker/tests.py +++ b/tests/adapters/broker/redis_broker/tests.py @@ -60,9 +60,7 @@ def test_event_message_structure(self, test_domain): "metadata", ] ) - assert ( - json_message["type"] == "Redis Broker Tests.PersonAdded.v1" - ) # FIXME Normalize Domain Name + assert json_message["type"] == "RedisBrokerTests.PersonAdded.v1" assert json_message["metadata"]["kind"] == "EVENT" diff --git a/tests/command/test_command_field_types.py b/tests/command/test_command_field_types.py new file mode 100644 index 00000000..ca5eb0f8 --- /dev/null +++ b/tests/command/test_command_field_types.py @@ -0,0 +1,38 @@ +import pytest + +from protean import BaseAggregate, BaseCommand, BaseEntity +from protean.exceptions import IncorrectUsageError +from protean.fields import HasMany, HasOne, String + + +class User(BaseAggregate): + email = String() + name = String() + account = HasOne("Account") + addresses = HasMany("Address") + + +class Account(BaseEntity): + password_hash = String() + + +class Address(BaseEntity): + street = String() + city = String() + state = String() + postal_code = String() + + +def test_events_cannot_hold_associations(): + with pytest.raises(IncorrectUsageError) as exc: + + class Register(BaseCommand): + email = String() + name = String() + account = HasOne(Account) + + assert exc.value.messages == { + "_event": [ + "Commands cannot have associations. Remove account (HasOne) from class Register" + ] + } diff --git a/tests/event/test_event_field_types.py b/tests/event/test_event_field_types.py new file mode 100644 index 00000000..e524790b --- /dev/null +++ b/tests/event/test_event_field_types.py @@ -0,0 +1,38 @@ +import pytest + +from protean import BaseAggregate, BaseEntity, BaseEvent +from protean.exceptions import IncorrectUsageError +from protean.fields import HasMany, HasOne, String + + +class User(BaseAggregate): + email = String() + name = String() + account = HasOne("Account") + addresses = HasMany("Address") + + +class Account(BaseEntity): + password_hash = String() + + +class Address(BaseEntity): + street = String() + city = String() + state = String() + postal_code = String() + + +def test_events_cannot_hold_associations(): + with pytest.raises(IncorrectUsageError) as exc: + + class UserRegistered(BaseEvent): + email = String() + name = String() + account = HasOne(Account) + + assert exc.value.messages == { + "_event": [ + "Events cannot have associations. Remove account (HasOne) from class UserRegistered" + ] + } diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index bccfef8e..6699eeda 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -85,6 +85,41 @@ class UserLoggedIn(BaseEvent): event = UserLoggedIn(user_id=str(uuid4())) assert event._metadata.version == "v2" + def test_version_value_in_multiple_event_definitions(self, test_domain): + def version1(): + class DummyEvent(BaseEvent): + user_id = Identifier(identifier=True) + + return DummyEvent + + def version2(): + class DummyEvent(BaseEvent): + __version__ = "v2" + user_id = Identifier(identifier=True) + + return DummyEvent + + event_cls1 = version1() + event_cls2 = version2() + + test_domain.register(event_cls1, part_of=User) + test_domain.register(event_cls2, part_of=User) + test_domain.init(traverse=False) + + assert event_cls1.__version__ == "v1" + assert event_cls2.__version__ == "v2" + + assert len(test_domain.registry.events) == 3 # Includes UserLoggedIn + + assert ( + test_domain.registry.events[fqn(event_cls1)].cls.__type__ + == "Test.DummyEvent.v1" + ) + assert ( + test_domain.registry.events[fqn(event_cls2)].cls.__type__ + == "Test.DummyEvent.v2" + ) + def test_event_metadata(): user_id = str(uuid4()) diff --git a/tests/field/test_vo.py b/tests/field/test_vo.py new file mode 100644 index 00000000..65b4d39a --- /dev/null +++ b/tests/field/test_vo.py @@ -0,0 +1,32 @@ +import pytest + +from protean import BaseAggregate, BaseEntity, BaseValueObject +from protean.exceptions import IncorrectUsageError +from protean.fields import String, ValueObject +from protean.reflection import fields + + +def test_value_object_associated_class(test_domain): + class Address(BaseValueObject): + street_address = String() + + class User(BaseAggregate): + email = String() + address = ValueObject(Address) + + assert fields(User)["address"].value_object_cls == Address + + +def test_value_object_to_cls_is_always_a_base_value_object_subclass(test_domain): + class Address(BaseEntity): + street_address = String() + + with pytest.raises(IncorrectUsageError) as exc: + + class User(BaseAggregate): + email = String() + address = ValueObject(Address) + + assert exc.value.messages == { + "_value_object": ["`Address` is not a valid Value Object"] + } From 9921944e28ff277c7baa6a9a95eedc136daffcc3 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Tue, 16 Jul 2024 13:55:59 -0700 Subject: [PATCH 16/17] Store event and command types in domain for easy retrieval Use the `__type__` value present in events and commands to retrieve event and command classes from domain, instead of fetching by fully qualified name. This change is required to support cross-domain event processing. Also: - Fix bug with collecting identity value in associations - Remove unused delist functionality in registry --- src/protean/domain/__init__.py | 27 ++++++++------ src/protean/domain/registry.py | 9 +---- src/protean/fields/association.py | 47 +++++++++++++++++-------- src/protean/server/engine.py | 3 +- src/protean/server/subscription.py | 7 +++- src/protean/utils/mixins.py | 18 ++++++---- tests/command/test_command_basics.py | 21 +++++++++++ tests/domain/test_init.py | 8 +++-- tests/event/tests.py | 6 ++++ tests/message/test_message_to_object.py | 6 ++-- tests/test_registry.py | 41 --------------------- 11 files changed, 106 insertions(+), 87 deletions(-) create mode 100644 tests/command/test_command_basics.py diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index cabaf1a3..045db89a 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -180,6 +180,9 @@ def __init__( self._models: Dict[str, BaseModel] = {} self._constructed_models: Dict[str, BaseModel] = {} + # Cache for holding events and commands by their types + self._events_and_commands: Dict[str, Union[BaseCommand, BaseEvent]] = {} + #: A list of functions that are called when the domain context #: is destroyed. This is the place to store code that cleans up and #: disconnects from databases, for example. @@ -259,7 +262,7 @@ def init(self, traverse=True): # noqa: C901 self._generate_fact_event_classes() # Generate and set event/command `__type__` value - self._set_event_and_command_type() + self._set_and_record_event_and_command_type() # Parse and setup handler methods in Command Handlers self._setup_command_handlers() @@ -827,20 +830,22 @@ def _set_aggregate_cluster_options(self): element.cls.meta_.aggregate_cluster.meta_.provider, ) - def _set_event_and_command_type(self): + def _set_and_record_event_and_command_type(self): for element_type in [DomainObjects.EVENT, DomainObjects.COMMAND]: for _, element in self.registry._elements[element_type.value].items(): - setattr( - element.cls, - "__type__", - ( - f"{self.camel_case_name}." - # f"{element.cls.meta_.aggregate_cluster.__class__.__name__}." - f"{element.cls.__name__}." - f"{element.cls.__version__}" - ), + # Type is .. + # E.g. `Authentication.UserRegistered.v1`, `Ecommerce.OrderPlaced.v1` + type_string = ( + f"{self.camel_case_name}." + # f"{element.cls.meta_.aggregate_cluster.__class__.__name__}." + f"{element.cls.__name__}." + f"{element.cls.__version__}" ) + setattr(element.cls, "__type__", type_string) + + self._events_and_commands[type_string] = element.cls + def _setup_command_handlers(self): for element_type in [DomainObjects.COMMAND_HANDLER]: for _, element in self.registry._elements[element_type.value].items(): diff --git a/src/protean/domain/registry.py b/src/protean/domain/registry.py index 63dbba0b..0ae3cecf 100644 --- a/src/protean/domain/registry.py +++ b/src/protean/domain/registry.py @@ -83,6 +83,7 @@ def register_element(self, element_cls): f"Element `{element_cls.__name__}` is not a valid element class" ) + # Element name is always the fully qualified name of the class element_name = fully_qualified_name(element_cls) element = self._elements[element_cls.element_type.value][element_name] @@ -111,14 +112,6 @@ def register_element(self, element_cls): f"Registered Element {element_name} with Domain as a {element_cls.element_type.value}" ) - def delist_element(self, element_cls): - if self._is_invalid_element_cls(element_cls): - raise NotImplementedError - - element_name = fully_qualified_name(element_cls) - - self._elements[element_cls.element_type.value].pop(element_name, None) - @property def elements(self): elems = {} diff --git a/src/protean/fields/association.py b/src/protean/fields/association.py index 289aab60..e7884244 100644 --- a/src/protean/fields/association.py +++ b/src/protean/fields/association.py @@ -406,6 +406,12 @@ def __set__(self, instance, value): # 2. Determine and store the change in the relationship current_value = getattr(instance, self.field_name) + current_value_id = ( + getattr(current_value, id_field(current_value).field_name) + if current_value + else None + ) + value_id = getattr(value, id_field(value).field_name) if value else None if current_value is None: # Entity was not associated earlier instance._temp_cache[self.field_name]["change"] = "ADDED" @@ -413,11 +419,11 @@ def __set__(self, instance, value): # Entity was associated earlier, but now being removed instance._temp_cache[self.field_name]["change"] = "DELETED" instance._temp_cache[self.field_name]["old_value"] = current_value - elif current_value.id != value.id: + elif current_value_id != value_id: # A New Entity is being associated replacing the old one instance._temp_cache[self.field_name]["change"] = "UPDATED" instance._temp_cache[self.field_name]["old_value"] = current_value - elif current_value.id == value.id and value.state_.is_changed: + elif current_value_id == value_id and value.state_.is_changed: # Entity was associated earlier, but now being updated instance._temp_cache[self.field_name]["change"] = "UPDATED" else: @@ -527,7 +533,9 @@ def add(self, instance, items) -> None: } ) - current_value_ids = [value.id for value in data] + current_value_ids = [ + getattr(value, id_field(value).field_name) for value in data + ] # Remove items when set to empty if len(items) == 0 and len(current_value_ids) > 0: @@ -535,9 +543,10 @@ def add(self, instance, items) -> None: for item in items: # Items to add - if item.id not in current_value_ids: + identity = getattr(item, id_field(item).field_name) + if identity not in current_value_ids: # If the same item is added multiple times, the last item added will win - instance._temp_cache[self.field_name]["added"][item.id] = item + instance._temp_cache[self.field_name]["added"][identity] = item setattr( item, @@ -552,7 +561,7 @@ def add(self, instance, items) -> None: self.delete_cached_value(instance) # Items to update elif ( - item.id in current_value_ids + identity in current_value_ids and item.state_.is_persisted and item.state_.is_changed ): @@ -565,7 +574,7 @@ def add(self, instance, items) -> None: # Temporarily set linkage to parent in child entity setattr(item, self._linked_reference(type(instance)), instance) - instance._temp_cache[self.field_name]["updated"][item.id] = item + instance._temp_cache[self.field_name]["updated"][identity] = item # Reset Cache self.delete_cached_value(instance) @@ -601,12 +610,15 @@ def remove(self, instance, items) -> None: } ) - current_value_ids = [value.id for value in data] + current_value_ids = [ + getattr(value, id_field(value).field_name) for value in data + ] for item in items: - if item.id in current_value_ids: - if item.id not in instance._temp_cache[self.field_name]["removed"]: - instance._temp_cache[self.field_name]["removed"][item.id] = item + identity = getattr(item, id_field(item).field_name) + if identity in current_value_ids: + if identity not in instance._temp_cache[self.field_name]["removed"]: + instance._temp_cache[self.field_name]["removed"][identity] = item # Reset Cache self.delete_cached_value(instance) @@ -648,9 +660,10 @@ def _fetch_objects(self, instance, key, value) -> list: # Update objects from temporary cache if present updated_objects = [] for value in data: - if value.id in instance._temp_cache[self.field_name]["updated"]: + identity = getattr(value, id_field(value).field_name) + if identity in instance._temp_cache[self.field_name]["updated"]: updated_objects.append( - instance._temp_cache[self.field_name]["updated"][value.id] + instance._temp_cache[self.field_name]["updated"][identity] ) else: updated_objects.append(value) @@ -658,7 +671,13 @@ def _fetch_objects(self, instance, key, value) -> list: # Remove objects marked as removed in temporary cache for _, item in instance._temp_cache[self.field_name]["removed"].items(): - data[:] = [value for value in data if value.id != item.id] + # Retain data that is not among deleted items + data[:] = [ + value + for value in data + if getattr(value, id_field(value).field_name) + != getattr(item, id_field(item).field_name) + ] return data diff --git a/src/protean/server/engine.py b/src/protean/server/engine.py index aec991f2..fc2837a2 100644 --- a/src/protean/server/engine.py +++ b/src/protean/server/engine.py @@ -110,7 +110,8 @@ async def handle_message( f"Error handling message {message.stream_name}-{message.id} " f"in {handler_cls.__name__}" ) - logger.error(f"{str(exc)}") + # Print the stack trace + logger.error(traceback.format_exc()) handler_cls.handle_error(exc, message) await self.shutdown(exit_code=1) diff --git a/src/protean/server/subscription.py b/src/protean/server/subscription.py index f8d4bb70..2e8a3969 100644 --- a/src/protean/server/subscription.py +++ b/src/protean/server/subscription.py @@ -98,7 +98,12 @@ async def poll(self) -> None: await self.tick() if self.keep_going and not self.engine.shutting_down: - await asyncio.sleep(self.tick_interval) + # Keep control of the loop if in test mode + # Otherwise `asyncio.sleep` will give away control and + # the loop will be able to be stopped with `shutdown()` + if not self.engine.test_mode: + await asyncio.sleep(self.tick_interval) + self.loop.create_task(self.poll()) async def tick(self): diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 2ba501fe..19fdc99b 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -81,17 +81,23 @@ def from_dict(cls, message: Dict) -> Message: def to_object(self) -> Union[BaseEvent, BaseCommand]: """Reconstruct the event/command object from the message data.""" - if self.metadata.kind == MessageType.EVENT.value: - element_record = current_domain.registry.events[self.metadata.fqn] - elif self.metadata.kind == MessageType.COMMAND.value: - element_record = current_domain.registry.commands[self.metadata.fqn] - else: + if self.metadata.kind not in [ + MessageType.COMMAND.value, + MessageType.EVENT.value, + ]: # We are dealing with a malformed or unknown message raise InvalidDataError( {"_message": ["Message type is not supported for deserialization"]} ) - return element_record.cls(_metadata=self.metadata, **self.data) + element_cls = current_domain._events_and_commands.get(self.metadata.type, None) + + if element_cls is None: + raise ConfigurationError( + f"Message type {self.metadata.type} is not registered with the domain." + ) + + return element_cls(_metadata=self.metadata, **self.data) @classmethod def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: diff --git a/tests/command/test_command_basics.py b/tests/command/test_command_basics.py new file mode 100644 index 00000000..3b346fa4 --- /dev/null +++ b/tests/command/test_command_basics.py @@ -0,0 +1,21 @@ +from protean import BaseCommand, BaseEventSourcedAggregate +from protean.fields import Identifier, String + + +class User(BaseEventSourcedAggregate): + id = Identifier(identifier=True) + email = String() + name = String() + + +class Register(BaseCommand): + user_id = Identifier(identifier=True) + email = String() + name = String() + + +def test_domain_stores_command_type_for_easy_retrieval(test_domain): + test_domain.register(Register, part_of=User) + test_domain.init(traverse=False) + + assert Register.__type__ in test_domain._events_and_commands diff --git a/tests/domain/test_init.py b/tests/domain/test_init.py index 669d1ecc..eb156315 100644 --- a/tests/domain/test_init.py +++ b/tests/domain/test_init.py @@ -50,10 +50,12 @@ def test_domain_init_constructs_fact_events(self, test_domain): mock_generate_fact_event_classes.assert_called_once() def test_domain_init_sets_event_command_types(self, test_domain): - mock_set_event_and_command_type = Mock() - test_domain._set_event_and_command_type = mock_set_event_and_command_type + mock_set_and_record_event_and_command_type = Mock() + test_domain._set_and_record_event_and_command_type = ( + mock_set_and_record_event_and_command_type + ) test_domain.init(traverse=False) - mock_set_event_and_command_type.assert_called_once() + mock_set_and_record_event_and_command_type.assert_called_once() def test_domain_init_sets_up_command_handlers(self, test_domain): mock_setup_command_handlers = Mock() diff --git a/tests/event/tests.py b/tests/event/tests.py index ac4ab602..5b5239d7 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -132,6 +132,12 @@ def special_method(self): assert fully_qualified_name(AnnotatedDomainEvent) in test_domain.registry.events + def test_domain_stores_event_type_for_easy_retrieval(self, test_domain): + test_domain.register(PersonAdded, part_of=Person) + test_domain.init(traverse=False) + + assert PersonAdded.__type__ in test_domain._events_and_commands + class TestDomainEventEquivalence: @pytest.fixture(autouse=True) diff --git a/tests/message/test_message_to_object.py b/tests/message/test_message_to_object.py index df8dab64..8b650aa6 100644 --- a/tests/message/test_message_to_object.py +++ b/tests/message/test_message_to_object.py @@ -63,9 +63,11 @@ def test_construct_event_from_message(): assert reconstructed_event.id == identifier -def test_construct_command_from_message(): +def test_construct_command_from_message(test_domain): identifier = str(uuid4()) - command = Register(id=identifier, email="john.doe@gmail.com", name="John Doe") + command = test_domain._enrich_command( + Register(id=identifier, email="john.doe@gmail.com", name="John Doe") + ) message = Message.to_message(command) reconstructed_command = message.to_object() diff --git a/tests/test_registry.py b/tests/test_registry.py index 8ab8c5ba..804e4982 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -32,22 +32,6 @@ def test_element_registration(): ) -def test_delisting_element(): - register = _DomainRegistry() - register.register_element(User) - - assert ( - "tests.test_registry.User" in register._elements[DomainObjects.AGGREGATE.value] - ) - - register.delist_element(User) - - assert ( - "tests.test_registry.User" - not in register._elements[DomainObjects.AGGREGATE.value] - ) - - def test_fetching_elements_from_registry(): register = _DomainRegistry() register.register_element(User) @@ -89,31 +73,6 @@ class FooBar3: register.register_element(FooBar3) -def test_that_delisting_an_unknown_element_type_triggers_an_error(): - class DummyEnum(Enum): - UNKNOWN = "UNKNOWN" - - class FooBar1: - element_type = "FOOBAR" - - class FooBar2: - element_type = DummyEnum.UNKNOWN - - class FooBar3: - pass - - register = _DomainRegistry() - - with pytest.raises(NotImplementedError): - register.delist_element(FooBar1) - - with pytest.raises(NotImplementedError): - register.delist_element(FooBar2) - - with pytest.raises(NotImplementedError): - register.delist_element(FooBar3) - - def test_that_re_registering_an_element_has_no_effect(): register = _DomainRegistry() register.register_element(User) From b9feafd3f6409677e66e8cfc72ea1896bebc8e0a Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Tue, 16 Jul 2024 15:26:44 -0700 Subject: [PATCH 17/17] Register external event in current domain This commit allows external events to be registered in a domain to allow processing incoming events. The event is only stored in a cache mapped by event types, and does not update the registry. --- src/protean/domain/__init__.py | 22 ++- tests/event/tests.py | 20 +- tests/workflows/test_event_flows.py | 274 ++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 tests/workflows/test_event_flows.py diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 045db89a..7737ff15 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -8,7 +8,7 @@ import sys from collections import defaultdict from functools import lru_cache -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union from uuid import uuid4 from inflection import parameterize, titleize, transliterate, underscore @@ -846,6 +846,26 @@ def _set_and_record_event_and_command_type(self): self._events_and_commands[type_string] = element.cls + def register_external_event(self, event_cls: Type[BaseEvent], type_string: str): + """Register an external event with the domain. + + When we are consuming an event generated by another Protean domain, we only want + to map the event type to a class. We don't want to add the event to this domain's + registry, since we won't do anything else with this event other than consuming it. + This method simply maps the external event type manually to the event class, + bypassing the type string construction process. + """ + # Ensure class is an event + if ( + not issubclass(event_cls, BaseEvent) + or event_cls.element_type != DomainObjects.EVENT + ): + raise IncorrectUsageError( + {"element": [f"Class `{event_cls.__name__}` is not an Event"]} + ) + + self._events_and_commands[type_string] = event_cls + def _setup_command_handlers(self): for element_type in [DomainObjects.COMMAND_HANDLER]: for _, element in self.registry._elements[element_type.value].items(): diff --git a/tests/event/tests.py b/tests/event/tests.py index 5b5239d7..f642af36 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -3,7 +3,7 @@ import pytest from protean import BaseAggregate, BaseEvent, BaseValueObject -from protean.exceptions import NotSupportedError +from protean.exceptions import IncorrectUsageError, NotSupportedError from protean.fields import Identifier, String, ValueObject from protean.reflection import data_fields, declared_fields, fields from protean.utils import fully_qualified_name @@ -138,6 +138,24 @@ def test_domain_stores_event_type_for_easy_retrieval(self, test_domain): assert PersonAdded.__type__ in test_domain._events_and_commands + def test_registering_external_event(self, test_domain): + class ExternalEvent(BaseEvent): + foo = String() + + test_domain.register_external_event(ExternalEvent, "Bar.ExternalEvent.v1") + + assert "Bar.ExternalEvent.v1" in test_domain._events_and_commands + assert fully_qualified_name(ExternalEvent) not in test_domain.registry.events + + def test_registering_invalid_external_event_class(self, test_domain): + class Dummy: + pass + + with pytest.raises(IncorrectUsageError) as exc: + test_domain.register_external_event(Dummy, "Bar.ExternalEvent.v1") + + assert exc.value.messages == {"element": ["Class `Dummy` is not an Event"]} + class TestDomainEventEquivalence: @pytest.fixture(autouse=True) diff --git a/tests/workflows/test_event_flows.py b/tests/workflows/test_event_flows.py new file mode 100644 index 00000000..1a13b56e --- /dev/null +++ b/tests/workflows/test_event_flows.py @@ -0,0 +1,274 @@ +""" +Event Consumption flows: +1. Event Handler on same Aggregate +2. Event Handler on different Aggregate +3. Event Handler on different Domain +""" + +import asyncio +from datetime import datetime, timezone + +import pytest + +from protean import ( + BaseAggregate, + BaseCommand, + BaseCommandHandler, + BaseEntity, + BaseEvent, + BaseEventHandler, + BaseValueObject, + BaseView, + Domain, + handle, +) +from protean.exceptions import ObjectNotFoundError +from protean.fields import ( + Date, + DateTime, + Float, + HasMany, + Identifier, + Integer, + List, + String, + ValueObject, +) +from protean.globals import current_domain +from protean.server import Engine +from protean.utils import CommandProcessing, EventProcessing + + +class Order(BaseAggregate): + customer_id = Identifier(required=True) + items = HasMany("OrderItem") + total = Float(required=True) + ordered_at = DateTime(default=lambda: datetime.now(timezone.utc)) + + +class OrderItem(BaseEntity): + product_id = Identifier(required=True) + price = Float(required=True) + quantity = Integer(required=True) + + +# FIXME Auto-generate ValueObject from Entity? +class OrderItemValueObject(BaseValueObject): + product_id = Identifier(required=True) + price = Float(required=True) + quantity = Integer(required=True) + + +class PlaceOrder(BaseCommand): + order_id = Identifier(identifier=True) + customer_id = Identifier(required=True) + items = List(content_type=ValueObject(OrderItemValueObject)) + total = Float(required=True) + ordered_at = DateTime(required=True) + + +class OrderPlaced(BaseEvent): + order_id = Identifier(identifier=True) + customer_id = Identifier(required=True) + items = List(content_type=ValueObject(OrderItemValueObject)) + total = Float(required=True) + ordered_at = DateTime(required=True) + + +class OrdersCommandHandler(BaseCommandHandler): + @handle(PlaceOrder) + def place_order(self, command: PlaceOrder): + # FIXME Cumbersome conversion to and from OrderItemValueObject + items = [OrderItem(**item.to_dict()) for item in command.items] + order = Order( + id=command.order_id, + customer_id=command.customer_id, + items=items, + total=command.total, + ordered_at=command.ordered_at, + ) + order.raise_( + OrderPlaced( + order_id=order.id, + customer_id=order.customer_id, + items=command.items, + total=order.total, + ordered_at=order.ordered_at, + ) + ) + current_domain.repository_for(Order).add(order) + + +class DailyOrders(BaseView): + date = Date(identifier=True) + total = Integer(required=True) + + +class OrdersEventHandler(BaseEventHandler): + @handle(OrderPlaced) + def order_placed(self, event: OrderPlaced): + try: + view = current_domain.repository_for(DailyOrders).get( + event.ordered_at.date() + ) + except ObjectNotFoundError: + view = DailyOrders(date=event.ordered_at.date(), total=1) + current_domain.repository_for(DailyOrders).add(view) + + +class Customer(BaseAggregate): + name = String(required=True) + order_history = HasMany("OrderHistory") + + +class OrderHistory(BaseEntity): + order_id = Identifier(identifier=True) + items = List(content_type=ValueObject(OrderItemValueObject)) + total = Float(required=True) + ordered_at = DateTime(required=True) + + +class CustomerOrderEventHandler(BaseEventHandler): + @handle(OrderPlaced) + def order_placed(self, event: OrderPlaced): + customer = current_domain.repository_for(Customer).get(event.customer_id) + order_history = OrderHistory( + order_id=event.order_id, + items=event.items, + total=event.total, + ordered_at=event.ordered_at, + ) + customer.add_order_history(order_history) + current_domain.repository_for(Customer).add(customer) + + +class Shipment(BaseAggregate): + order_id = Identifier(required=True) + customer_id = Identifier(required=True) + items = List(content_type=ValueObject(OrderItemValueObject)) + status = String( + choices=["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"], default="PENDING" + ) + shipped_at = DateTime() + + +class ShipmentEventHandler(BaseEventHandler): + @handle(OrderPlaced) + def order_placed(self, event: OrderPlaced): + shipment = Shipment( + order_id=event.order_id, + customer_id=event.customer_id, + items=event.items, + ) + current_domain.repository_for(Shipment).add(shipment) + + +@pytest.fixture +def test_domain(): + test_domain = Domain(__file__, "Test") + + test_domain.config["event_store"] = { + "provider": "message_db", + "database_uri": "postgresql://message_store@localhost:5433/message_store", + } + test_domain.config["command_processing"] = CommandProcessing.ASYNC.value + test_domain.config["event_processing"] = EventProcessing.ASYNC.value + + test_domain.register(Order) + test_domain.register(OrderItem, part_of=Order) + test_domain.register(PlaceOrder, part_of=Order) + test_domain.register(OrderPlaced, part_of=Order) + test_domain.register(OrdersCommandHandler, part_of=Order) + test_domain.register(OrdersEventHandler, part_of=Order) + test_domain.register(DailyOrders) + + test_domain.register(Customer) + test_domain.register(OrderHistory, part_of=Customer) + test_domain.register( + CustomerOrderEventHandler, part_of=Customer, stream_category="test::order" + ) + test_domain.init(traverse=False) + + yield test_domain + + +@pytest.fixture +def shipment_domain(): + shipment_domain = Domain(__file__, "Shipment") + + shipment_domain.config["event_store"] = { + "provider": "message_db", + "database_uri": "postgresql://message_store@localhost:5433/message_store", + } + shipment_domain.config["command_processing"] = CommandProcessing.ASYNC.value + shipment_domain.config["event_processing"] = EventProcessing.ASYNC.value + + shipment_domain.register(Shipment) + shipment_domain.register( + ShipmentEventHandler, part_of=Shipment, stream_category="test::order" + ) + + # Set up external event in the shipment domain + # This is the case when both domains in play are built in Protean + shipment_domain.register_external_event(OrderPlaced, "Test.OrderPlaced.v1") + + shipment_domain.init(traverse=False) + + yield shipment_domain + + +@pytest.mark.message_db +def test_workflow_among_protean_domains(test_domain, shipment_domain): + with test_domain.domain_context(): + customer = Customer(id="1", name="John Doe") + test_domain.repository_for(Customer).add(customer) + + # Initiate command + command = PlaceOrder( + order_id="1", + customer_id="1", + items=[OrderItemValueObject(product_id="1", price=100.0, quantity=1)], + total=100.0, + ordered_at=datetime.now(timezone.utc), + ) + test_domain.process(command) + + # Start server and process command + engine = Engine(domain=test_domain, test_mode=True) + engine.run() + + # Check effects + + # Event Handler on same aggregate updates view. + view = test_domain.repository_for(DailyOrders).get(command.ordered_at.date()) + assert view.total == 1 + assert view.date == command.ordered_at.date() + + # Event Handler on different aggregate updates history. + refreshed_customer = test_domain.repository_for(Customer).get(customer.id) + assert len(refreshed_customer.order_history) == 1 + + # Event Handler on different domain creates a new aggregate. + # Simulate Engine running in another domain + with shipment_domain.domain_context(): + # Create a new event loop + new_loop = asyncio.new_event_loop() + + # Set the new event loop as the current event loop + asyncio.set_event_loop(new_loop) + + engine = Engine(domain=shipment_domain, test_mode=True) + engine.run() + + shipments = ( + shipment_domain.repository_for(Shipment) + ._dao.query.filter(order_id=command.order_id) + .all() + .items + ) + assert len(shipments) == 1 + assert shipments[0].order_id == command.order_id + assert shipments[0].customer_id == command.customer_id + assert shipments[0].items == command.items + assert shipments[0].status == "PENDING" + assert shipments[0].shipped_at is None