From 8302be00633fbc6c293101c67de6eae2f5e8dc01 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 4 Jul 2024 11:28:40 -0700 Subject: [PATCH 1/4] Stream Enhancements Contains: - Unify metadata in Event and Command objects - Remove `MessageMetadata` class - Build Message metadata directly from event/command object - Introduce `kind`, `stream_name`, and `origin_stream_name` to metadata - Initialize event store in `init` - Don't build stream name in different places. Use `.meta_stream_name` --- docs/core-concepts/building-blocks/events.md | 11 ++ docs/stylesheets/extra.css | 2 +- src/protean/adapters/event_store/__init__.py | 66 ++++-------- src/protean/adapters/event_store/memory.py | 5 +- src/protean/container.py | 11 +- src/protean/core/command.py | 45 +++++++- src/protean/core/entity.py | 9 +- src/protean/core/event.py | 41 ++++++- src/protean/domain/__init__.py | 1 + src/protean/utils/mixins.py | 83 ++++----------- .../test_memory_message_repository.py | 1 - .../message_db_event_store/tests.py | 5 + tests/adapters/model/dict_model/tests.py | 1 + .../events/test_aggregate_streams.py | 100 ++++++++++++++++++ ..._event_regular_metadata_id_and_sequence.py | 31 ++---- .../test_raising_aggregate_events.py} | 0 .../events/test_raising_fact_events.py | 15 +-- .../test_inline_command_processing.py | 2 + .../test_retrieving_handlers_by_command.py | 2 + tests/conftest.py | 6 +- tests/domain/test_domain_traversal.py | 12 ++- tests/domain/test_init.py | 96 ++++++++++------- tests/email_provider/elements.py | 4 +- tests/entity/test_lifecycle_methods.py | 6 ++ tests/event/test_event_metadata.py | 8 +- tests/event/test_event_payload.py | 6 +- tests/event/test_stream_name_derivation.py | 35 ++++++ tests/event/tests.py | 4 + .../test_consuming_fact_events.py | 30 ++++++ .../test_retrieving_handlers_by_event.py | 3 + .../test_event_es_metadata_id_and_sequence.py | 2 +- .../events/test_fact_event_generation.py | 6 +- .../test_generated_event_version.py | 14 +-- tests/event_store/test_appending_commands.py | 1 + tests/event_store/test_appending_events.py | 1 + tests/event_store/test_deriving_category.py | 2 + ...test_event_store_adapter_initialization.py | 2 +- ...test_inline_event_processing_on_publish.py | 1 + tests/event_store/test_reading_all_streams.py | 2 + .../test_streams_initialization.py | 3 +- tests/message/test_object_to_message.py | 4 - .../test_origin_stream_name_in_metadata.py | 11 +- ...st_message_filtering_with_origin_stream.py | 7 +- .../subscription/test_no_message_filtering.py | 7 +- .../test_inline_event_processing.py | 1 + .../test_nested_inline_event_processing.py | 1 + 46 files changed, 473 insertions(+), 233 deletions(-) create mode 100644 tests/aggregate/events/test_aggregate_streams.py rename tests/aggregate/{test_aggregate_events.py => events/test_raising_aggregate_events.py} (100%) create mode 100644 tests/event/test_stream_name_derivation.py create mode 100644 tests/event_handler/test_consuming_fact_events.py diff --git a/docs/core-concepts/building-blocks/events.md b/docs/core-concepts/building-blocks/events.md index 1fce445f..e8277ee2 100644 --- a/docs/core-concepts/building-blocks/events.md +++ b/docs/core-concepts/building-blocks/events.md @@ -7,6 +7,11 @@ consistent and informed. ## Facts +### Events are always associated with aggregates. { data-toc-label="Linked to Aggregates" } +An event is always associated to the aggregate that emits it. Events of an +event type are emitted to the aggregate stream that the event type is +associated with. + ### Events are essentially Data Transfer Objects (DTO). { data-toc-label="Data Transfer Objects" } They can only hold simple fields and Value Objects. @@ -97,6 +102,11 @@ or notifying external consumers for choice events, like `LowInventoryAlert`. They are also appropriate for composing a custom view of the state based on events (for example in Command Query Resource Separation). +#### Multiple Event Types + +Aggregates usually emit events of multiple delta event types. Each event +is individually associated with the aggregate. + ### Fact Events A fact event encloses the entire state of the aggregate at that specific point @@ -112,6 +122,7 @@ multiple delta event types, which can be risky and error-prone, especially as data schemas evolve and change over time. Instead, they rely on the owning service to compute and produce a fully detailed fact event. + ## Persistence ### Events are stored in an Event Store. { data-toc-label="Event Store" } diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index ce767212..df67b07f 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -23,7 +23,7 @@ p a, article ul li a { } /* Primary color and tighter space */ -.md-typeset h1, .md-typeset h2, .md-typeset h3 { +.md-typeset h1, .md-typeset h2, .md-typeset h3, .md-typeset h4 { color: var(--md-primary-fg-color); letter-spacing: -.025em; } diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index c460285d..4e21d45c 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -31,38 +31,32 @@ def __init__(self, domain): @property def store(self): - if self._event_store is None: - self._initialize() - return self._event_store def _initialize(self): - if not self._event_store: - logger.debug("Initializing Event Store...") - - configured_event_store = self.domain.config["event_store"] - if configured_event_store and isinstance(configured_event_store, dict): - event_store_full_path = EVENT_STORE_PROVIDERS[ - configured_event_store["provider"] - ] - event_store_module, event_store_class = event_store_full_path.rsplit( - ".", maxsplit=1 - ) + logger.debug("Initializing Event Store...") + + configured_event_store = self.domain.config["event_store"] + if configured_event_store and isinstance(configured_event_store, dict): + event_store_full_path = EVENT_STORE_PROVIDERS[ + configured_event_store["provider"] + ] + event_store_module, event_store_class = event_store_full_path.rsplit( + ".", maxsplit=1 + ) - event_store_cls = getattr( - importlib.import_module(event_store_module), event_store_class - ) + event_store_cls = getattr( + importlib.import_module(event_store_module), event_store_class + ) - store = event_store_cls(self.domain, configured_event_store) - else: - raise ConfigurationError( - "Configure at least one event store in the domain" - ) + store = event_store_cls(self.domain, configured_event_store) + else: + raise ConfigurationError("Configure at least one event store in the domain") - self._event_store = store + self._event_store = store - self._initialize_event_streams() - self._initialize_command_streams() + self._initialize_event_streams() + self._initialize_command_streams() return self._event_store @@ -85,9 +79,6 @@ def _initialize_command_streams(self): ) def repository_for(self, part_of): - if self._event_store is None: - self._initialize() - repository_cls = type( part_of.__name__ + "Repository", (BaseEventSourcedRepository,), {} ) @@ -97,20 +88,12 @@ def repository_for(self, part_of): return repository_cls(self.domain) def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: - if self._event_streams is None: - self._initialize_event_streams() - + """Return all handlers configured to run on the given event.""" + # Gather handlers configured to run on all events all_stream_handlers = self._event_streams.get("$all", set()) - # Get the Aggregate's stream_name - aggregate_stream_name = None - if event.meta_.aggregate_cluster: - aggregate_stream_name = event.meta_.aggregate_cluster.meta_.stream_name - - stream_name = event.meta_.stream_name or aggregate_stream_name - stream_handlers = self._event_streams.get(stream_name, set()) - - # Gather all handlers that are configured to run on this event + # Gather all handlers configured to run on this event + stream_handlers = self._event_streams.get(event.meta_.stream_name, set()) configured_stream_handlers = set() for stream_handler in stream_handlers: if fqn(event.__class__) in stream_handler._handlers: @@ -119,9 +102,6 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: return set.union(configured_stream_handlers, all_stream_handlers) def command_handler_for(self, command: BaseCommand) -> Optional[BaseCommandHandler]: - if self._command_streams is None: - self._initialize_command_streams() - stream_name = command.meta_.stream_name or ( command.meta_.part_of.meta_.stream_name if command.meta_.part_of else None ) diff --git a/src/protean/adapters/event_store/memory.py b/src/protean/adapters/event_store/memory.py index 30dff9ec..1317649d 100644 --- a/src/protean/adapters/event_store/memory.py +++ b/src/protean/adapters/event_store/memory.py @@ -2,10 +2,11 @@ from typing import Any, Dict, List from protean.core.aggregate import BaseAggregate +from protean.core.event import Metadata from protean.core.repository import BaseRepository from protean.globals import current_domain from protean.port.event_store import BaseEventStore -from protean.utils.mixins import MessageMetadata, MessageRecord +from protean.utils.mixins import MessageRecord class MemoryMessage(BaseAggregate, MessageRecord): @@ -52,7 +53,7 @@ def write( position=next_position, type=message_type, data=data, - metadata=MessageMetadata(**metadata) if metadata else None, + metadata=metadata, time=datetime.now(UTC), ) ) diff --git a/src/protean/container.py b/src/protean/container.py index 9dd85355..edbfec3a 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -13,7 +13,7 @@ ValidationError, ) from protean.fields import Auto, Field, FieldBase, ValueObject -from protean.globals import current_domain +from protean.globals import g from protean.reflection import id_field from protean.utils import generate_identity @@ -396,10 +396,11 @@ def raise_(self, event, fact_event=False) -> None: event_with_metadata = event.__class__( event.to_dict(), _metadata={ - "id": ( - f"{current_domain.name}.{self.__class__.__name__}.{event._metadata.version}" - f".{identifier}.{self._version}" - ), + "id": (f"{self.meta_.stream_name}-{identifier}-{self._version}"), + "type": f"{self.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", + "kind": "EVENT", + "stream_name": self.meta_.stream_name, + "origin_stream_name": event._metadata.origin_stream_name, "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 e846e566..62da2aad 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -1,11 +1,13 @@ from protean.container import BaseContainer, OptionsMixin +from protean.core.event import Metadata from protean.exceptions import ( IncorrectUsageError, InvalidDataError, NotSupportedError, ValidationError, ) -from protean.fields import Field +from protean.fields import Field, ValueObject +from protean.globals import g from protean.reflection import _ID_FIELD_NAME, declared_fields from protean.utils import DomainObjects, derive_element_class @@ -24,6 +26,9 @@ def __new__(cls, *args, **kwargs): raise NotSupportedError("BaseCommand cannot be instantiated") return super().__new__(cls) + # Track Metadata + _metadata = ValueObject(Metadata, default=lambda: Metadata()) # pragma: no cover + def __init_subclass__(subclass) -> None: super().__init_subclass__() @@ -32,7 +37,30 @@ def __init_subclass__(subclass) -> None: def __init__(self, *args, **kwargs): try: - super().__init__(*args, **kwargs) + super().__init__(*args, finalize=False, **kwargs) + + version = ( + self.__class__.__version__ + if hasattr(self.__class__, "__version__") + else "v1" + ) + + origin_stream_name = None + if hasattr(g, "message_in_context"): + if g.message_in_context.metadata.kind == "EVENT": + origin_stream_name = g.message_in_context.stream_name + + # Value Objects are immutable, so we create a clone/copy and associate it + self._metadata = Metadata( + self._metadata.to_dict(), # Template + kind="COMMAND", + origin_stream_name=origin_stream_name, + version=version, + ) + + # Finally lock the event and make it immutable + self._initialized = True + except ValidationError as exception: raise InvalidDataError(exception.messages) @@ -50,11 +78,22 @@ def __setattr__(self, name, value): @classmethod def _default_options(cls): + part_of = ( + getattr(cls.meta_, "part_of") if hasattr(cls.meta_, "part_of") else None + ) + + # This method is called during class import, so we cannot use part_of if it + # is still a string. We ignore it for now, and resolve `stream_name` later + # when the domain has resolved references. + # FIXME A better mechanism would be to not set stream_name here, unless explicitly + # specified, and resolve it during `domain.init()` + part_of = None if isinstance(part_of, str) else part_of + return [ ("abstract", False), ("aggregate_cluster", None), ("part_of", None), - ("stream_name", None), + ("stream_name", part_of.meta_.stream_name if part_of else None), ] @classmethod diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index e8134876..0e1ad4ab 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -11,7 +11,7 @@ from protean.exceptions import IncorrectUsageError, NotSupportedError, ValidationError from protean.fields import Auto, HasMany, Reference, ValueObject from protean.fields.association import Association -from protean.globals import current_domain +from protean.globals import g from protean.reflection import ( _FIELDS, attributes, @@ -449,9 +449,12 @@ def raise_(self, event) -> None: event.to_dict(), _metadata={ "id": ( - f"{current_domain.name}.{self.__class__.__name__}.{event._metadata.version}" - f".{identifier}.{aggregate_version}.{event_number}" + f"{self._root.meta_.stream_name}-{identifier}-{aggregate_version}.{event_number}" ), + "type": f"{self._root.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", + "kind": "EVENT", + "stream_name": self._root.meta_.stream_name, + "origin_stream_name": event._metadata.origin_stream_name, "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 3c015f4b..e7e454d8 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -6,6 +6,7 @@ from protean.core.value_object import BaseValueObject from protean.exceptions import 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 from protean.utils import DomainObjects, derive_element_class @@ -17,6 +18,20 @@ class Metadata(BaseValueObject): # Format is .... id = String() + # Type of the event + # Format is .. + type = String() + + # Kind of the object + # Can be one of "EVENT", "COMMAND" + kind = String() + + # Name of the stream to which the event/command is written + stream_name = String() + + # Name of the stream that originated this event/command + origin_stream_name = String() + # Time of event generation timestamp = DateTime(default=lambda: datetime.now(timezone.utc)) @@ -114,11 +129,27 @@ def __track_id_field(subclass): def __init__(self, *args, **kwargs): super().__init__(*args, finalize=False, **kwargs) - if hasattr(self.__class__, "__version__"): - # Value Objects are immutable, so we create a clone/copy and associate it - self._metadata = Metadata( - self._metadata.to_dict(), version=self.__class__.__version__ - ) + version = ( + self.__class__.__version__ + if hasattr(self.__class__, "__version__") + else "v1" + ) + + origin_stream_name = 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 + ): + origin_stream_name = g.message_in_context.metadata.origin_stream_name + + # Value Objects are immutable, so we create a clone/copy and associate it + self._metadata = Metadata( + self._metadata.to_dict(), # Template + kind="EVENT", + origin_stream_name=origin_stream_name, + version=version, + ) # Finally lock the event and make it immutable self._initialized = True diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 356b775a..a0192590 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -298,6 +298,7 @@ def _initialize(self): self.providers._initialize() self.caches._initialize() self.brokers._initialize() + self.event_store._initialize() def make_config(self): """Used to construct the config; invoked by the Domain constructor.""" diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index d541e598..35b6191b 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -10,7 +10,7 @@ from protean import fields from protean.container import BaseContainer, OptionsMixin from protean.core.command import BaseCommand -from protean.core.event import BaseEvent +from protean.core.event import BaseEvent, Metadata from protean.core.event_sourced_aggregate import BaseEventSourcedAggregate from protean.core.unit_of_work import UnitOfWork from protean.core.value_object import BaseValueObject @@ -28,26 +28,6 @@ class MessageType(Enum): READ_POSITION = "READ_POSITION" -class MessageMetadata(BaseValueObject): - # Marks message as a `COMMAND` or an `EVENT` - kind = fields.String(required=True, max_length=15, choices=MessageType) - - # Name of service that owns the contract of the message - owner = fields.String(max_length=50) - - # Allows for parsing of different versions, in case of - # breaking changes. - schema_version = fields.Integer() - - # `origin_stream_name` helps keep track of the origin of a message. - # A command created by an event is automatically associated with the original stream. - # Events raised subsequently by the commands also carry forward the original stream name. - origin_stream_name = fields.String() - - # FIXME Provide mechanism to add custom metadata fields/structure - # Can come handy in case of multi-tenancy, etc. - - class MessageRecord(BaseContainer): """ Base Container holding all fields of a message. @@ -77,7 +57,7 @@ class MessageRecord(BaseContainer): data = fields.Dict() # JSON representation of the message metadata - metadata = fields.ValueObject(MessageMetadata) + metadata = fields.ValueObject(Metadata) class Message(MessageRecord, OptionsMixin): # FIXME Remove OptionsMixin @@ -120,7 +100,7 @@ def from_dict(cls, message: Dict) -> Message: stream_name=message["stream_name"], type=message["type"], data=message["data"], - metadata=MessageMetadata(**message["metadata"]), + metadata=message["metadata"], position=message["position"], global_position=message["global_position"], time=message["time"], @@ -133,35 +113,25 @@ def to_aggregate_event_message( ) -> Message: identifier = getattr(aggregate, id_field(aggregate).field_name) - # Take the Aggregate's stream_name - aggregate_stream_name = None - if event.meta_.aggregate_cluster: - aggregate_stream_name = event.meta_.aggregate_cluster.meta_.stream_name - - # Use explicit stream name if provided, or fallback on Aggregate's stream name - stream_name = event.meta_.stream_name or aggregate_stream_name - - if not stream_name: + if not event.meta_.stream_name: raise ConfigurationError( f"No stream name found for `{event.__class__.__name__}`. " "Either specify an explicit stream name or associate the event with an aggregate." ) + # If this is a Fact Event, don't set an expected version. + # Otherwise, expect the previous version + if event.__class__.__name__.endswith("FactEvent"): + expected_version = None + else: + expected_version = int(event._metadata.sequence_id) - 1 + return cls( - stream_name=f"{stream_name}-{identifier}", + stream_name=f"{event.meta_.stream_name}-{identifier}", type=fully_qualified_name(event.__class__), data=event.to_dict(), - metadata=MessageMetadata( - kind=MessageType.EVENT.value, - owner=current_domain.name, - **cls.derived_metadata(MessageType.EVENT.value), - # schema_version=event.meta_.version, # FIXME Maintain version for event - ), - # If this is a Fact Event, don't set an expected version. - # Otherwise, expect the previous version - expected_version=None - if event.__class__.__name__.endswith("FactEvent") - else int(event._metadata.sequence_id) - 1, + metadata=event._metadata, + expected_version=expected_version, ) def to_object(self) -> Union[BaseEvent, BaseCommand]: @@ -186,22 +156,16 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: else: identifier = str(uuid4()) - # Take the Aggregate's stream_name - aggregate_stream_name = None - if message_object.meta_.aggregate_cluster: - aggregate_stream_name = ( - message_object.meta_.aggregate_cluster.meta_.stream_name + if not message_object.meta_.stream_name: + raise ConfigurationError( + f"No stream name found for `{message_object.__class__.__name__}`. " + "Either specify an explicit stream name or associate the event with an aggregate." ) - # Use explicit stream name if provided, or fallback on Aggregate's stream name - stream_name = message_object.meta_.stream_name or aggregate_stream_name - if isinstance(message_object, BaseEvent): - stream_name = f"{stream_name}-{identifier}" - kind = MessageType.EVENT.value + stream_name = f"{message_object.meta_.stream_name}-{identifier}" elif isinstance(message_object, BaseCommand): - stream_name = f"{stream_name}:command-{identifier}" - kind = MessageType.COMMAND.value + stream_name = f"{message_object.meta_.stream_name}:command-{identifier}" else: raise NotImplementedError # FIXME Handle unknown messages better @@ -209,12 +173,7 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: stream_name=stream_name, type=fully_qualified_name(message_object.__class__), data=message_object.to_dict(), - metadata=MessageMetadata( - kind=kind, - owner=current_domain.name, - **cls.derived_metadata(kind), - ), - # schema_version=command.meta_.version, # FIXME Maintain version + metadata=message_object._metadata, ) diff --git a/tests/adapters/event_store/memory_event_store/test_memory_message_repository.py b/tests/adapters/event_store/memory_event_store/test_memory_message_repository.py index 99e78611..8fd995cc 100644 --- a/tests/adapters/event_store/memory_event_store/test_memory_message_repository.py +++ b/tests/adapters/event_store/memory_event_store/test_memory_message_repository.py @@ -2,7 +2,6 @@ def test_is_category(test_domain): - test_domain.event_store.store # Establish connection to event store repo = test_domain.repository_for(MemoryMessage) assert repo.is_category("testStream-123") is False assert repo.is_category("testStream") is True diff --git a/tests/adapters/event_store/message_db_event_store/tests.py b/tests/adapters/event_store/message_db_event_store/tests.py index 6d16f60b..b01c66f2 100644 --- a/tests/adapters/event_store/message_db_event_store/tests.py +++ b/tests/adapters/event_store/message_db_event_store/tests.py @@ -7,6 +7,10 @@ @pytest.mark.message_db class TestMessageDBEventStore: + @pytest.fixture(autouse=True) + def initialize_domain(self, test_domain): + test_domain.init(traverse=False) + def test_retrieving_message_store_from_domain(self, test_domain): assert test_domain.event_store is not None assert test_domain.event_store.store is not None @@ -18,6 +22,7 @@ def test_error_on_message_db_initialization(self): domain.config["event_store"]["database_uri"] = ( "postgresql://message_store@localhost:5433/dummy" ) + domain.init(traverse=False) with pytest.raises(ConfigurationError) as exc: domain.event_store.store._write( diff --git a/tests/adapters/model/dict_model/tests.py b/tests/adapters/model/dict_model/tests.py index 93fc2c4c..ae4d5de0 100644 --- a/tests/adapters/model/dict_model/tests.py +++ b/tests/adapters/model/dict_model/tests.py @@ -9,6 +9,7 @@ class TestModel: @pytest.fixture(autouse=True) def register_person_aggregate(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) def test_that_model_class_is_created_automatically(self, test_domain): model_cls = test_domain.repository_for(Person)._model diff --git a/tests/aggregate/events/test_aggregate_streams.py b/tests/aggregate/events/test_aggregate_streams.py new file mode 100644 index 00000000..4d818c0e --- /dev/null +++ b/tests/aggregate/events/test_aggregate_streams.py @@ -0,0 +1,100 @@ +import pytest + +from protean import BaseAggregate, BaseEntity, BaseEvent +from protean.fields import HasOne, Identifier, String +from protean.utils.mixins import Message + + +class Account(BaseEntity): + password_hash = String(max_length=512) + + def change_password(self, password): + self.password_hash = password + self.raise_(PasswordChanged(account_id=self.id, user_id=self.user_id)) + + +class PasswordChanged(BaseEvent): + account_id = Identifier(required=True) + user_id = Identifier(required=True) + + +class User(BaseAggregate): + name = String(max_length=50, required=True) + email = String(required=True) + status = String(choices=["ACTIVE", "ARCHIVED"]) + + account = HasOne(Account) + + def activate(self): + self.raise_(UserActivated(user_id=self.id)) + + def change_name(self, name): + self.raise_(UserRenamed(user_id=self.id, name=name)) + + +class UserActivated(BaseEvent): + user_id = Identifier(identifier=True) + + +class UserRenamed(BaseEvent): + user_id = Identifier(identifier=True) + name = String(required=True, max_length=50) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User, fact_events=True) + test_domain.register(Account, part_of=User) + test_domain.register(UserActivated, part_of=User) + test_domain.register(UserRenamed, part_of=User) + test_domain.register(PasswordChanged, part_of=User) + test_domain.init(traverse=False) + + +class TestDeltaEvents: + def test_aggregate_stream_name(self): + assert User.meta_.stream_name == "user" + + def test_event_metadata(self): + user = User(name="John Doe", email="john.doe@example.com") + user.change_name("Jane Doe") + user.activate() + + 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.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.version == "v1" + assert user._events[1]._metadata.sequence_id == "0.2" + + def test_event_stream_name_in_message(self): + user = User(name="John Doe", email="john.doe@example.com") + user.change_name("Jane Doe") + + message = Message.to_message(user._events[0]) + + assert message.stream_name == f"user-{user.id}" + + def test_event_metadata_from_stream(self, test_domain): + user = User(name="John Doe", email="john.doe@example.com") + user.change_name("Jane Doe") + user.activate() + + test_domain.repository_for(User).add(user) + + event_messages = test_domain.event_store.store.read(f"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.type == "User.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.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 f1bd2f12..4b5453bd 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"Test.User.v1.{user.id}.0.1" + assert user._events[0]._metadata.id == f"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"Test.User.v1.{user.id}.0.1" + assert user._events[0]._metadata.id == f"user-{user.id}-0.1" assert user._events[0]._metadata.sequence_id == "0.1" - assert user._events[1]._metadata.id == f"Test.User.v1.{user.id}.0.2" + assert user._events[1]._metadata.id == f"user-{user.id}-0.2" assert user._events[1]._metadata.sequence_id == "0.2" @@ -79,10 +79,7 @@ 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"Test.User.v1.{refreshed_user.id}.1.1" - ) + assert refreshed_user._events[0]._metadata.id == f"user-{refreshed_user.id}-1.1" assert refreshed_user._events[0]._metadata.sequence_id == "1.1" @@ -95,15 +92,9 @@ 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"Test.User.v1.{refreshed_user.id}.1.1" - ) + assert refreshed_user._events[0]._metadata.id == f"user-{refreshed_user.id}-1.1" assert refreshed_user._events[0]._metadata.sequence_id == "1.1" - assert ( - refreshed_user._events[1]._metadata.id - == f"Test.User.v1.{refreshed_user.id}.1.2" - ) + assert refreshed_user._events[1]._metadata.id == f"user-{refreshed_user.id}-1.2" assert refreshed_user._events[1]._metadata.sequence_id == "1.2" @@ -121,13 +112,7 @@ 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"Test.User.v1.{refreshed_user.id}.2.1" - ) + assert refreshed_user._events[0]._metadata.id == f"user-{refreshed_user.id}-2.1" assert refreshed_user._events[0]._metadata.sequence_id == "2.1" - assert ( - refreshed_user._events[1]._metadata.id - == f"Test.User.v1.{refreshed_user.id}.2.2" - ) + assert refreshed_user._events[1]._metadata.id == f"user-{refreshed_user.id}-2.2" assert refreshed_user._events[1]._metadata.sequence_id == "2.2" diff --git a/tests/aggregate/test_aggregate_events.py b/tests/aggregate/events/test_raising_aggregate_events.py similarity index 100% rename from tests/aggregate/test_aggregate_events.py rename to tests/aggregate/events/test_raising_aggregate_events.py diff --git a/tests/aggregate/events/test_raising_fact_events.py b/tests/aggregate/events/test_raising_fact_events.py index cf4f3813..e8952276 100644 --- a/tests/aggregate/events/test_raising_fact_events.py +++ b/tests/aggregate/events/test_raising_fact_events.py @@ -1,26 +1,19 @@ import pytest -from protean import BaseAggregate, BaseEntity -from protean.fields import HasOne, String +from protean import BaseAggregate +from protean.fields import String from protean.utils.mixins import Message -class Account(BaseEntity): - password_hash = String(max_length=512) - - class User(BaseAggregate): name = String(max_length=50, required=True) email = String(required=True) status = String(choices=["ACTIVE", "ARCHIVED"]) - account = HasOne(Account) - @pytest.fixture(autouse=True) def register_elements(test_domain): test_domain.register(User, fact_events=True) - test_domain.register(Account, part_of=User) test_domain.init(traverse=False) @@ -47,7 +40,7 @@ def test_generation_of_first_fact_event_on_persistence(event): def test_fact_event_version_metadata(event): - assert event._metadata.id.endswith(".0.1") + assert event._metadata.id.endswith("-0.1") assert event._metadata.sequence_id == "0.1" assert event._version == 0 @@ -67,6 +60,6 @@ def test_fact_event_version_metadata_after_second_edit(test_domain): # Deserialize event event = Message.to_object(event_messages[1]) - assert event._metadata.id.endswith(".1.1") + assert event._metadata.id.endswith("-1.1") assert event._metadata.sequence_id == "1.1" assert event._version == 1 diff --git a/tests/command_handler/test_inline_command_processing.py b/tests/command_handler/test_inline_command_processing.py index e1c8290e..958e0d9f 100644 --- a/tests/command_handler/test_inline_command_processing.py +++ b/tests/command_handler/test_inline_command_processing.py @@ -29,6 +29,7 @@ def register(self, event: Register) -> None: def test_that_command_can_be_processed_inline(test_domain): test_domain.register(User) test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) assert test_domain.config["command_processing"] == CommandProcessing.SYNC.value @@ -39,6 +40,7 @@ def test_that_command_can_be_processed_inline(test_domain): def test_that_command_is_persisted_in_message_store(test_domain): test_domain.register(User) test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) 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 922ddb7e..bc735279 100644 --- a/tests/command_handler/test_retrieving_handlers_by_command.py +++ b/tests/command_handler/test_retrieving_handlers_by_command.py @@ -63,6 +63,7 @@ def test_retrieving_handler_by_command(test_domain): test_domain.register(Post) test_domain.register(Create, part_of=Post) test_domain.register(PostCommandHandler, part_of=Post) + test_domain.init(traverse=False) assert test_domain.command_handler_for(Register()) == UserCommandHandlers assert test_domain.command_handler_for(Create()) == PostCommandHandler @@ -82,6 +83,7 @@ def test_error_on_defining_multiple_handlers_for_a_command(test_domain): test_domain.register(User) test_domain.register(UserCommandHandlers, part_of=User) test_domain.register(AdminUserCommandHandlers, part_of=User) + test_domain.init(traverse=False) with pytest.raises(NotSupportedError) as exc: test_domain.command_handler_for(Register()) diff --git a/tests/conftest.py b/tests/conftest.py index 6952e01c..1478917b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,6 +186,9 @@ def test_domain(db_config, store_config, request): domain.config["databases"]["default"] = db_config domain.config["event_store"] = store_config + # We initialize and load default configuration into the domain here + # so that test cases that don't need explicit domain setup can + # still function. domain._initialize() with domain.domain_context(): @@ -232,4 +235,5 @@ def run_around_tests(test_domain): cache = test_domain.caches[cache_name] cache.flush_all() - test_domain.event_store.store._data_reset() + if test_domain.event_store.store: + test_domain.event_store.store._data_reset() diff --git a/tests/domain/test_domain_traversal.py b/tests/domain/test_domain_traversal.py index f9abae7b..c8b67058 100644 --- a/tests/domain/test_domain_traversal.py +++ b/tests/domain/test_domain_traversal.py @@ -22,7 +22,9 @@ def test_loading_domain_with_init(self): assert publishing7.domain is not None publishing7.domain.init() - assert len(publishing7.domain.registry.aggregates) == 1 + assert ( + len(publishing7.domain.registry.aggregates) == 2 + ) # Includes MemoryMessage Aggregate @pytest.mark.no_test_domain def test_loading_nested_domain_with_init(self): @@ -30,7 +32,9 @@ def test_loading_nested_domain_with_init(self): assert publishing13.domain is not None publishing13.domain.init() - assert len(publishing13.domain.registry.aggregates) == 2 + assert ( + len(publishing13.domain.registry.aggregates) == 3 + ) # Includes MemoryMessage Aggregate @pytest.mark.no_test_domain @@ -54,7 +58,7 @@ def test_all_elements_in_nested_structure_are_registered(self): assert domain.name == "Publishing20" domain.init() - assert len(domain.registry.aggregates) == 2 + assert len(domain.registry.aggregates) == 3 # Includes MemoryMessage Aggregate def test_elements_in_folder_with_their_own_toml_are_ignored(self): change_working_directory_to("test21") @@ -64,4 +68,4 @@ def test_elements_in_folder_with_their_own_toml_are_ignored(self): assert domain.name == "Publishing21" domain.init() - assert len(domain.registry.aggregates) == 1 + assert len(domain.registry.aggregates) == 2 # Includes MemoryMessage Aggregate diff --git a/tests/domain/test_init.py b/tests/domain/test_init.py index c28372a2..e63ff12d 100644 --- a/tests/domain/test_init.py +++ b/tests/domain/test_init.py @@ -1,36 +1,60 @@ -import mock - - -def test_domain_init_calls_validate_domain(test_domain): - mock_validate_domain = mock.Mock() - test_domain._validate_domain = mock_validate_domain - test_domain.init(traverse=False) - mock_validate_domain.assert_called_once() - - -def test_domain_init_calls_traverse(test_domain): - mock_traverse = mock.Mock() - test_domain._traverse = mock_traverse - test_domain.init() - mock_traverse.assert_called_once() - - -def test_domain_init_does_not_call_traverse_when_false(test_domain): - mock_traverse = mock.Mock() - test_domain._traverse = mock_traverse - test_domain.init(traverse=False) - mock_traverse.assert_not_called() - - -def test_domain_init_calls_resolve_references(test_domain): - mock_resolve_references = mock.Mock() - test_domain._resolve_references = mock_resolve_references - test_domain.init(traverse=False) - mock_resolve_references.assert_called_once() - - -def test_domain_init_constructs_fact_events(test_domain): - mock_generate_fact_event_classes = mock.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() +from mock import Mock, patch + +from protean.adapters.broker import Brokers +from protean.adapters.cache import Caches +from protean.adapters.event_store import EventStore +from protean.adapters.repository import Providers + + +class TestDomainInitMethodCalls: + def test_domain_init_calls_validate_domain(self, test_domain): + mock_validate_domain = Mock() + test_domain._validate_domain = mock_validate_domain + test_domain.init(traverse=False) + mock_validate_domain.assert_called_once() + + def test_domain_init_calls_traverse(self, test_domain): + mock_traverse = Mock() + test_domain._traverse = mock_traverse + test_domain.init() + mock_traverse.assert_called_once() + + def test_domain_init_does_not_call_traverse_when_false(self, test_domain): + mock_traverse = Mock() + test_domain._traverse = mock_traverse + test_domain.init(traverse=False) + mock_traverse.assert_not_called() + + def test_domain_init_calls_resolve_references(self, test_domain): + mock_resolve_references = Mock() + test_domain._resolve_references = mock_resolve_references + test_domain.init(traverse=False) + mock_resolve_references.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() + + +class TestDomainInitializationCalls: + @patch.object(Providers, "_initialize") + def test_domain_initializes_providers(self, mock_initialize, test_domain): + test_domain._initialize() + mock_initialize.assert_called_once() + + @patch.object(Brokers, "_initialize") + def test_domain_initializes_brokers(self, mock_initialize, test_domain): + test_domain._initialize() + mock_initialize.assert_called_once() + + @patch.object(Caches, "_initialize") + def test_domain_initializes_caches(self, mock_initialize, test_domain): + test_domain._initialize() + mock_initialize.assert_called_once() + + @patch.object(EventStore, "_initialize") + def test_domain_initializes_event_store(self, mock_initialize, test_domain): + test_domain._initialize() + mock_initialize.assert_called_once() diff --git a/tests/email_provider/elements.py b/tests/email_provider/elements.py index 2cda760b..8cb09a19 100644 --- a/tests/email_provider/elements.py +++ b/tests/email_provider/elements.py @@ -21,7 +21,9 @@ def add_newcomer(cls, person_dict): ) # Publish Event via the domain - current_domain.publish(PersonAdded(**newcomer.to_dict())) + payload = newcomer.to_dict() + payload.pop("_version") + current_domain.publish(PersonAdded(**payload)) return newcomer diff --git a/tests/entity/test_lifecycle_methods.py b/tests/entity/test_lifecycle_methods.py index da5a04bf..f42afe81 100644 --- a/tests/entity/test_lifecycle_methods.py +++ b/tests/entity/test_lifecycle_methods.py @@ -8,6 +8,12 @@ class TestDefaults: + @pytest.fixture(autouse=True) + def register_elements(self, test_domain): + test_domain.register(Area) + test_domain.register(Building, part_of=Area) + test_domain.init(traverse=False) + def test_that_building_is_marked_as_done_if_above_4_floors(self): building = Building(name="Foo", floors=4) diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index 5c2f4d67..35fa2cbf 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -79,11 +79,15 @@ def test_event_metadata(): assert event._metadata is not None assert isinstance(event._metadata.timestamp, datetime) - assert event._metadata.id == f"Test.User.v1.{user.id}.0" + assert event._metadata.id == f"user-{user.id}-0" assert event.to_dict() == { "_metadata": { - "id": f"Test.User.v1.{user.id}.0", + "id": f"user-{user.id}-0", + "type": "User.UserLoggedIn.v1", + "kind": "EVENT", + "stream_name": "user", + "origin_stream_name": 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 10eae7ca..5c61b1e8 100644 --- a/tests/event/test_event_payload.py +++ b/tests/event/test_event_payload.py @@ -36,7 +36,11 @@ def test_event_payload(): assert event.to_dict() == { "_metadata": { - "id": f"Test.User.v1.{user_id}.0", + "id": f"user-{user_id}-0", + "type": "User.UserLoggedIn.v1", + "kind": "EVENT", + "stream_name": "user", + "origin_stream_name": None, "timestamp": str(event._metadata.timestamp), "version": "v1", "sequence_id": "0", diff --git a/tests/event/test_stream_name_derivation.py b/tests/event/test_stream_name_derivation.py new file mode 100644 index 00000000..f407442c --- /dev/null +++ b/tests/event/test_stream_name_derivation.py @@ -0,0 +1,35 @@ +import pytest + +from protean import BaseAggregate, BaseEvent +from protean.fields import String +from protean.fields.basic import Identifier + + +class User(BaseAggregate): + email = String() + name = String() + + +class UserLoggedIn(BaseEvent): + user_id = Identifier(identifier=True) + + +def test_stream_name_from_part_of(test_domain): + test_domain.register(User) + test_domain.register(UserLoggedIn, part_of=User) + + assert UserLoggedIn.meta_.stream_name == "user" + + +def test_stream_name_from_explicit_stream_name_in_aggregate(test_domain): + test_domain.register(User, stream_name="authentication") + test_domain.register(UserLoggedIn, part_of=User) + + assert UserLoggedIn.meta_.stream_name == "authentication" + + +def test_stream_name_from_explicit_stream_name(test_domain): + test_domain.register(User) + test_domain.register(UserLoggedIn, stream_name="authentication") + + assert UserLoggedIn.meta_.stream_name == "authentication" diff --git a/tests/event/tests.py b/tests/event/tests.py index 4dee98e7..ad4bf6e6 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -46,6 +46,10 @@ 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 + "kind": "EVENT", + "stream_name": None, # Type is none here because of the same reason as above + "origin_stream_name": 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_handler/test_consuming_fact_events.py b/tests/event_handler/test_consuming_fact_events.py new file mode 100644 index 00000000..cee2f5ef --- /dev/null +++ b/tests/event_handler/test_consuming_fact_events.py @@ -0,0 +1,30 @@ +import pytest + +from protean import BaseAggregate, BaseEventHandler, BaseView, handle +from protean.fields import String +from protean.utils.mixins import Message + + +class User(BaseAggregate): + name = String(max_length=50, required=True) + email = String(required=True) + status = String(choices=["ACTIVE", "ARCHIVED"]) + + +class UserView(BaseView): + id = String(identifier=True) + name = String(max_length=50, required=True) + email = String(required=True) + status = String(required=True) + + +class ManageUserView(BaseEventHandler): + @handle("Test.UserFact.v1") + def record_user_fact_event(self, message: Message) -> None: + pass + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User, fact_events=True) + test_domain.init(traverse=False) diff --git a/tests/event_handler/test_retrieving_handlers_by_event.py b/tests/event_handler/test_retrieving_handlers_by_event.py index 76f84d9d..df33f74c 100644 --- a/tests/event_handler/test_retrieving_handlers_by_event.py +++ b/tests/event_handler/test_retrieving_handlers_by_event.py @@ -85,17 +85,20 @@ def register_elements(test_domain): def test_retrieving_handler_by_event(test_domain): + test_domain._initialize() 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() 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() assert test_domain.handlers_for(Registered()) == { UserEventHandler, diff --git a/tests/event_sourced_aggregates/events/test_event_es_metadata_id_and_sequence.py b/tests/event_sourced_aggregates/events/test_event_es_metadata_id_and_sequence.py index 48dd7406..9bad33bc 100644 --- a/tests/event_sourced_aggregates/events/test_event_es_metadata_id_and_sequence.py +++ b/tests/event_sourced_aggregates/events/test_event_es_metadata_id_and_sequence.py @@ -33,5 +33,5 @@ def test_event_is_generated_with_unique_id(): user.login() event = user._events[0] - assert event._metadata.id == f"Test.User.v1.{identifier}.0" + assert event._metadata.id == f"user-{identifier}-0" assert event._metadata.sequence_id == "0" diff --git a/tests/event_sourced_aggregates/events/test_fact_event_generation.py b/tests/event_sourced_aggregates/events/test_fact_event_generation.py index 020dcf24..62b3db58 100644 --- a/tests/event_sourced_aggregates/events/test_fact_event_generation.py +++ b/tests/event_sourced_aggregates/events/test_fact_event_generation.py @@ -64,7 +64,7 @@ def test_generation_of_first_fact_event_on_persistence(test_domain): assert event.email == "john.doe@example.com" # Check event versions - assert event._metadata.id.endswith(".0") + assert event._metadata.id.endswith("-0") assert event._metadata.sequence_id == "0" assert event._version == 0 @@ -101,7 +101,7 @@ def test_generation_of_subsequent_fact_events_after_fetch(test_domain): assert event.__class__.__name__ == "UserFactEvent" assert event.name == "John Doe" - assert event._metadata.id.endswith(".0") + assert event._metadata.id.endswith("-0") assert event._metadata.sequence_id == "0" assert event._version == 0 @@ -111,6 +111,6 @@ def test_generation_of_subsequent_fact_events_after_fetch(test_domain): assert event.__class__.__name__ == "UserFactEvent" assert event.name == "Jane Doe" - assert event._metadata.id.endswith(".1") + assert event._metadata.id.endswith("-1") assert event._metadata.sequence_id == "1" assert event._version == 1 diff --git a/tests/event_sourced_aggregates/test_generated_event_version.py b/tests/event_sourced_aggregates/test_generated_event_version.py index fe1f2487..afc90683 100644 --- a/tests/event_sourced_aggregates/test_generated_event_version.py +++ b/tests/event_sourced_aggregates/test_generated_event_version.py @@ -71,7 +71,7 @@ def register_elements(test_domain): def test_aggregate_and_event_version_on_initialization(): user = User.register(user_id="1", name="John Doe", email="john.doe@example.com") assert user._version == 0 - assert user._events[0]._metadata.id.endswith(".0") + assert user._events[0]._metadata.id.endswith("-0") assert user._events[0]._metadata.sequence_id == "0" @@ -88,7 +88,7 @@ def test_aggregate_and_event_version_after_first_persistence(test_domain): # Deserialize event event = Message.to_object(event_messages[0]) - assert event._metadata.id.endswith(".0") + assert event._metadata.id.endswith("-0") assert event._metadata.sequence_id == "0" @@ -112,7 +112,7 @@ def test_aggregate_and_event_version_after_first_persistence_after_multiple_pers # Deserialize event event = Message.to_object(event_messages[-1]) - assert event._metadata.id.endswith(".10") + assert event._metadata.id.endswith("-10") assert event._metadata.sequence_id == "10" @@ -124,9 +124,9 @@ def test_aggregate_and_event_version_after_multiple_event_generation_in_one_upda # Check event versions before persistence assert user._version == 1 - assert user._events[0]._metadata.id.endswith(".0") + assert user._events[0]._metadata.id.endswith("-0") assert user._events[0]._metadata.sequence_id == "0" - assert user._events[1]._metadata.id.endswith(".1") + assert user._events[1]._metadata.id.endswith("-1") assert user._events[1]._metadata.sequence_id == "1" # Persist user just once @@ -143,7 +143,7 @@ def test_aggregate_and_event_version_after_multiple_event_generation_in_one_upda event1 = Message.to_object(event_messages[0]) event2 = Message.to_object(event_messages[1]) - assert event1._metadata.id.endswith(".0") + assert event1._metadata.id.endswith("-0") assert event1._metadata.sequence_id == "0" - assert event2._metadata.id.endswith(".1") + assert event2._metadata.id.endswith("-1") assert event2._metadata.sequence_id == "1" diff --git a/tests/event_store/test_appending_commands.py b/tests/event_store/test_appending_commands.py index 57419d22..c7da2384 100644 --- a/tests/event_store/test_appending_commands.py +++ b/tests/event_store/test_appending_commands.py @@ -22,6 +22,7 @@ class Register(BaseCommand): def test_command_submission_without_aggregate(test_domain): test_domain.register(User) + test_domain.init(traverse=False) with pytest.raises(IncorrectUsageError) as exc: test_domain.register(Register) diff --git a/tests/event_store/test_appending_events.py b/tests/event_store/test_appending_events.py index 911c7fa9..dcc5fa90 100644 --- a/tests/event_store/test_appending_events.py +++ b/tests/event_store/test_appending_events.py @@ -16,6 +16,7 @@ class UserLoggedIn(BaseEvent): @pytest.mark.eventstore def test_appending_raw_events(test_domain): test_domain.register(UserLoggedIn, stream_name="authentication") + test_domain.init(traverse=False) identifier = str(uuid4()) event = UserLoggedIn(user_id=identifier) diff --git a/tests/event_store/test_deriving_category.py b/tests/event_store/test_deriving_category.py index 865f4086..32099566 100644 --- a/tests/event_store/test_deriving_category.py +++ b/tests/event_store/test_deriving_category.py @@ -3,6 +3,8 @@ @pytest.mark.eventstore def test_deriving_category(test_domain): + test_domain.init(traverse=False) + assert test_domain.event_store.store.category(None) == "" assert test_domain.event_store.store.category("") == "" diff --git a/tests/event_store/test_event_store_adapter_initialization.py b/tests/event_store/test_event_store_adapter_initialization.py index 64769d16..8c3500a5 100644 --- a/tests/event_store/test_event_store_adapter_initialization.py +++ b/tests/event_store/test_event_store_adapter_initialization.py @@ -10,6 +10,6 @@ def test_domain_event_store_attribute(test_domain): @mock.patch("protean.adapters.event_store.EventStore._initialize") def test_event_store_initialization(mock_store_initialize, test_domain): - test_domain.event_store.store # Initializes store if not initialized already + test_domain._initialize() mock_store_initialize.assert_called_once() 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 e5d1a4ad..0d1720fe 100644 --- a/tests/event_store/test_inline_event_processing_on_publish.py +++ b/tests/event_store/test_inline_event_processing_on_publish.py @@ -38,6 +38,7 @@ def registered(self, _: Registered) -> None: def test_inline_event_processing_on_publish_in_sync_mode(test_domain): test_domain.register(Registered, stream_name="user") test_domain.register(UserEventHandler, stream_name="user") + test_domain.init(traverse=False) current_domain.publish( Registered( diff --git a/tests/event_store/test_reading_all_streams.py b/tests/event_store/test_reading_all_streams.py index dcede259..11a14c7c 100644 --- a/tests/event_store/test_reading_all_streams.py +++ b/tests/event_store/test_reading_all_streams.py @@ -80,6 +80,8 @@ def register_elements(test_domain): test_domain.register(Created, part_of=Post) test_domain.register(Published, part_of=Post) + test_domain.init(traverse=False) + @pytest.mark.eventstore def test_reading_messages_from_all_streams(test_domain): diff --git a/tests/event_store/test_streams_initialization.py b/tests/event_store/test_streams_initialization.py index 299b3e6a..03b5ff10 100644 --- a/tests/event_store/test_streams_initialization.py +++ b/tests/event_store/test_streams_initialization.py @@ -60,11 +60,10 @@ def register(test_domain): test_domain.register(Email) test_domain.register(UserEventHandler, part_of=User) test_domain.register(EmailEventHandler, part_of=Email) + test_domain.init(traverse=False) def test_streams_initialization(test_domain): - test_domain.event_store.store # Initializes store if not initialized already - assert len(test_domain.event_store._event_streams) == 2 assert all( stream_name in test_domain.event_store._event_streams diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index 888a0692..59ef36e3 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -62,7 +62,6 @@ def test_construct_message_from_event(test_domain): assert message.type == fully_qualified_name(Registered) assert message.stream_name == f"{User.meta_.stream_name}-{identifier}" assert message.metadata.kind == "EVENT" - assert message.metadata.owner == test_domain.name assert message.data == user._events[-1].to_dict() assert message.time is None assert message.expected_version == user._version - 1 @@ -72,7 +71,6 @@ def test_construct_message_from_event(test_domain): assert message_dict["type"] == fully_qualified_name(Registered) assert message_dict["metadata"]["kind"] == "EVENT" - assert message_dict["metadata"]["owner"] == test_domain.name assert message_dict["stream_name"] == f"{User.meta_.stream_name}-{identifier}" assert message_dict["data"] == user._events[-1].to_dict() assert message_dict["time"] is None @@ -94,7 +92,6 @@ def test_construct_message_from_command(test_domain): assert message.type == fully_qualified_name(Register) assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" assert message.metadata.kind == "COMMAND" - assert message.metadata.owner == test_domain.name assert message.data == command.to_dict() assert message.time is None @@ -102,7 +99,6 @@ def test_construct_message_from_command(test_domain): message_dict = message.to_dict() assert message_dict["type"] == fully_qualified_name(Register) assert message_dict["metadata"]["kind"] == "COMMAND" - assert message_dict["metadata"]["owner"] == test_domain.name assert ( message_dict["stream_name"] == f"{User.meta_.stream_name}:command-{identifier}" ) diff --git a/tests/message/test_origin_stream_name_in_metadata.py b/tests/message/test_origin_stream_name_in_metadata.py index 6d578a8b..7a25f5d8 100644 --- a/tests/message/test_origin_stream_name_in_metadata.py +++ b/tests/message/test_origin_stream_name_in_metadata.py @@ -3,10 +3,11 @@ import pytest from protean import BaseCommand, BaseEvent, BaseEventSourcedAggregate +from protean.core.event import Metadata from protean.fields import String from protean.fields.basic import Identifier from protean.globals import g -from protean.utils.mixins import Message, MessageMetadata +from protean.utils.mixins import Message class User(BaseEventSourcedAggregate): @@ -76,9 +77,9 @@ def test_origin_stream_name_in_event_from_command_without_origin_stream_name(use def test_origin_stream_name_in_event_from_command_with_origin_stream_name(user_id): command_message = register_command_message(user_id) - command_message.metadata = MessageMetadata( + command_message.metadata = Metadata( command_message.metadata.to_dict(), origin_stream_name="foo" - ) # MessageMetadata is a VO and immutable, so creating a copy with updated value + ) # Metadata is a VO and immutable, so creating a copy with updated value g.message_in_context = command_message event_message = Message.to_message( @@ -118,9 +119,9 @@ def test_origin_stream_name_in_aggregate_event_from_command_with_origin_stream_n ): command_message = register_command_message(user_id) - command_message.metadata = MessageMetadata( + command_message.metadata = Metadata( command_message.metadata.to_dict(), origin_stream_name="foo" - ) # MessageMetadata is a VO and immutable, so creating a copy with updated value + ) # Metadata is a VO and immutable, so creating a copy with updated value g.message_in_context = command_message 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 d02a4255..14cfd481 100644 --- a/tests/subscription/test_message_filtering_with_origin_stream.py +++ b/tests/subscription/test_message_filtering_with_origin_stream.py @@ -7,10 +7,11 @@ import pytest from protean import BaseEvent, BaseEventHandler, BaseEventSourcedAggregate, handle +from protean.core.event import Metadata from protean.fields import DateTime, Identifier, String from protean.server import Engine from protean.utils import fqn -from protean.utils.mixins import Message, MessageMetadata +from protean.utils.mixins import Message class User(BaseEventSourcedAggregate): @@ -98,9 +99,9 @@ async def test_message_filtering_for_event_handlers_with_defined_origin_stream( Message.to_aggregate_event_message(email, email._events[0]), ] - messages[2].metadata = MessageMetadata( + messages[2].metadata = Metadata( messages[2].metadata.to_dict(), origin_stream_name=f"user-{identifier}" - ) # MessageMetadata is a VO and immutable, so creating a copy with updated value + ) # Metadata is a VO and immutable, so creating a copy with updated value # Mock `read` method and have it return the 3 messages mock_store_read = mock.Mock() diff --git a/tests/subscription/test_no_message_filtering.py b/tests/subscription/test_no_message_filtering.py index 3b5345ed..1436232c 100644 --- a/tests/subscription/test_no_message_filtering.py +++ b/tests/subscription/test_no_message_filtering.py @@ -7,10 +7,11 @@ import pytest from protean import BaseEvent, BaseEventHandler, BaseEventSourcedAggregate, handle +from protean.core.event import Metadata from protean.fields import DateTime, Identifier, String from protean.server import Engine from protean.utils import fqn -from protean.utils.mixins import Message, MessageMetadata +from protean.utils.mixins import Message class User(BaseEventSourcedAggregate): @@ -97,9 +98,9 @@ async def test_no_filtering_for_event_handlers_without_defined_origin_stream( Message.to_aggregate_event_message(email, email._events[0]), ] - messages[2].metadata = MessageMetadata( + messages[2].metadata = Metadata( messages[2].metadata.to_dict(), origin_stream_name=f"user-{identifier}" - ) # MessageMetadata is a VO and immutable, so creating a copy with updated value + ) # Metadata is a VO and immutable, so creating a copy with updated value # Mock `read` method and have it return the 3 messages mock_store_read = mock.Mock() diff --git a/tests/unit_of_work/test_inline_event_processing.py b/tests/unit_of_work/test_inline_event_processing.py index 4d9dcd34..f54eeca0 100644 --- a/tests/unit_of_work/test_inline_event_processing.py +++ b/tests/unit_of_work/test_inline_event_processing.py @@ -96,6 +96,7 @@ def test_inline_event_processing_in_sync_mode(test_domain): test_domain.register(Registered, 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( 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 8f4f9268..55bbbe66 100644 --- a/tests/unit_of_work/test_nested_inline_event_processing.py +++ b/tests/unit_of_work/test_nested_inline_event_processing.py @@ -99,6 +99,7 @@ def test_nested_uow_processing(test_domain): test_domain.register(Published, part_of=Post) test_domain.register(PostEventHandler, part_of=Post) test_domain.register(Metrics, stream_name="post") + test_domain.init(traverse=False) identifier = str(uuid4()) PostCommandHandler().create_new_post( From 841424a51904e75bb6a6bceb2e9fd87809b229f6 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Fri, 5 Jul 2024 10:20:25 -0700 Subject: [PATCH 2/4] Stream enhancements - Part 2 Changes: - Ensure stream names have aggregate id within them - Use `stream_name` in Event/Command metadata as-is for persistence into event store - Base handlers and handler retreival on stream name - Ensure commands always belong to an aggregate cluster - Remove `stream_name` option from events and and commands All elements in an aggregate cluster should use the aggregate's stream name - Enhance `Domain.process` to enrich command before processing --- src/protean/adapters/event_store/__init__.py | 14 ++- src/protean/adapters/event_store/memory.py | 1 - src/protean/container.py | 19 ++- src/protean/core/command.py | 28 ++--- src/protean/core/command_handler.py | 13 +- src/protean/core/entity.py | 13 +- src/protean/core/event.py | 17 +-- src/protean/domain/__init__.py | 55 +++++++-- src/protean/utils/mixins.py | 52 +------- .../test_automatic_stream_association.py | 10 +- tests/command/test_command_meta.py | 15 +-- ...st_handle_decorator_in_command_handlers.py | 5 + .../test_inline_command_processing.py | 2 + .../test_retrieving_handlers_by_command.py | 10 +- .../test_automatic_stream_association.py | 115 ------------------ tests/event/test_event_meta.py | 9 -- tests/event/test_event_metadata.py | 2 +- tests/event/test_event_part_of_resolution.py | 5 +- tests/event/test_event_payload.py | 2 +- tests/event/test_event_properties.py | 7 ++ tests/event/test_raising_events.py | 4 +- tests/event/test_stream_name_derivation.py | 13 +- tests/event/tests.py | 20 ++- .../test_event_association_with_aggregate.py | 7 +- ...tiple_events_for_one_aggregate_in_a_uow.py | 2 + .../event_sourced_repository/test_add_uow.py | 1 + tests/event_store/test_appending_commands.py | 2 +- tests/event_store/test_appending_events.py | 15 ++- ...test_inline_event_processing_on_publish.py | 29 +++-- tests/message/test_object_to_message.py | 25 ++-- .../test_origin_stream_name_in_metadata.py | 54 ++++---- tests/server/test_engine_run.py | 27 ++-- .../test_read_position_updates.py | 2 +- tests/test_brokers.py | 46 ++++--- 34 files changed, 301 insertions(+), 340 deletions(-) delete mode 100644 tests/event/test_automatic_stream_association.py diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index 4e21d45c..be7be755 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -93,7 +93,9 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: all_stream_handlers = self._event_streams.get("$all", set()) # Gather all handlers configured to run on this event - stream_handlers = self._event_streams.get(event.meta_.stream_name, set()) + stream_handlers = self._event_streams.get( + event.meta_.part_of.meta_.stream_name, set() + ) configured_stream_handlers = set() for stream_handler in stream_handlers: if fqn(event.__class__) in stream_handler._handlers: @@ -102,12 +104,12 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: return set.union(configured_stream_handlers, all_stream_handlers) def command_handler_for(self, command: BaseCommand) -> Optional[BaseCommandHandler]: - stream_name = command.meta_.stream_name or ( - command.meta_.part_of.meta_.stream_name if command.meta_.part_of else None - ) + if not command.meta_.part_of: + raise ConfigurationError( + f"Command `{command.__name__}` needs to be associated with an aggregate" + ) - if not stream_name: - return None + stream_name = command.meta_.part_of.meta_.stream_name handler_classes = self._command_streams.get(stream_name, set()) diff --git a/src/protean/adapters/event_store/memory.py b/src/protean/adapters/event_store/memory.py index 1317649d..aa558b5c 100644 --- a/src/protean/adapters/event_store/memory.py +++ b/src/protean/adapters/event_store/memory.py @@ -2,7 +2,6 @@ from typing import Any, Dict, List from protean.core.aggregate import BaseAggregate -from protean.core.event import Metadata from protean.core.repository import BaseRepository from protean.globals import current_domain from protean.port.event_store import BaseEventStore diff --git a/src/protean/container.py b/src/protean/container.py index edbfec3a..1447bc63 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -8,12 +8,12 @@ from typing import Any, Type, Union from protean.exceptions import ( + ConfigurationError, InvalidDataError, NotSupportedError, ValidationError, ) from protean.fields import Auto, Field, FieldBase, ValueObject -from protean.globals import g from protean.reflection import id_field from protean.utils import generate_identity @@ -388,18 +388,31 @@ def raise_(self, event, fact_event=False) -> None: Event is immutable, so we clone a new event object from the event raised, and add the enhanced metadata to it. """ + # Verify that event is indeed associated with this aggregate + if event.meta_.part_of != self.__class__: + raise ConfigurationError( + f"Event `{event.__class__.__name__}` is not associated with " + f"aggregate `{self.__class__.__name__}`" + ) + if not fact_event: self._version += 1 identifier = getattr(self, id_field(self).field_name) + # Set Fact Event stream to be `-fact` + if event.__class__.__name__.endswith("FactEvent"): + stream_name = f"{self.meta_.stream_name}-fact" + else: + stream_name = self.meta_.stream_name + event_with_metadata = event.__class__( event.to_dict(), _metadata={ - "id": (f"{self.meta_.stream_name}-{identifier}-{self._version}"), + "id": (f"{stream_name}-{identifier}-{self._version}"), "type": f"{self.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", "kind": "EVENT", - "stream_name": self.meta_.stream_name, + "stream_name": f"{stream_name}-{identifier}", "origin_stream_name": event._metadata.origin_stream_name, "timestamp": event._metadata.timestamp, "version": event._metadata.version, diff --git a/src/protean/core/command.py b/src/protean/core/command.py index 62da2aad..f0380a81 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -8,7 +8,7 @@ ) from protean.fields import Field, ValueObject from protean.globals import g -from protean.reflection import _ID_FIELD_NAME, declared_fields +from protean.reflection import _ID_FIELD_NAME, declared_fields, fields from protean.utils import DomainObjects, derive_element_class @@ -64,6 +64,15 @@ def __init__(self, *args, **kwargs): except ValidationError as exception: raise InvalidDataError(exception.messages) + @property + def payload(self): + """Return the payload of the event.""" + return { + field_name: field_obj.as_dict(getattr(self, field_name, None)) + for field_name, field_obj in fields(self).items() + if field_name not in {"_metadata"} + } + def __setattr__(self, name, value): if not hasattr(self, "_initialized") or not self._initialized: return super().__setattr__(name, value) @@ -78,22 +87,10 @@ def __setattr__(self, name, value): @classmethod def _default_options(cls): - part_of = ( - getattr(cls.meta_, "part_of") if hasattr(cls.meta_, "part_of") else None - ) - - # This method is called during class import, so we cannot use part_of if it - # is still a string. We ignore it for now, and resolve `stream_name` later - # when the domain has resolved references. - # FIXME A better mechanism would be to not set stream_name here, unless explicitly - # specified, and resolve it during `domain.init()` - part_of = None if isinstance(part_of, str) else part_of - return [ ("abstract", False), ("aggregate_cluster", None), ("part_of", None), - ("stream_name", part_of.meta_.stream_name if part_of else None), ] @classmethod @@ -119,10 +116,7 @@ def __track_id_field(subclass): def command_factory(element_cls, **kwargs): element_cls = derive_element_class(element_cls, BaseCommand, **kwargs) - if ( - not (element_cls.meta_.part_of or element_cls.meta_.stream_name) - and not element_cls.meta_.abstract - ): + if not element_cls.meta_.part_of and not element_cls.meta_.abstract: raise IncorrectUsageError( { "_command": [ diff --git a/src/protean/core/command_handler.py b/src/protean/core/command_handler.py index 41edd118..2a2e3706 100644 --- a/src/protean/core/command_handler.py +++ b/src/protean/core/command_handler.py @@ -75,12 +75,23 @@ def command_handler_factory(element_cls, **kwargs): } ) + # 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" + ] + } + ) + # 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_.stream_name + method._target_cls.meta_.part_of.meta_.stream_name or element_cls.meta_.part_of.meta_.stream_name ) diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 0e1ad4ab..f58719b1 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -11,7 +11,6 @@ from protean.exceptions import IncorrectUsageError, NotSupportedError, ValidationError from protean.fields import Auto, HasMany, Reference, ValueObject from protean.fields.association import Association -from protean.globals import g from protean.reflection import ( _FIELDS, attributes, @@ -443,17 +442,23 @@ def raise_(self, event) -> None: # in the same edit session event_number = len(self._root._events) + 1 - identifier = getattr(self, id_field(self).field_name) + identifier = getattr(self._root, id_field(self._root).field_name) + + # Set Fact Event stream to be `-fact` + if event.__class__.__name__.endswith("FactEvent"): + stream_name = f"{self._root.meta_.stream_name}-fact" + else: + stream_name = self._root.meta_.stream_name event_with_metadata = event.__class__( event.to_dict(), _metadata={ "id": ( - f"{self._root.meta_.stream_name}-{identifier}-{aggregate_version}.{event_number}" + f"{stream_name}-{identifier}-{aggregate_version}.{event_number}" ), "type": f"{self._root.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", "kind": "EVENT", - "stream_name": self._root.meta_.stream_name, + "stream_name": f"{stream_name}-{identifier}", "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 e7e454d8..c6c4afff 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -89,22 +89,10 @@ def __setattr__(self, name, value): @classmethod def _default_options(cls): - part_of = ( - getattr(cls.meta_, "part_of") if hasattr(cls.meta_, "part_of") else None - ) - - # This method is called during class import, so we cannot use part_of if it - # is still a string. We ignore it for now, and resolve `stream_name` later - # when the domain has resolved references. - # FIXME A better mechanism would be to not set stream_name here, unless explicitly - # specified, and resolve it during `domain.init()` - part_of = None if isinstance(part_of, str) else part_of - return [ ("abstract", False), ("aggregate_cluster", None), ("part_of", None), - ("stream_name", part_of.meta_.stream_name if part_of else None), ] @classmethod @@ -189,10 +177,7 @@ def to_dict(self): def domain_event_factory(element_cls, **kwargs): element_cls = derive_element_class(element_cls, BaseEvent, **kwargs) - if ( - not (element_cls.meta_.part_of or element_cls.meta_.stream_name) - and not element_cls.meta_.abstract - ): + if not element_cls.meta_.part_of and not element_cls.meta_.abstract: raise IncorrectUsageError( { "_event": [ diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index a0192590..1a4f4d64 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -3,11 +3,13 @@ """ import inspect +import json import logging import sys from collections import defaultdict from functools import lru_cache from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from uuid import uuid4 from werkzeug.datastructures import ImmutableDict @@ -27,7 +29,8 @@ NotSupportedError, ) from protean.fields import HasMany, HasOne, Reference, ValueObject -from protean.reflection import declared_fields, has_fields +from protean.globals import g +from protean.reflection import declared_fields, has_fields, id_field from protean.utils import ( CommandProcessing, DomainObjects, @@ -804,11 +807,7 @@ def _generate_fact_event_classes(self): for _, element in self.registry._elements[element_type.value].items(): if element.cls.meta_.fact_events: event_cls = element_to_fact_event(element.cls) - self.register( - event_cls, - part_of=element.cls, - stream_name=element.cls.meta_.stream_name + "-fact", - ) + self.register(event_cls, part_of=element.cls) ###################### # Element Decorators # @@ -935,6 +934,47 @@ def publish(self, events: Union[BaseEvent, List[BaseEvent]]) -> None: ##################### # Handling Commands # ##################### + def _enrich_command(self, command: BaseCommand) -> BaseCommand: + # Enrich Command + identifier = None + identity_field = id_field(command) + if identity_field: + identifier = getattr(command, identity_field.field_name) + else: + identifier = str(uuid4()) + + stream_name = f"{command.meta_.part_of.meta_.stream_name}:command-{identifier}" + + origin_stream_name = None + if hasattr(g, "message_in_context"): + if g.message_in_context.metadata.kind == "EVENT": + origin_stream_name = g.message_in_context.stream_name + + 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}" + ), + "kind": "EVENT", + "stream_name": stream_name, + "origin_stream_name": origin_stream_name, + "timestamp": command._metadata.timestamp, + "version": command._metadata.version, + "sequence_id": None, + "payload_hash": hash( + json.dumps( + command.payload, + sort_keys=True, + ) + ), + }, + ) + + return command_with_metadata + def process(self, command: BaseCommand, asynchronous: bool = True) -> Optional[Any]: """Process command and return results based on specified preference. @@ -950,7 +990,8 @@ 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. """ - position = self.event_store.store.append(command) + command_with_metadata = self._enrich_command(command) + position = self.event_store.store.append(command_with_metadata) if ( not asynchronous diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 35b6191b..314a53d5 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -5,7 +5,6 @@ from collections import defaultdict from enum import Enum from typing import Callable, Dict, Union -from uuid import uuid4 from protean import fields from protean.container import BaseContainer, OptionsMixin @@ -13,10 +12,8 @@ from protean.core.event import BaseEvent, Metadata from protean.core.event_sourced_aggregate import BaseEventSourcedAggregate from protean.core.unit_of_work import UnitOfWork -from protean.core.value_object import BaseValueObject from protean.exceptions import ConfigurationError -from protean.globals import current_domain, g -from protean.reflection import has_id_field, id_field +from protean.globals import current_domain from protean.utils import fully_qualified_name logger = logging.getLogger(__name__) @@ -71,29 +68,6 @@ class Message(MessageRecord, OptionsMixin): # FIXME Remove OptionsMixin # Version that the stream is expected to be when the message is written expected_version = fields.Integer() - @classmethod - def derived_metadata(cls, new_message_type: str) -> Dict: - additional_metadata = {} - - if hasattr(g, "message_in_context"): - if ( - new_message_type == "COMMAND" - and g.message_in_context.metadata.kind == "EVENT" - ): - additional_metadata["origin_stream_name"] = ( - g.message_in_context.stream_name - ) - - if ( - new_message_type == "EVENT" - and g.message_in_context.metadata.kind == "COMMAND" - and g.message_in_context.metadata.origin_stream_name is not None - ): - additional_metadata["origin_stream_name"] = ( - g.message_in_context.metadata.origin_stream_name - ) - return additional_metadata - @classmethod def from_dict(cls, message: Dict) -> Message: return Message( @@ -111,14 +85,6 @@ def from_dict(cls, message: Dict) -> Message: def to_aggregate_event_message( cls, aggregate: BaseEventSourcedAggregate, event: BaseEvent ) -> Message: - identifier = getattr(aggregate, id_field(aggregate).field_name) - - if not event.meta_.stream_name: - raise ConfigurationError( - f"No stream name found for `{event.__class__.__name__}`. " - "Either specify an explicit stream name or associate the event with an aggregate." - ) - # If this is a Fact Event, don't set an expected version. # Otherwise, expect the previous version if event.__class__.__name__.endswith("FactEvent"): @@ -127,7 +93,7 @@ def to_aggregate_event_message( expected_version = int(event._metadata.sequence_id) - 1 return cls( - stream_name=f"{event.meta_.stream_name}-{identifier}", + stream_name=event._metadata.stream_name, type=fully_qualified_name(event.__class__), data=event.to_dict(), metadata=event._metadata, @@ -151,23 +117,13 @@ def to_object(self) -> Union[BaseEvent, BaseCommand]: @classmethod def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: - if has_id_field(message_object): - identifier = getattr(message_object, id_field(message_object).field_name) - else: - identifier = str(uuid4()) - - if not message_object.meta_.stream_name: + if not message_object.meta_.part_of.meta_.stream_name: raise ConfigurationError( f"No stream name found for `{message_object.__class__.__name__}`. " "Either specify an explicit stream name or associate the event with an aggregate." ) - if isinstance(message_object, BaseEvent): - stream_name = f"{message_object.meta_.stream_name}-{identifier}" - elif isinstance(message_object, BaseCommand): - stream_name = f"{message_object.meta_.stream_name}:command-{identifier}" - else: - raise NotImplementedError # FIXME Handle unknown messages better + stream_name = message_object._metadata.stream_name return cls( stream_name=stream_name, diff --git a/tests/command/test_automatic_stream_association.py b/tests/command/test_automatic_stream_association.py index 8c71ec36..83bb4183 100644 --- a/tests/command/test_automatic_stream_association.py +++ b/tests/command/test_automatic_stream_association.py @@ -86,10 +86,10 @@ def register(test_domain): 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, stream_name="subscriptions") + 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, stream_name="recalls") + test_domain.register(Recall, part_of=Email) test_domain.register(UserCommandHandler, part_of=User) test_domain.register(EmailCommandHandler, part_of=Email) @@ -101,11 +101,11 @@ def test_automatic_association_of_events_with_aggregate_and_stream(): assert Activate.meta_.part_of is User assert Activate.meta_.stream_name == "user" - assert Subscribe.meta_.part_of is None - assert Subscribe.meta_.stream_name == "subscriptions" + 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 == "recalls" + assert Recall.meta_.stream_name == "email" diff --git a/tests/command/test_command_meta.py b/tests/command/test_command_meta.py index 1f76324e..8b5a250a 100644 --- a/tests/command/test_command_meta.py +++ b/tests/command/test_command_meta.py @@ -67,8 +67,10 @@ def test_command_associated_with_aggregate(test_domain): @pytest.mark.eventstore -def test_command_associated_with_stream_name(test_domain): - test_domain.register(Register, stream_name="foo") +def test_command_associated_with_aggregate_with_custom_stream_name(test_domain): + test_domain.register(User, stream_name="foo") + test_domain.register(Register, part_of=User) + test_domain.init(traverse=False) identifier = str(uuid4()) test_domain.process( @@ -91,12 +93,3 @@ def test_aggregate_cluster_of_event(test_domain): test_domain.init(traverse=False) assert Register.meta_.aggregate_cluster == User - - -def test_no_aggregate_cluster_for_command_with_stream(test_domain): - class SendEmail(BaseCommand): - email = String() - - test_domain.register(SendEmail, stream_name="email") - - assert SendEmail.meta_.aggregate_cluster is None 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 127b1f27..3c5a6e85 100644 --- a/tests/command_handler/test_handle_decorator_in_command_handlers.py +++ b/tests/command_handler/test_handle_decorator_in_command_handlers.py @@ -29,6 +29,7 @@ def register(self, command: Register) -> None: pass test_domain.register(User) + test_domain.register(Register, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) assert fully_qualified_name(Register) in UserCommandHandlers._handlers @@ -45,7 +46,10 @@ def update_billing_address(self, event: ChangeAddress) -> None: pass test_domain.register(User) + test_domain.register(Register, part_of=User) + test_domain.register(ChangeAddress, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) assert len(UserCommandHandlers._handlers) == 2 assert all( @@ -82,6 +86,7 @@ def provision_user_account(self, event: Register) -> None: with pytest.raises(NotSupportedError) as exc: test_domain.register(User) + test_domain.register(Register, part_of=User) test_domain.register(UserCommandHandlers, part_of=User) assert ( diff --git a/tests/command_handler/test_inline_command_processing.py b/tests/command_handler/test_inline_command_processing.py index 958e0d9f..baf3ed32 100644 --- a/tests/command_handler/test_inline_command_processing.py +++ b/tests/command_handler/test_inline_command_processing.py @@ -28,6 +28,7 @@ def register(self, event: Register) -> None: 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) @@ -39,6 +40,7 @@ def test_that_command_can_be_processed_inline(test_domain): def test_that_command_is_persisted_in_message_store(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) diff --git a/tests/command_handler/test_retrieving_handlers_by_command.py b/tests/command_handler/test_retrieving_handlers_by_command.py index bc735279..738c0478 100644 --- a/tests/command_handler/test_retrieving_handlers_by_command.py +++ b/tests/command_handler/test_retrieving_handlers_by_command.py @@ -2,7 +2,7 @@ from protean import BaseCommand, BaseEventSourcedAggregate, handle from protean.core.command_handler import BaseCommandHandler -from protean.exceptions import NotSupportedError +from protean.exceptions import ConfigurationError, NotSupportedError from protean.fields import Identifier, String, Text @@ -76,7 +76,13 @@ def test_for_no_errors_when_no_handler_method_has_not_been_defined_for_a_command def test_retrieving_handlers_for_unknown_command(test_domain): - assert test_domain.command_handler_for(UnknownCommand) is None + with pytest.raises(ConfigurationError) as exc: + test_domain.command_handler_for(UnknownCommand) + + assert ( + exc.value.args[0] + == "Command `UnknownCommand` needs to be associated with an aggregate" + ) def test_error_on_defining_multiple_handlers_for_a_command(test_domain): diff --git a/tests/event/test_automatic_stream_association.py b/tests/event/test_automatic_stream_association.py deleted file mode 100644 index 6d5a2c98..00000000 --- a/tests/event/test_automatic_stream_association.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import pytest - -from protean import BaseEvent, BaseEventHandler, 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 Registered(BaseEvent): - id = Identifier() - email = String() - name = String() - password_hash = String() - - -class Activated(BaseEvent): - id = Identifier() - activated_at = DateTime() - - -class LoggedIn(BaseEvent): - id = Identifier() - activated_at = DateTime() - - -class Subscribed(BaseEvent): - """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 Sent(BaseEvent): - email = String() - sent_at = DateTime() - - -class Recalled(BaseEvent): - email = String() - sent_at = DateTime() - - -class UserEventHandler(BaseEventHandler): - @handle(Registered) - def send_activation_email(self, _: Registered) -> None: - pass - - @handle(Activated) - def provision_user(self, _: Activated) -> None: - pass - - @handle(Activated) - def send_welcome_email(self, _: Activated) -> None: - pass - - @handle(LoggedIn) - def record_login(self, _: LoggedIn) -> None: - pass - - @handle(Subscribed) - def subscribed_for_notifications(self, _: Subscribed) -> None: - pass - - -class EmailEventHandler(BaseEventHandler): - @handle(Sent) - def record_sent_email(self, _: Sent) -> None: - pass - - @handle(Recalled) - def record_recalls(self, _: Recalled) -> None: - pass - - -@pytest.fixture(autouse=True) -def register(test_domain): - test_domain.register(User) - test_domain.register(Registered, stream_name="user") - test_domain.register(Activated, stream_name="user") - test_domain.register(LoggedIn, part_of=User) - test_domain.register(Subscribed, stream_name="subscriptions") - test_domain.register(Email) - test_domain.register(Sent, stream_name="email") - test_domain.register(Recalled, part_of=Email, stream_name="recalls") - test_domain.register(UserEventHandler, part_of=User) - test_domain.register(EmailEventHandler, part_of=Email) - - -def test_automatic_association_of_events_with_aggregate_and_stream(): - assert Registered.meta_.part_of is None - assert Registered.meta_.stream_name == "user" - - assert Activated.meta_.part_of is None - assert Activated.meta_.stream_name == "user" - - assert Subscribed.meta_.part_of is None - assert Subscribed.meta_.stream_name == "subscriptions" - - assert Sent.meta_.part_of is None - assert Sent.meta_.stream_name == "email" - - assert Recalled.meta_.part_of is Email - assert Recalled.meta_.stream_name == "recalls" diff --git a/tests/event/test_event_meta.py b/tests/event/test_event_meta.py index df2979c4..81457de5 100644 --- a/tests/event/test_event_meta.py +++ b/tests/event/test_event_meta.py @@ -52,12 +52,3 @@ def test_that_part_of_is_resolved_correctly(): def test_aggregate_cluster_of_event(): assert UserLoggedIn.meta_.aggregate_cluster == User - - -def test_no_aggregate_cluster_for_command_with_stream(test_domain): - class EmailSent(BaseEvent): - email = String() - - test_domain.register(EmailSent, stream_name="email") - - assert EmailSent.meta_.aggregate_cluster is None diff --git a/tests/event/test_event_metadata.py b/tests/event/test_event_metadata.py index 35fa2cbf..c7c04fc2 100644 --- a/tests/event/test_event_metadata.py +++ b/tests/event/test_event_metadata.py @@ -86,7 +86,7 @@ def test_event_metadata(): "id": f"user-{user.id}-0", "type": "User.UserLoggedIn.v1", "kind": "EVENT", - "stream_name": "user", + "stream_name": f"user-{user.id}", "origin_stream_name": None, "timestamp": str(event._metadata.timestamp), "version": "v1", diff --git a/tests/event/test_event_part_of_resolution.py b/tests/event/test_event_part_of_resolution.py index 080a52be..f8a4f15b 100644 --- a/tests/event/test_event_part_of_resolution.py +++ b/tests/event/test_event_part_of_resolution.py @@ -22,10 +22,11 @@ def register_elements(test_domain): def test_event_does_not_have_stream_name_before_domain_init(): - assert UserLoggedIn.meta_.stream_name is None + assert isinstance(UserLoggedIn.meta_.part_of, str) def test_event_has_stream_name_after_domain_init(test_domain): test_domain.init(traverse=False) - assert UserLoggedIn.meta_.stream_name == "user" + assert UserLoggedIn.meta_.part_of == User + assert UserLoggedIn.meta_.part_of.meta_.stream_name == "user" diff --git a/tests/event/test_event_payload.py b/tests/event/test_event_payload.py index 5c61b1e8..23f07229 100644 --- a/tests/event/test_event_payload.py +++ b/tests/event/test_event_payload.py @@ -39,7 +39,7 @@ def test_event_payload(): "id": f"user-{user_id}-0", "type": "User.UserLoggedIn.v1", "kind": "EVENT", - "stream_name": "user", + "stream_name": f"user-{user_id}", "origin_stream_name": None, "timestamp": str(event._metadata.timestamp), "version": "v1", diff --git a/tests/event/test_event_properties.py b/tests/event/test_event_properties.py index 7846dae9..f72b826b 100644 --- a/tests/event/test_event_properties.py +++ b/tests/event/test_event_properties.py @@ -20,6 +20,13 @@ class Registered(BaseEvent): name = String() +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User) + test_domain.register(Registered, part_of=User) + test_domain.init(traverse=False) + + def test_that_events_are_immutable(): event = Registered(email="john.doe@gmail.com", name="John Doe", user_id="1234") with pytest.raises(IncorrectUsageError): diff --git a/tests/event/test_raising_events.py b/tests/event/test_raising_events.py index b91daba0..b524c56e 100644 --- a/tests/event/test_raising_events.py +++ b/tests/event/test_raising_events.py @@ -19,8 +19,8 @@ class UserLoggedIn(BaseEvent): @pytest.mark.eventstore def test_raising_event(test_domain): - test_domain.register(User) - test_domain.register(UserLoggedIn, stream_name="authentication") + test_domain.register(User, stream_name="authentication") + test_domain.register(UserLoggedIn, part_of=User) identifier = str(uuid4()) user = User(id=identifier, email="test@example.com", name="Test User") diff --git a/tests/event/test_stream_name_derivation.py b/tests/event/test_stream_name_derivation.py index f407442c..606addbb 100644 --- a/tests/event/test_stream_name_derivation.py +++ b/tests/event/test_stream_name_derivation.py @@ -1,5 +1,3 @@ -import pytest - from protean import BaseAggregate, BaseEvent from protean.fields import String from protean.fields.basic import Identifier @@ -18,18 +16,11 @@ def test_stream_name_from_part_of(test_domain): test_domain.register(User) test_domain.register(UserLoggedIn, part_of=User) - assert UserLoggedIn.meta_.stream_name == "user" + assert UserLoggedIn.meta_.part_of.meta_.stream_name == "user" def test_stream_name_from_explicit_stream_name_in_aggregate(test_domain): test_domain.register(User, stream_name="authentication") test_domain.register(UserLoggedIn, part_of=User) - assert UserLoggedIn.meta_.stream_name == "authentication" - - -def test_stream_name_from_explicit_stream_name(test_domain): - test_domain.register(User) - test_domain.register(UserLoggedIn, stream_name="authentication") - - assert UserLoggedIn.meta_.stream_name == "authentication" + assert UserLoggedIn.meta_.part_of.meta_.stream_name == "authentication" diff --git a/tests/event/tests.py b/tests/event/tests.py index ad4bf6e6..e8b7a9b6 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -2,9 +2,9 @@ import pytest -from protean import BaseEvent, BaseValueObject +from protean import BaseAggregate, BaseEvent, BaseValueObject from protean.exceptions import NotSupportedError -from protean.fields import String, ValueObject +from protean.fields import Identifier, String, ValueObject from protean.reflection import data_fields, declared_fields, fields from protean.utils import fully_qualified_name @@ -30,12 +30,23 @@ def test_that_domain_event_can_accommodate_value_objects(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): + id = Identifier(identifier=True) email = ValueObject(Email, required=True) name = String(max_length=50) - test_domain.register(UserAdded, stream_name="user") - event = UserAdded(email_address="john.doe@gmail.com", name="John Doe") + test_domain.register(UserAdded, part_of=User) + + user = User( + id=str(uuid.uuid4()), + email=Email(address="john.doe@gmail.com"), + name="John Doe", + ) + event = UserAdded(id=user.id, email_address=user.email_address, name=user.name) assert event is not None assert event.email == Email(address="john.doe@gmail.com") @@ -59,6 +70,7 @@ class UserAdded(BaseEvent): "address": "john.doe@gmail.com", }, "name": "John Doe", + "id": user.id, } ) 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 3843a792..e4a66817 100644 --- a/tests/event_sourced_aggregates/test_event_association_with_aggregate.py +++ b/tests/event_sourced_aggregates/test_event_association_with_aggregate.py @@ -106,12 +106,9 @@ def test_that_trying_to_associate_an_event_with_multiple_aggregates_throws_an_er @pytest.mark.eventstore def test_an_unassociated_event_throws_error(test_domain): user = User.register(user_id="1", name="", email="") - user.raise_(UserArchived(user_id=user.user_id)) - with pytest.raises(ConfigurationError) as exc: - test_domain.repository_for(User).add(user) + user.raise_(UserArchived(user_id=user.user_id)) assert exc.value.args[0] == ( - "No stream name found for `UserArchived`. " - "Either specify an explicit stream name or associate the event with an aggregate." + "Event `UserArchived` is not associated with aggregate `User`" ) 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 40fd61e2..8a75fe1c 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 @@ -76,6 +76,8 @@ def rename_user(self, command: RenameNameTwice) -> None: def test_that_multiple_events_are_raised_per_aggregate_in_the_same_uow(test_domain): test_domain.register(User) + test_domain.register(Register, part_of=User) + test_domain.register(RenameNameTwice, part_of=User) test_domain.register(UserCommandHandler, part_of=User) test_domain.register(Registered, part_of=User) test_domain.register(Renamed, part_of=User) diff --git a/tests/event_sourced_repository/test_add_uow.py b/tests/event_sourced_repository/test_add_uow.py index d2902779..4d47b45f 100644 --- a/tests/event_sourced_repository/test_add_uow.py +++ b/tests/event_sourced_repository/test_add_uow.py @@ -20,6 +20,7 @@ class Registered(BaseEvent): @pytest.fixture(autouse=True) def register_elements(test_domain): test_domain.register(User) + test_domain.register(Registered, part_of=User) test_domain.init(traverse=False) diff --git a/tests/event_store/test_appending_commands.py b/tests/event_store/test_appending_commands.py index c7da2384..c31092a9 100644 --- a/tests/event_store/test_appending_commands.py +++ b/tests/event_store/test_appending_commands.py @@ -41,7 +41,7 @@ def test_command_submission(test_domain): test_domain.init(traverse=False) identifier = str(uuid4()) - test_domain.event_store.store.append( + test_domain.process( Register( user_id=identifier, email="john.doe@gmail.com", diff --git a/tests/event_store/test_appending_events.py b/tests/event_store/test_appending_events.py index dcc5fa90..605f2db7 100644 --- a/tests/event_store/test_appending_events.py +++ b/tests/event_store/test_appending_events.py @@ -4,23 +4,30 @@ import pytest -from protean import BaseEvent +from protean import BaseEvent, BaseEventSourcedAggregate from protean.fields.basic import Identifier from protean.utils.mixins import Message +class User(BaseEventSourcedAggregate): + user_id = Identifier(identifier=True) + + class UserLoggedIn(BaseEvent): user_id = Identifier(identifier=True) @pytest.mark.eventstore def test_appending_raw_events(test_domain): - test_domain.register(UserLoggedIn, stream_name="authentication") + test_domain.register(User, stream_name="authentication") + test_domain.register(UserLoggedIn, part_of=User) test_domain.init(traverse=False) identifier = str(uuid4()) - event = UserLoggedIn(user_id=identifier) - test_domain.event_store.store.append(event) + user = User(user_id=identifier) + user.raise_(UserLoggedIn(user_id=identifier)) + event = user._events[0] # Remember event for later comparison + test_domain.repository_for(User).add(user) messages = test_domain.event_store.store.read("authentication") 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 0d1720fe..0390ccd2 100644 --- a/tests/event_store/test_inline_event_processing_on_publish.py +++ b/tests/event_store/test_inline_event_processing_on_publish.py @@ -9,7 +9,7 @@ import pytest -from protean import BaseEvent, BaseEventHandler, handle +from protean import BaseEvent, BaseEventHandler, BaseEventSourcedAggregate, handle from protean.fields import Identifier, String from protean.globals import current_domain @@ -21,6 +21,13 @@ def count_up(): counter += 1 +class User(BaseEventSourcedAggregate): + user_id = Identifier(identifier=True) + email = String() + name = String() + password_hash = String() + + class Registered(BaseEvent): user_id = Identifier() email = String() @@ -36,18 +43,26 @@ def registered(self, _: Registered) -> None: @pytest.mark.eventstore def test_inline_event_processing_on_publish_in_sync_mode(test_domain): - test_domain.register(Registered, stream_name="user") + test_domain.register(User, stream_name="user") + test_domain.register(Registered, part_of=User) test_domain.register(UserEventHandler, stream_name="user") test_domain.init(traverse=False) - current_domain.publish( + user = User( + user_id=str(uuid4()), + email="john.doe@example.com", + name="John Doe", + password_hash="hash", + ) + user.raise_( Registered( - user_id=str(uuid4()), - email="john.doe@example.com", - name="John Doe", - password_hash="hash", + user_id=user.user_id, + email=user.email, + name=user.name, + password_hash=user.password_hash, ) ) + current_domain.publish(user._events[0]) global counter assert counter == 1 diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index 59ef36e3..6f4f9892 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -82,10 +82,12 @@ 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") + test_domain.process(command) - message = Message.to_message(command) + messages = test_domain.event_store.store.read("user:command") + assert len(messages) == 1 - assert message is not None + message = messages[0] assert type(message) is Message # Verify Message Content @@ -93,7 +95,7 @@ def test_construct_message_from_command(test_domain): assert message.stream_name == f"{User.meta_.stream_name}:command-{identifier}" assert message.metadata.kind == "COMMAND" assert message.data == command.to_dict() - assert message.time is None + assert message.time is not None # Verify Message Dict message_dict = message.to_dict() @@ -103,17 +105,19 @@ def test_construct_message_from_command(test_domain): message_dict["stream_name"] == f"{User.meta_.stream_name}:command-{identifier}" ) assert message_dict["data"] == command.to_dict() - assert message_dict["time"] is None + assert message_dict["time"] is not None -def test_construct_message_from_command_without_identifier(): +def test_construct_message_from_command_without_identifier(test_domain): """Test that a new UUID is used as identifier when there is no explicit identifier specified""" identifier = str(uuid4()) command = SendEmailCommand(to="john.doe@gmail.com", subject="Foo", content="Bar") + test_domain.process(command) - message = Message.to_message(command) + messages = test_domain.event_store.store.read("send_email:command") + assert len(messages) == 1 - assert message is not None + message = messages[0] assert type(message) is Message message_dict = message.to_dict() @@ -127,9 +131,10 @@ def test_construct_message_from_command_without_identifier(): pytest.fail("Command identifier is not a valid UUID") -def test_construct_message_from_either_event_or_command(): +def test_construct_message_from_either_event_or_command(test_domain): identifier = str(uuid4()) command = Register(id=identifier, email="john.doe@gmail.com", name="John Doe") + command = test_domain._enrich_command(command) message = Message.to_message(command) @@ -142,7 +147,9 @@ def test_construct_message_from_either_event_or_command(): assert message.metadata.kind == "COMMAND" assert message.data == command.to_dict() - event = Registered(id=identifier, email="john.doe@gmail.com", name="John Doe") + 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")) + event = user._events[-1] # This simulates the call by UnitOfWork message = Message.to_message(event) diff --git a/tests/message/test_origin_stream_name_in_metadata.py b/tests/message/test_origin_stream_name_in_metadata.py index 7a25f5d8..3d82c264 100644 --- a/tests/message/test_origin_stream_name_in_metadata.py +++ b/tests/message/test_origin_stream_name_in_metadata.py @@ -41,28 +41,35 @@ def user_id(): return str(uuid4()) -def register_command_message(user_id): - return Message.to_message( +@pytest.fixture +def register_command_message(user_id, test_domain): + enriched_command = test_domain._enrich_command( Register( user_id=user_id, email="john.doe@gmail.com", name="John Doe", ) ) + return Message.to_message(enriched_command) +@pytest.fixture def registered_event_message(user_id): - return Message.to_message( + user = User(id=user_id, email="john.doe@gmail.com", name="John Doe") + user.raise_( Registered( user_id=user_id, - email="john.doe@gmail.com", - name="John Doe", + email=user.email, + name=user.name, ) ) + return Message.to_message(user._events[0]) -def test_origin_stream_name_in_event_from_command_without_origin_stream_name(user_id): - g.message_in_context = register_command_message(user_id) +def test_origin_stream_name_in_event_from_command_without_origin_stream_name( + user_id, register_command_message +): + g.message_in_context = register_command_message event_message = Message.to_message( Registered( @@ -74,8 +81,10 @@ def test_origin_stream_name_in_event_from_command_without_origin_stream_name(use assert event_message.metadata.origin_stream_name is None -def test_origin_stream_name_in_event_from_command_with_origin_stream_name(user_id): - command_message = register_command_message(user_id) +def test_origin_stream_name_in_event_from_command_with_origin_stream_name( + user_id, register_command_message +): + command_message = register_command_message command_message.metadata = Metadata( command_message.metadata.to_dict(), origin_stream_name="foo" @@ -94,9 +103,9 @@ def test_origin_stream_name_in_event_from_command_with_origin_stream_name(user_i def test_origin_stream_name_in_aggregate_event_from_command_without_origin_stream_name( - user_id, + user_id, register_command_message ): - g.message_in_context = register_command_message(user_id) + g.message_in_context = register_command_message user = User( id=user_id, email="john.doe@gmail.com", @@ -115,9 +124,9 @@ def test_origin_stream_name_in_aggregate_event_from_command_without_origin_strea def test_origin_stream_name_in_aggregate_event_from_command_with_origin_stream_name( - user_id, + user_id, register_command_message ): - command_message = register_command_message(user_id) + command_message = register_command_message command_message.metadata = Metadata( command_message.metadata.to_dict(), origin_stream_name="foo" @@ -141,14 +150,17 @@ def test_origin_stream_name_in_aggregate_event_from_command_with_origin_stream_n assert event_message.metadata.origin_stream_name == "foo" -def test_origin_stream_name_in_command_from_event(user_id): - g.message_in_context = registered_event_message(user_id) - command_message = Message.to_message( - Register( - user_id=user_id, - email="john.doe@gmail.com", - name="John Doe", - ) +def test_origin_stream_name_in_command_from_event( + user_id, test_domain, registered_event_message +): + g.message_in_context = registered_event_message + command = Register( + user_id=user_id, + email="john.doe@gmail.com", + name="John Doe", ) + 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}" diff --git a/tests/server/test_engine_run.py b/tests/server/test_engine_run.py index a0f0700f..c130a68c 100644 --- a/tests/server/test_engine_run.py +++ b/tests/server/test_engine_run.py @@ -3,8 +3,9 @@ import pytest -from protean import BaseEvent, BaseEventHandler, Engine, handle +from protean import BaseAggregate, BaseEvent, BaseEventHandler, Engine, handle from protean.fields import Identifier +from protean.utils import EventProcessing counter = 0 @@ -14,6 +15,10 @@ def count_up(): counter += 1 +class User(BaseAggregate): + user_id = Identifier(identifier=True) + + class UserLoggedIn(BaseEvent): user_id = Identifier(identifier=True) @@ -40,14 +45,18 @@ def auto_set_and_close_loop(): @pytest.fixture(autouse=True) def register_elements(test_domain): - test_domain.register(UserLoggedIn, stream_name="authentication") + test_domain.config["event_processing"] = EventProcessing.ASYNC.value + test_domain.register(User, stream_name="authentication") + test_domain.register(UserLoggedIn, part_of=User) test_domain.register(UserEventHandler, stream_name="authentication") + test_domain.init(traverse=False) def test_processing_messages_on_start(test_domain): identifier = str(uuid4()) - event = UserLoggedIn(user_id=identifier) - test_domain.event_store.store.append(event) + user = User(user_id=identifier) + user.raise_(UserLoggedIn(user_id=identifier)) + test_domain.repository_for(User).add(user) engine = Engine(domain=test_domain, test_mode=True) engine.run() @@ -58,8 +67,9 @@ def test_processing_messages_on_start(test_domain): def test_that_read_position_is_updated_after_engine_run(test_domain): identifier = str(uuid4()) - event = UserLoggedIn(user_id=identifier) - test_domain.event_store.store.append(event) + user = User(user_id=identifier) + user.raise_(UserLoggedIn(user_id=identifier)) + test_domain.repository_for(User).add(user) messages = test_domain.event_store.store.read("authentication") assert len(messages) == 1 @@ -73,8 +83,9 @@ def test_that_read_position_is_updated_after_engine_run(test_domain): def test_processing_messages_from_beginning_the_first_time(test_domain): identifier = str(uuid4()) - event = UserLoggedIn(user_id=identifier) - test_domain.event_store.store.append(event) + user = User(user_id=identifier) + user.raise_(UserLoggedIn(user_id=identifier)) + test_domain.repository_for(User).add(user) engine = Engine(domain=test_domain, test_mode=True) engine.run() diff --git a/tests/subscription/test_read_position_updates.py b/tests/subscription/test_read_position_updates.py index 3ec55214..7d5d7ca7 100644 --- a/tests/subscription/test_read_position_updates.py +++ b/tests/subscription/test_read_position_updates.py @@ -69,7 +69,7 @@ def register_elements(test_domain): test_domain.register(Email) test_domain.register(Registered, part_of=User) test_domain.register(Activated, part_of=User) - test_domain.register(Sent, stream_name="email") + test_domain.register(Sent, part_of=Email) test_domain.register(UserEventHandler, part_of=User) test_domain.register(EmailEventHandler, stream_name="email") diff --git a/tests/test_brokers.py b/tests/test_brokers.py index 11b6b391..41d4d441 100644 --- a/tests/test_brokers.py +++ b/tests/test_brokers.py @@ -27,7 +27,7 @@ class PersonAdded(BaseEvent): class NotifySSOSubscriber(BaseSubscriber): def __call__(self, domain_event_dict): - print("Received Event: ", domain_event_dict) + pass class AddPersonCommand(BaseCommand): @@ -103,14 +103,16 @@ def test_that_brokers_can_be_registered_manually(self, test_domain): class TestEventPublish: @pytest.mark.eventstore def test_that_event_is_persisted_on_publish(self, mocker, test_domain): - test_domain.publish( + person = Person(first_name="John", last_name="Doe", age=24) + person.raise_( PersonAdded( - id="1234", - first_name="John", - last_name="Doe", - age=24, + id=person.id, + first_name=person.first_name, + last_name=person.last_name, + age=person.age, ) ) + test_domain.publish(person._events[0]) messages = test_domain.event_store.store.read("person") @@ -119,20 +121,28 @@ def test_that_event_is_persisted_on_publish(self, mocker, test_domain): @pytest.mark.eventstore def test_that_multiple_events_are_persisted_on_publish(self, mocker, test_domain): + person1 = Person(id="1234", first_name="John", last_name="Doe", age=24) + person1.raise_( + PersonAdded( + id=person1.id, + first_name=person1.first_name, + last_name=person1.last_name, + age=person1.age, + ) + ) + person2 = Person(id="1235", first_name="Jane", last_name="Doe", age=23) + person2.raise_( + PersonAdded( + id=person2.id, + first_name=person2.first_name, + last_name=person2.last_name, + age=person2.age, + ) + ) test_domain.publish( [ - PersonAdded( - id="1234", - first_name="John", - last_name="Doe", - age=24, - ), - PersonAdded( - id="1235", - first_name="Jane", - last_name="Doe", - age=25, - ), + person1._events[0], + person2._events[0], ] ) From d5343015ef80d59004047438f2e3f2e654c064e5 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 6 Jul 2024 09:14:24 -0700 Subject: [PATCH 3/4] Stream Enhancements - Part 3 Changes: - Add `_event_position` temp variable in aggregates to track last event position in event store - Track expected version per event with `_event_position` - Fetch last event position on aggregate load and update `_event_position` - Unify method to append event to event store in aggregates and event sourced aggregates - Unify method to construct message from event for aggregates and event sourced aggregates - Track database state within the memory provider instead of a global object --- .vscode/launch.json | 2 +- src/protean/adapters/repository/memory.py | 27 +-- src/protean/container.py | 7 + src/protean/core/aggregate.py | 9 +- src/protean/core/entity.py | 18 +- src/protean/core/event.py | 3 + src/protean/core/event_sourced_aggregate.py | 5 + src/protean/core/event_sourced_repository.py | 2 + src/protean/core/repository.py | 14 +- src/protean/core/unit_of_work.py | 15 +- src/protean/port/event_store.py | 21 +- src/protean/reflection.py | 2 - src/protean/utils/mixins.py | 32 +-- .../postgresql/test_json_datatype.py | 14 +- .../repository/elasticsearch_repo/test_dao.py | 6 + .../elasticsearch_repo/test_repo.py | 1 + .../sqlite/test_transactions.py | 1 + .../events/test_aggregate_event_version.py | 208 ++++++++++++++++++ .../test_appending_aggregate_events.py | 8 +- tests/event_store/test_reading_all_streams.py | 10 +- .../test_reading_events_of_type.py | 14 +- .../test_reading_last_event_of_type.py | 26 +-- tests/event_store/test_reading_messages.py | 10 +- tests/message/test_message_to_object.py | 2 +- tests/message/test_object_to_message.py | 2 +- .../test_origin_stream_name_in_metadata.py | 12 +- tests/server/test_any_event_handler.py | 2 +- tests/server/test_error_handling.py | 4 +- tests/server/test_event_handling.py | 2 +- tests/server/test_handling_all_events.py | 6 +- ...st_message_filtering_with_origin_stream.py | 6 +- .../subscription/test_no_message_filtering.py | 6 +- .../test_read_position_updates.py | 6 +- tests/unit_of_work/test_uow_transactions.py | 8 +- 34 files changed, 360 insertions(+), 151 deletions(-) create mode 100644 tests/aggregate/events/test_aggregate_event_version.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 57c0dd41..b64d53d0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -66,7 +66,7 @@ "module": "pytest", "justMyCode": false, "args": [ - "tests/adapters/model/sqlalchemy_model/postgresql/test_array_datatype.py::test_array_data_type_association", + "tests/adapters/model/sqlalchemy_model/postgresql/test_json_datatype.py::test_persistence_of_json_with_array_data", "--postgresql" ] }, diff --git a/src/protean/adapters/repository/memory.py b/src/protean/adapters/repository/memory.py index 03054485..4c7d774c 100644 --- a/src/protean/adapters/repository/memory.py +++ b/src/protean/adapters/repository/memory.py @@ -21,12 +21,6 @@ from protean.reflection import attributes, fields, id_field from protean.utils.query import Q -# Global in-memory store of dict data. Keyed by name, to provide -# multiple named local memory caches. -_databases = defaultdict(dict) -_locks = defaultdict(Lock) -_counters = defaultdict(count) - def derive_schema_name(model_cls): if hasattr(model_cls.meta_, "schema_name"): @@ -63,9 +57,9 @@ def __init__(self, provider, new_connection=False): self._db = current_uow._sessions[self._provider.name]._db else: self._db = { - "data": copy.deepcopy(_databases), - "lock": _locks.setdefault(self._provider.name, Lock()), - "counters": _counters, + "data": copy.deepcopy(self._provider._databases), + "lock": self._provider._locks.setdefault(self._provider.name, Lock()), + "counters": self._provider._counters, } def add(self, element): @@ -84,8 +78,7 @@ def commit(self): if current_uow and self._provider.name in current_uow._sessions: current_uow._sessions[self._provider.name]._db["data"] = self._db["data"] else: - global _databases - _databases = self._db["data"] + self._provider._databases = self._db["data"] def rollback(self): pass @@ -104,6 +97,11 @@ def __init__(self, name, domain, conn_info: dict): conn_info["database"] = "memory" super().__init__(name, domain, conn_info) + # Global in-memory store of dict data. + self._databases = defaultdict(dict) + self._locks = defaultdict(Lock) + self._counters = defaultdict(count) + # A temporary cache of already constructed model classes self._model_classes = {} @@ -122,10 +120,9 @@ def get_connection(self, session_cls=None): def _data_reset(self): """Reset data""" - global _databases, _locks, _counters - _databases = defaultdict(dict) - _locks = defaultdict(Lock) - _counters = defaultdict(count) + self._databases = defaultdict(dict) + self._locks = defaultdict(Lock) + self._counters = defaultdict(count) # Discard any active Unit of Work if current_uow and current_uow.in_progress: diff --git a/src/protean/container.py b/src/protean/container.py index 1447bc63..093d1f14 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -342,6 +342,9 @@ def __setattr__(self, name, value): "_root", # Root entity in the hierarchy "_owner", # Owner entity in the hierarchy "_disable_invariant_checks", # Flag to disable invariant checks + "_next_version", # Temp placeholder to track next version of the entity + "_event_position", # Temp placeholder to track event version of the entity + "_expected_version", # Temp placeholder to track expected version of an event ] or name.startswith(("add_", "remove_", "get_one_from_", "filter_")) ): @@ -408,6 +411,7 @@ def raise_(self, event, fact_event=False) -> None: event_with_metadata = event.__class__( event.to_dict(), + _expected_version=self._event_position, _metadata={ "id": (f"{stream_name}-{identifier}-{self._version}"), "type": f"{self.__class__.__name__}.{event.__class__.__name__}.{event._metadata.version}", @@ -426,6 +430,9 @@ def raise_(self, event, fact_event=False) -> None: }, ) + # Increment the event position after generating event + self._event_position = self._event_position + 1 + self._events.append(event_with_metadata) diff --git a/src/protean/core/aggregate.py b/src/protean/core/aggregate.py index 1a521f6f..3a0c2225 100644 --- a/src/protean/core/aggregate.py +++ b/src/protean/core/aggregate.py @@ -49,7 +49,12 @@ def __new__(cls, *args, **kwargs): _version = Integer(default=-1) # Temporary variable to track next version of Aggregate - _next_version = Integer(default=0) + _next_version = 0 + + # Temporary variable to track version of events of Aggregate + # This can be different from the version of the Aggregate itself because + # a single aggregate update could have triggered multiple events. + _event_position = -1 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -95,7 +100,7 @@ def element_to_fact_event(element_cls): attrs = { key: value for key, value in fields(element_cls).items() - if not isinstance(value, Reference) and key not in ["_next_version"] + if not isinstance(value, Reference) } # Recursively convert HasOne and HasMany associations to Value Objects diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index f58719b1..18b08e9d 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -8,7 +8,12 @@ from functools import partial from protean.container import BaseContainer, IdentityMixin, OptionsMixin -from protean.exceptions import IncorrectUsageError, NotSupportedError, ValidationError +from protean.exceptions import ( + ConfigurationError, + IncorrectUsageError, + NotSupportedError, + ValidationError, +) from protean.fields import Auto, HasMany, Reference, ValueObject from protean.fields.association import Association from protean.reflection import ( @@ -427,6 +432,13 @@ def raise_(self, event) -> None: The event is always registered on the aggregate, irrespective of where it is raised in the entity cluster.""" + # Verify that event is indeed associated with this aggregate + if event.meta_.part_of != self._root.__class__: + raise ConfigurationError( + f"Event `{event.__class__.__name__}` is not associated with" + f" aggregate `{self._root.__class__.__name__}`" + ) + # Events are sometimes raised from within the aggregate, well-before persistence. # In that case, the aggregate's next version has to be considered in events, # because we want to associate the event with the version that will be persisted. @@ -452,6 +464,7 @@ def raise_(self, event) -> None: event_with_metadata = event.__class__( event.to_dict(), + _expected_version=self._root._event_position, _metadata={ "id": ( f"{stream_name}-{identifier}-{aggregate_version}.{event_number}" @@ -472,6 +485,9 @@ def raise_(self, event) -> None: }, ) + # Increment the event position after generating event + self._root._event_position = self._root._event_position + 1 + self._root._events.append(event_with_metadata) def __eq__(self, other): diff --git a/src/protean/core/event.py b/src/protean/core/event.py index c6c4afff..526970f5 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -117,6 +117,9 @@ def __track_id_field(subclass): def __init__(self, *args, **kwargs): super().__init__(*args, finalize=False, **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__") diff --git a/src/protean/core/event_sourced_aggregate.py b/src/protean/core/event_sourced_aggregate.py index 0ba0e7ea..c240ae60 100644 --- a/src/protean/core/event_sourced_aggregate.py +++ b/src/protean/core/event_sourced_aggregate.py @@ -33,6 +33,11 @@ class BaseEventSourcedAggregate( # Track current version of Aggregate _version = Integer(default=-1) + # Temporary variable to track version of events of Aggregate + # This can be different from the version of the Aggregate itself because + # a single aggregate update could have triggered multiple events. + _event_position = -1 + def __new__(cls, *args, **kwargs): if cls is BaseEventSourcedAggregate: raise NotSupportedError("BaseEventSourcedAggregate cannot be instantiated") diff --git a/src/protean/core/event_sourced_repository.py b/src/protean/core/event_sourced_repository.py index f799f00e..6aa03bd9 100644 --- a/src/protean/core/event_sourced_repository.py +++ b/src/protean/core/event_sourced_repository.py @@ -96,6 +96,8 @@ def get(self, identifier: Identifier) -> BaseEventSourcedAggregate: } ) + aggregate._event_position = aggregate._version + return aggregate diff --git a/src/protean/core/repository.py b/src/protean/core/repository.py index ecd0cee5..ac2ea580 100644 --- a/src/protean/core/repository.py +++ b/src/protean/core/repository.py @@ -9,7 +9,7 @@ from protean.core.unit_of_work import UnitOfWork from protean.exceptions import IncorrectUsageError, NotSupportedError from protean.fields import HasMany, HasOne -from protean.globals import current_uow +from protean.globals import current_domain, current_uow from protean.port.dao import BaseDAO from protean.port.provider import BaseProvider from protean.reflection import association_fields, has_association_fields @@ -139,7 +139,6 @@ def add(self, aggregate: BaseAggregate) -> BaseAggregate: # noqa: C901 # Remove state attribute from the payload, as it is not needed for the Fact Event payload.pop("state_", None) - payload.pop("_next_version", None) # Construct and raise the Fact Event fact_event = aggregate._fact_event_cls(**payload) @@ -246,7 +245,16 @@ 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. """ - return self._dao.get(identifier) + aggregate = self._dao.get(identifier) + + # Fetch and sync events version + last_message = current_domain.event_store.store.read_last_message( + f"{aggregate.meta_.stream_name}-{identifier}" + ) + if last_message: + aggregate._event_position = last_message.position + + return aggregate def repository_factory(element_cls, **opts): diff --git a/src/protean/core/unit_of_work.py b/src/protean/core/unit_of_work.py index f7cb9c19..50151fc0 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 DomainObjects, EventProcessing, fqn +from protean.utils import EventProcessing, fqn logger = logging.getLogger(__name__) @@ -78,16 +78,9 @@ def commit(self): # noqa: C901 events = [] for _, item in self._identity_map.items(): if item._events: - if item.element_type == DomainObjects.EVENT_SOURCED_AGGREGATE: - for event in item._events: - current_domain.event_store.store.append_aggregate_event( - item, event - ) - events.append((item, event)) - else: - for event in item._events: - current_domain.event_store.store.append(event) - events.append((item, event)) + for event in item._events: + current_domain.event_store.store.append(event) + events.append((item, event)) item._events = [] # Iteratively consume all events produced in this session diff --git a/src/protean/port/event_store.py b/src/protean/port/event_store.py index 5a2fc49c..57c870ce 100644 --- a/src/protean/port/event_store.py +++ b/src/protean/port/event_store.py @@ -85,12 +85,13 @@ def read( def read_last_message(self, stream_name) -> Message: # FIXME Rename to read_last_stream_message raw_message = self._read_last_message(stream_name) - return Message.from_dict(raw_message) + if raw_message: + return Message.from_dict(raw_message) - def append_aggregate_event( - self, aggregate: BaseEventSourcedAggregate, event: BaseEvent - ) -> int: - message = Message.to_aggregate_event_message(aggregate, event) + return None + + def append(self, object: Union[BaseEvent, BaseCommand]) -> int: + message = Message.to_message(object) position = self._write( message.stream_name, @@ -102,16 +103,6 @@ def append_aggregate_event( return position - def append(self, object: Union[BaseEvent, BaseCommand]) -> int: - message = Message.to_message(object) - - return self._write( - message.stream_name, - message.type, - message.data, - metadata=message.metadata.to_dict(), - ) - def load_aggregate( self, part_of: Type[BaseEventSourcedAggregate], identifier: Identifier ) -> Optional[BaseEventSourcedAggregate]: diff --git a/src/protean/reflection.py b/src/protean/reflection.py index 3975b59c..3c8038de 100644 --- a/src/protean/reflection.py +++ b/src/protean/reflection.py @@ -34,7 +34,6 @@ def data_fields(class_or_instance): fields_dict = dict(getattr(class_or_instance, _FIELDS)) # Remove internal fields - fields_dict.pop("_next_version", None) fields_dict.pop("_metadata", None) except AttributeError: raise IncorrectUsageError( @@ -118,7 +117,6 @@ def declared_fields(class_or_instance): # Remove internal fields fields_dict.pop("_version", None) - fields_dict.pop("_next_version", None) fields_dict.pop("_metadata", None) except AttributeError: raise IncorrectUsageError( diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 314a53d5..32b0b823 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -10,7 +10,6 @@ from protean.container import BaseContainer, OptionsMixin from protean.core.command import BaseCommand from protean.core.event import BaseEvent, Metadata -from protean.core.event_sourced_aggregate import BaseEventSourcedAggregate from protean.core.unit_of_work import UnitOfWork from protean.exceptions import ConfigurationError from protean.globals import current_domain @@ -81,25 +80,6 @@ def from_dict(cls, message: Dict) -> Message: id=message["id"], ) - @classmethod - def to_aggregate_event_message( - cls, aggregate: BaseEventSourcedAggregate, event: BaseEvent - ) -> Message: - # If this is a Fact Event, don't set an expected version. - # Otherwise, expect the previous version - if event.__class__.__name__.endswith("FactEvent"): - expected_version = None - else: - expected_version = int(event._metadata.sequence_id) - 1 - - return cls( - stream_name=event._metadata.stream_name, - type=fully_qualified_name(event.__class__), - data=event.to_dict(), - metadata=event._metadata, - expected_version=expected_version, - ) - def to_object(self) -> Union[BaseEvent, BaseCommand]: if self.metadata.kind == MessageType.EVENT.value: element_record = current_domain.registry.events[self.type] @@ -123,13 +103,21 @@ def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: "Either specify an explicit stream name or associate the event with an aggregate." ) - stream_name = message_object._metadata.stream_name + # Set the expected version of the stream + # Applies only to events + expected_version = None + if message_object._metadata.kind == MessageType.EVENT.value: + # If this is a Fact Event, don't set an expected version. + # Otherwise, expect the previous version + if not message_object.__class__.__name__.endswith("FactEvent"): + expected_version = message_object._expected_version return cls( - stream_name=stream_name, + stream_name=message_object._metadata.stream_name, type=fully_qualified_name(message_object.__class__), data=message_object.to_dict(), metadata=message_object._metadata, + expected_version=expected_version, ) diff --git a/tests/adapters/model/sqlalchemy_model/postgresql/test_json_datatype.py b/tests/adapters/model/sqlalchemy_model/postgresql/test_json_datatype.py index efbc3fd7..405ebac9 100644 --- a/tests/adapters/model/sqlalchemy_model/postgresql/test_json_datatype.py +++ b/tests/adapters/model/sqlalchemy_model/postgresql/test_json_datatype.py @@ -13,18 +13,20 @@ class Event(BaseAggregate): payload = Dict() -@pytest.mark.postgresql -def test_json_data_type_association(test_domain): +@pytest.fixture(autouse=True) +def register_elements(test_domain): test_domain.register(Event) + test_domain.init(traverse=False) + +@pytest.mark.postgresql +def test_json_data_type_association(test_domain): model_cls = test_domain.repository_for(Event)._model type(model_cls.payload.property.columns[0].type) is sa_types.JSON @pytest.mark.postgresql def test_basic_dict_data_type_operations(test_domain): - test_domain.register(Event) - model_cls = test_domain.repository_for(Event)._model event = Event( @@ -39,8 +41,6 @@ def test_basic_dict_data_type_operations(test_domain): @pytest.mark.postgresql def test_json_with_array_data(test_domain): - test_domain.register(Event) - model_cls = test_domain.repository_for(Event)._model event = Event( @@ -62,8 +62,6 @@ def test_json_with_array_data(test_domain): @pytest.mark.postgresql def test_persistence_of_json_with_array_data(test_domain): - test_domain.register(Event) - event = Event( name="UserCreated", payload=[ diff --git a/tests/adapters/repository/elasticsearch_repo/test_dao.py b/tests/adapters/repository/elasticsearch_repo/test_dao.py index c52d085e..39735730 100644 --- a/tests/adapters/repository/elasticsearch_repo/test_dao.py +++ b/tests/adapters/repository/elasticsearch_repo/test_dao.py @@ -17,6 +17,7 @@ class TestDAO: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) def test_successful_initialization_of_dao(self, test_domain): test_domain.repository_for(Person)._dao.query.all() @@ -59,6 +60,7 @@ class TestDAODeleteFunctionality: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) def test_delete_an_object_in_repository_by_id(self, test_domain): """Delete an object in the repository by ID""" @@ -214,6 +216,7 @@ class TestDAORetrievalFunctionality: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) @pytest.fixture def identifier(self): @@ -670,6 +673,7 @@ class TestDAOSaveFunctionality: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) def test_creation_throws_error_on_missing_fields(self, test_domain): """Add an entity to the repository missing a required attribute""" @@ -712,6 +716,7 @@ class TestDAOUpdateFunctionality: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) def test_update_an_existing_entity_in_the_repository(self, test_domain): identifier = uuid4() @@ -902,6 +907,7 @@ class TestDAOValidations: def register_elements(self, test_domain): test_domain.register(Person) test_domain.register(User) + test_domain.init(traverse=False) @pytest.mark.xfail def test_unique(self, test_domain): diff --git a/tests/adapters/repository/elasticsearch_repo/test_repo.py b/tests/adapters/repository/elasticsearch_repo/test_repo.py index d3cfac8c..ba756e65 100644 --- a/tests/adapters/repository/elasticsearch_repo/test_repo.py +++ b/tests/adapters/repository/elasticsearch_repo/test_repo.py @@ -12,6 +12,7 @@ class TestElasticsearchRepository: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Person) + test_domain.init(traverse=False) @pytest.fixture def identifier(self): diff --git a/tests/adapters/repository/sqlalchemy_repo/sqlite/test_transactions.py b/tests/adapters/repository/sqlalchemy_repo/sqlite/test_transactions.py index da7a392b..a0850c95 100644 --- a/tests/adapters/repository/sqlalchemy_repo/sqlite/test_transactions.py +++ b/tests/adapters/repository/sqlalchemy_repo/sqlite/test_transactions.py @@ -22,6 +22,7 @@ def clear_uow(self): def register_elements(self, test_domain): test_domain.register(Person) test_domain.register(PersonRepository, part_of=Person) + test_domain.init(traverse=False) def random_name(self): return "".join(random.choices(string.ascii_uppercase + string.digits, k=15)) diff --git a/tests/aggregate/events/test_aggregate_event_version.py b/tests/aggregate/events/test_aggregate_event_version.py new file mode 100644 index 00000000..947e20de --- /dev/null +++ b/tests/aggregate/events/test_aggregate_event_version.py @@ -0,0 +1,208 @@ +from enum import Enum + +import pytest + +from protean import BaseAggregate, BaseEntity, BaseEvent +from protean.fields import HasOne, Identifier, String + + +class UserStatus(Enum): + INACTIVE = "INACTIVE" + ACTIVE = "ACTIVE" + ARCHIVED = "ARCHIVED" + + +class Account(BaseEntity): + password_hash = String(max_length=512) + + def change_password(self, password): + self.password_hash = password + self.raise_(PasswordChanged(account_id=self.id, user_id=self.user_id)) + + +class PasswordChanged(BaseEvent): + account_id = Identifier(required=True) + user_id = Identifier(required=True) + + +class User(BaseAggregate): + name = String(max_length=50, required=True) + email = String(required=True) + status = String(choices=UserStatus, default=UserStatus.INACTIVE.value) + + account = HasOne(Account) + + @classmethod + def register(cls, name, email): + user = cls(name=name, email=email) + user.raise_(UserRegistered(user_id=user.id, name=name, email=email)) + + return user + + def activate(self): + self.status = UserStatus.ACTIVE.value + self.raise_(UserActivated(user_id=self.id)) + + def change_email(self, email): + # This method generates no events + self.email = email + + def change_name(self, name): + self.name = name + self.raise_(UserRenamed(user_id=self.id, name=name)) + + +class UserRegistered(BaseEvent): + user_id = Identifier(required=True) + name = String(max_length=50, required=True) + email = String(required=True) + + +class UserActivated(BaseEvent): + user_id = Identifier(required=True) + + +class UserRenamed(BaseEvent): + user_id = Identifier(required=True) + name = String(required=True, max_length=50) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User) + test_domain.register(Account, part_of=User) + test_domain.register(UserRegistered, part_of=User) + test_domain.register(UserActivated, part_of=User) + test_domain.register(UserRenamed, part_of=User) + test_domain.register(PasswordChanged, part_of=User) + test_domain.init(traverse=False) + + +@pytest.fixture +def user(): + return User.register(name="John Doe", email="john.doe@gmail.com") + + +def test_aggregate_tracks_event_version(user): + assert user._version == -1 + + # The aggregate's event position would have been incremented + assert user._event_position == 0 + + # Check for expected version inside the event + assert user._events[0]._expected_version == -1 + + +def test_aggregate_tracks_event_version_after_first_update(user, test_domain): + assert user._events[0]._expected_version == -1 + + test_domain.repository_for(User).add(user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + + assert refreshed_user._version == 0 + assert refreshed_user._event_position == 0 + + +def test_aggregate_tracks_event_version_after_multiple_updates(user, test_domain): + test_domain.repository_for(User).add(user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + refreshed_user.activate() + + assert refreshed_user._events[0]._expected_version == 0 + + test_domain.repository_for(User).add(refreshed_user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + assert refreshed_user._version == 1 + assert refreshed_user._event_position == 1 + + +def test_aggregate_manages_event_version_with_an_update_and_no_events(test_domain): + # We initialize user directly here to avoid raising events + user = User(name="John Doe", email="john.doe@gmail.com") + + assert len(user._events) == 0 + + test_domain.repository_for(User).add(user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + assert refreshed_user._version == 0 + assert refreshed_user._event_position == -1 + + +def test_aggregate_manages_event_version_with_multiple_updates_and_no_events( + test_domain, +): + user = User(name="John Doe", email="john.doe@gmail.com") + test_domain.repository_for(User).add(user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + refreshed_user.change_email("jane.doe@gmail.com") + + assert len(user._events) == 0 + + test_domain.repository_for(User).add(refreshed_user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + assert refreshed_user._version == 1 + assert refreshed_user._event_position == -1 + + +def test_aggregate_tracks_event_version_after_an_update_with_multiple_events( + user, test_domain +): + test_domain.repository_for(User).add(user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + refreshed_user.change_name("Jane Doe") + refreshed_user.activate() + + assert refreshed_user._events[0]._expected_version == 0 + assert refreshed_user._events[1]._expected_version == 1 + + test_domain.repository_for(User).add(refreshed_user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + assert refreshed_user._version == 1 + assert refreshed_user._event_position == 2 + + +@pytest.mark.xfail +def test_aggregate_tracks_event_version_after_multiple_updates_with_multiple_events( + user, test_domain +): + test_domain.repository_for(User).add(user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + refreshed_user.change_name("Jane Doe") + refreshed_user.activate() + + test_domain.repository_for(User).add(refreshed_user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + + assert refreshed_user._version == 1 + assert refreshed_user._event_position == 2 + + refreshed_user.account = Account(password_hash="hashed_password") + + test_domain.repository_for(User).add(refreshed_user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + + assert refreshed_user._version == 2 + assert refreshed_user._event_position == 2 + + refreshed_user = test_domain.repository_for(User).get(user.id) + refreshed_user.account.change_password("new_password") + + test_domain.repository_for(User).add(refreshed_user) + + refreshed_user = test_domain.repository_for(User).get(user.id) + + # FIXME This is a bug. Version and event position should be 3 + # The problem is that the aggregate root is not aware of changes within its child entities + assert refreshed_user._version == 3 + assert refreshed_user._event_position == 3 diff --git a/tests/event_store/test_appending_aggregate_events.py b/tests/event_store/test_appending_aggregate_events.py index c3218208..0f5053ea 100644 --- a/tests/event_store/test_appending_aggregate_events.py +++ b/tests/event_store/test_appending_aggregate_events.py @@ -70,7 +70,7 @@ def register_elements(test_domain): def test_appending_messages_to_aggregate(test_domain): identifier = str(uuid4()) user = User.register(id=identifier, email="john.doe@example.com", name="John Doe") - test_domain.event_store.store.append_aggregate_event(user, user._events[0]) + test_domain.event_store.store.append(user._events[0]) messages = test_domain.event_store.store._read("user") @@ -81,19 +81,19 @@ def test_appending_messages_to_aggregate(test_domain): def test_version_increment_on_new_event(test_domain): identifier = str(uuid4()) user = User.register(id=identifier, email="john.doe@example.com", name="John Doe") - test_domain.event_store.store.append_aggregate_event(user, user._events[0]) + test_domain.event_store.store.append(user._events[0]) events = test_domain.event_store.store._read(f"user-{identifier}") assert events[0]["position"] == 0 user.activate() - test_domain.event_store.store.append_aggregate_event(user, user._events[1]) + test_domain.event_store.store.append(user._events[1]) events = test_domain.event_store.store._read(f"user-{identifier}") assert events[-1]["position"] == 1 user.rename(name="John Doe 2") - test_domain.event_store.store.append_aggregate_event(user, user._events[2]) + test_domain.event_store.store.append(user._events[2]) events = test_domain.event_store.store._read(f"user-{identifier}") assert events[-1]["position"] == 2 diff --git a/tests/event_store/test_reading_all_streams.py b/tests/event_store/test_reading_all_streams.py index 11a14c7c..58ec623a 100644 --- a/tests/event_store/test_reading_all_streams.py +++ b/tests/event_store/test_reading_all_streams.py @@ -89,20 +89,20 @@ def test_reading_messages_from_all_streams(test_domain): user = User.register( id=user_identifier, email="john.doe@example.com", name="John Doe" ) - test_domain.event_store.store.append_aggregate_event(user, user._events[0]) + test_domain.event_store.store.append(user._events[0]) user.activate() - test_domain.event_store.store.append_aggregate_event(user, user._events[1]) + test_domain.event_store.store.append(user._events[1]) user.rename(name="Johnny Doe") - test_domain.event_store.store.append_aggregate_event(user, user._events[2]) + test_domain.event_store.store.append(user._events[2]) post_identifier = str(uuid4()) post = Post.create(id=post_identifier, topic="Foo", content="Bar") - test_domain.event_store.store.append_aggregate_event(post, post._events[0]) + test_domain.event_store.store.append(post._events[0]) post.publish() - test_domain.event_store.store.append_aggregate_event(post, post._events[1]) + test_domain.event_store.store.append(post._events[1]) messages = test_domain.event_store.store.read("$all") assert len(messages) == 5 diff --git a/tests/event_store/test_reading_events_of_type.py b/tests/event_store/test_reading_events_of_type.py index 1b14ac1a..5185debc 100644 --- a/tests/event_store/test_reading_events_of_type.py +++ b/tests/event_store/test_reading_events_of_type.py @@ -57,7 +57,7 @@ def registered_user(test_domain): identifier = str(uuid4()) user = User.register(id=identifier, email="john.doe@example.com", name="John Doe") - test_domain.event_store.store.append_aggregate_event(user, user._events[0]) + test_domain.event_store.store.append(user._events[0]) return user @@ -72,9 +72,7 @@ def test_reading_events_of_type_with_just_one_message(test_domain, registered_us @pytest.mark.eventstore def test_reading_events_of_type_with_other_events_present(test_domain, registered_user): registered_user.activate() - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[1] - ) + test_domain.event_store.store.append(registered_user._events[1]) assert isinstance(test_domain.event_store.events_of_type(Registered)[0], Registered) assert isinstance(test_domain.event_store.events_of_type(Activated)[0], Activated) @@ -84,15 +82,11 @@ class TestEventStoreEventsOfType: @pytest.fixture(autouse=True) def activate_and_rename(self, registered_user, test_domain): registered_user.activate() - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[1] - ) + test_domain.event_store.store.append(registered_user._events[1]) for i in range(10): registered_user.rename(name=f"John Doe {i}") - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[-1] - ) + test_domain.event_store.store.append(registered_user._events[-1]) yield diff --git a/tests/event_store/test_reading_last_event_of_type.py b/tests/event_store/test_reading_last_event_of_type.py index 7deb9a5d..40de226a 100644 --- a/tests/event_store/test_reading_last_event_of_type.py +++ b/tests/event_store/test_reading_last_event_of_type.py @@ -57,7 +57,7 @@ def registered_user(test_domain): identifier = str(uuid4()) user = User.register(id=identifier, email="john.doe@example.com", name="John Doe") - test_domain.event_store.store.append_aggregate_event(user, user._events[0]) + test_domain.event_store.store.append(user._events[0]) return user @@ -73,9 +73,7 @@ def test_reading_the_last_event_of_type_with_other_events_present( test_domain, registered_user ): registered_user.activate() - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[1] - ) + test_domain.event_store.store.append(registered_user._events[1]) assert isinstance( test_domain.event_store.last_event_of_type(Registered), Registered @@ -87,15 +85,11 @@ class TestEventStoreEventsOfType: @pytest.fixture(autouse=True) def activate_and_rename(self, registered_user, test_domain): registered_user.activate() - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[1] - ) + test_domain.event_store.store.append(registered_user._events[1]) for i in range(10): registered_user.rename(name=f"John Doe {i}") - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[-1] - ) + test_domain.event_store.store.append(registered_user._events[-1]) yield @@ -105,9 +99,7 @@ def test_reading_the_last_event_of_type_with_multiple_events( ): for i in range(10): registered_user.rename(name=f"John Doe {i}") - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[-1] - ) + test_domain.event_store.store.append(registered_user._events[-1]) event = test_domain.event_store.last_event_of_type(Renamed) assert event.name == "John Doe 9" @@ -118,9 +110,7 @@ def test_reading_the_last_event_of_type_with_multiple_events_in_stream( ): for i in range(10): registered_user.rename(name=f"John Doe {i}") - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[-1] - ) + test_domain.event_store.store.append(registered_user._events[-1]) event = test_domain.event_store.last_event_of_type(Renamed, "user") assert event.name == "John Doe 9" @@ -131,9 +121,7 @@ def test_reading_the_last_event_of_type_with_multiple_events_in_different_stream ): for i in range(10): registered_user.rename(name=f"John Doe {i}") - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[-1] - ) + test_domain.event_store.store.append(registered_user._events[-1]) event = test_domain.event_store.last_event_of_type(Renamed, "group") assert event is None diff --git a/tests/event_store/test_reading_messages.py b/tests/event_store/test_reading_messages.py index 23c458f3..38d58281 100644 --- a/tests/event_store/test_reading_messages.py +++ b/tests/event_store/test_reading_messages.py @@ -44,7 +44,7 @@ def registered_user(test_domain): identifier = str(uuid4()) user = User(id=identifier, email="john.doe@example.com") user.raise_(Registered(id=identifier, email="john.doe@example.com")) - test_domain.event_store.store.append_aggregate_event(user, user._events[-1]) + test_domain.event_store.store.append(user._events[-1]) return user @@ -52,9 +52,7 @@ def registered_user(test_domain): @pytest.fixture def activated_user(test_domain, registered_user): registered_user.raise_(Activated(id=registered_user.id)) - test_domain.event_store.store.append_aggregate_event( - registered_user, registered_user._events[-1] - ) + test_domain.event_store.store.append(registered_user._events[-1]) return registered_user @@ -63,9 +61,7 @@ def activated_user(test_domain, registered_user): def renamed_user(test_domain, activated_user): for i in range(10): activated_user.raise_(Renamed(id=activated_user.id, name=f"John Doe {i}")) - test_domain.event_store.store.append_aggregate_event( - activated_user, activated_user._events[-1] - ) + test_domain.event_store.store.append(activated_user._events[-1]) return activated_user diff --git a/tests/message/test_message_to_object.py b/tests/message/test_message_to_object.py index 54aeff11..72f4e281 100644 --- a/tests/message/test_message_to_object.py +++ b/tests/message/test_message_to_object.py @@ -49,7 +49,7 @@ def test_construct_event_from_message(): identifier = str(uuid4()) user = User(id=identifier, email="john.doe@gmail.com", name="John Doe") user.raise_(Registered(id=identifier, email="john.doe@gmail.com", name="John Doe")) - message = Message.to_aggregate_event_message(user, user._events[-1]) + message = Message.to_message(user._events[-1]) reconstructed_event = message.to_object() assert isinstance(reconstructed_event, Registered) diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index 6f4f9892..31ca12d8 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -53,7 +53,7 @@ def test_construct_message_from_event(test_domain): user.raise_(Registered(id=identifier, email="john.doe@gmail.com", name="John Doe")) # This simulates the call by UnitOfWork - message = Message.to_aggregate_event_message(user, user._events[-1]) + message = Message.to_message(user._events[-1]) assert message is not None assert type(message) is Message diff --git a/tests/message/test_origin_stream_name_in_metadata.py b/tests/message/test_origin_stream_name_in_metadata.py index 3d82c264..a20f882c 100644 --- a/tests/message/test_origin_stream_name_in_metadata.py +++ b/tests/message/test_origin_stream_name_in_metadata.py @@ -71,13 +71,15 @@ def test_origin_stream_name_in_event_from_command_without_origin_stream_name( ): g.message_in_context = register_command_message - event_message = Message.to_message( + user = User(id=user_id, email="john.doe@gmail.com", name="John Doe") + user.raise_( Registered( user_id=user_id, email="john.doe@gmail.com", name="John Doe", ) ) + event_message = Message.to_message(user._events[-1]) assert event_message.metadata.origin_stream_name is None @@ -91,13 +93,15 @@ def test_origin_stream_name_in_event_from_command_with_origin_stream_name( ) # Metadata is a VO and immutable, so creating a copy with updated value g.message_in_context = command_message - event_message = Message.to_message( + user = User(id=user_id, email="john.doe@gmail.com", name="John Doe") + user.raise_( Registered( user_id=user_id, email="john.doe@gmail.com", name="John Doe", ) ) + event_message = Message.to_message(user._events[-1]) assert event_message.metadata.origin_stream_name == "foo" @@ -118,7 +122,7 @@ def test_origin_stream_name_in_aggregate_event_from_command_without_origin_strea name="John Doe", ) ) - event_message = Message.to_aggregate_event_message(user, user._events[-1]) + event_message = Message.to_message(user._events[-1]) assert event_message.metadata.origin_stream_name is None @@ -145,7 +149,7 @@ def test_origin_stream_name_in_aggregate_event_from_command_with_origin_stream_n name="John Doe", ) ) - event_message = Message.to_aggregate_event_message(user, user._events[-1]) + event_message = Message.to_message(user._events[-1]) assert event_message.metadata.origin_stream_name == "foo" diff --git a/tests/server/test_any_event_handler.py b/tests/server/test_any_event_handler.py index df4951f7..36a48b96 100644 --- a/tests/server/test_any_event_handler.py +++ b/tests/server/test_any_event_handler.py @@ -55,7 +55,7 @@ async def test_that_an_event_handler_can_be_associated_with_an_all_stream(test_d password_hash="hash", ) ) - message = Message.to_aggregate_event_message(user, user._events[-1]) + message = Message.to_message(user._events[-1]) engine = Engine(domain=test_domain, test_mode=True) await engine.handle_message(UserEventHandler, message) diff --git a/tests/server/test_error_handling.py b/tests/server/test_error_handling.py index a94f2d6e..8f2fca26 100644 --- a/tests/server/test_error_handling.py +++ b/tests/server/test_error_handling.py @@ -67,7 +67,7 @@ async def test_that_exception_is_raised(test_domain): password_hash="hash", ) ) - message = Message.to_aggregate_event_message(user, user._events[-1]) + message = Message.to_message(user._events[-1]) engine = Engine(domain=test_domain, test_mode=True) @@ -96,7 +96,7 @@ def test_exceptions_stop_processing(test_domain): password_hash="hash", ) ) - test_domain.event_store.store.append_aggregate_event(user, user._events[0]) + test_domain.event_store.store.append(user._events[0]) engine = Engine(domain=test_domain) engine.run() diff --git a/tests/server/test_event_handling.py b/tests/server/test_event_handling.py index aacc83e1..53a1c3ec 100644 --- a/tests/server/test_event_handling.py +++ b/tests/server/test_event_handling.py @@ -57,7 +57,7 @@ async def test_handler_invocation(test_domain): password_hash="hash", ) ) - message = Message.to_aggregate_event_message(user, user._events[-1]) + message = Message.to_message(user._events[-1]) engine = Engine(domain=test_domain, test_mode=True) await engine.handle_message(UserEventHandler, message) diff --git a/tests/server/test_handling_all_events.py b/tests/server/test_handling_all_events.py index f9510414..a66fbaba 100644 --- a/tests/server/test_handling_all_events.py +++ b/tests/server/test_handling_all_events.py @@ -69,7 +69,7 @@ async def test_that_any_message_can_be_handled_with_any_handler(test_domain): password_hash="hash", ) ) - message1 = Message.to_aggregate_event_message(user, user._events[-1]) + message1 = Message.to_message(user._events[-1]) post_identifier = str(uuid4()) post = Post( @@ -79,8 +79,8 @@ async def test_that_any_message_can_be_handled_with_any_handler(test_domain): ) post.raise_(Created(id=post_identifier, topic="Foo", content="Bar")) - test_domain.event_store.store.append_aggregate_event(post, post._events[-1]) - message2 = Message.to_aggregate_event_message(post, post._events[-1]) + test_domain.event_store.store.append(post._events[-1]) + message2 = Message.to_message(post._events[-1]) engine = Engine(domain=test_domain, test_mode=True) await engine.handle_message(SystemMetrics, message1) diff --git a/tests/subscription/test_message_filtering_with_origin_stream.py b/tests/subscription/test_message_filtering_with_origin_stream.py index 14cfd481..be3595c7 100644 --- a/tests/subscription/test_message_filtering_with_origin_stream.py +++ b/tests/subscription/test_message_filtering_with_origin_stream.py @@ -94,9 +94,9 @@ async def test_message_filtering_for_event_handlers_with_defined_origin_stream( email.raise_(Sent(email="john.doe@gmail.com", sent_at=datetime.now(UTC))) # Construct 3 dummy messages and modify Sent message to have originated from the user stream messages = [ - Message.to_aggregate_event_message(user, user._events[0]), - Message.to_aggregate_event_message(user, user._events[1]), - Message.to_aggregate_event_message(email, email._events[0]), + Message.to_message(user._events[0]), + Message.to_message(user._events[1]), + Message.to_message(email._events[0]), ] messages[2].metadata = Metadata( diff --git a/tests/subscription/test_no_message_filtering.py b/tests/subscription/test_no_message_filtering.py index 1436232c..fffa869d 100644 --- a/tests/subscription/test_no_message_filtering.py +++ b/tests/subscription/test_no_message_filtering.py @@ -93,9 +93,9 @@ async def test_no_filtering_for_event_handlers_without_defined_origin_stream( email.raise_(Sent(email="john.doe@gmail.com", sent_at=datetime.now(UTC))) # Construct 3 dummy messages and modify Sent message to have originated from the user stream messages = [ - Message.to_aggregate_event_message(user, user._events[0]), - Message.to_aggregate_event_message(user, user._events[1]), - Message.to_aggregate_event_message(email, email._events[0]), + Message.to_message(user._events[0]), + Message.to_message(user._events[1]), + Message.to_message(email._events[0]), ] messages[2].metadata = Metadata( diff --git a/tests/subscription/test_read_position_updates.py b/tests/subscription/test_read_position_updates.py index 7d5d7ca7..9179e20e 100644 --- a/tests/subscription/test_read_position_updates.py +++ b/tests/subscription/test_read_position_updates.py @@ -103,7 +103,7 @@ async def test_write_position_after_interval(test_domain): last_written_position = await email_event_handler_subscription.fetch_last_position() assert last_written_position == -1 # Default value - test_domain.event_store.store.append_aggregate_event(email, email._events[0]) + test_domain.event_store.store.append(email._events[0]) await email_event_handler_subscription.tick() @@ -115,7 +115,7 @@ async def test_write_position_after_interval(test_domain): # Populate 15 messages (5 more than default interval) for _ in range(15): email.raise_(event) - test_domain.event_store.store.append_aggregate_event(email, email._events[-1]) + test_domain.event_store.store.append(email._events[-1]) await email_event_handler_subscription.tick() last_written_position = await email_event_handler_subscription.fetch_last_position() @@ -150,7 +150,7 @@ async def test_that_positions_are_not_written_when_already_in_sync(test_domain): # Populate 15 messages (5 more than default interval) for _ in range(15): email.raise_(event) - test_domain.event_store.store.append_aggregate_event(email, email._events[-1]) + test_domain.event_store.store.append(email._events[-1]) # Consume messages (By default, 10 messages per tick) await email_event_handler_subscription.tick() diff --git a/tests/unit_of_work/test_uow_transactions.py b/tests/unit_of_work/test_uow_transactions.py index 6e4edc96..d87c0bec 100644 --- a/tests/unit_of_work/test_uow_transactions.py +++ b/tests/unit_of_work/test_uow_transactions.py @@ -85,14 +85,14 @@ def test_changed_objects_are_committed_as_part_of_one_transaction( repo_with_uow.add(person_to_be_added) # Update an existing Person record - person_to_be_updated.last_name = "FooBar" - repo_with_uow.add(person_to_be_updated) + persisted_person = repo.get(person_to_be_updated.id) + persisted_person.last_name = "FooBar" + repo_with_uow.add(persisted_person) # Test that the underlying database is untouched assert len(person_dao.outside_uow().query.all().items) == 1 assert ( - person_dao.outside_uow().get(person_to_be_updated.id).last_name - != "FooBar" + person_dao.outside_uow().get(persisted_person.id).last_name != "FooBar" ) assert len(person_dao.query.all().items) == 2 From f733575b21fe415a392f73c219078f8e782c9d00 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 6 Jul 2024 19:42:23 -0700 Subject: [PATCH 4/4] Add tests and increase coverage --- .coveragerc | 4 +- .vscode/launch.json | 2 +- src/protean/adapters/event_store/__init__.py | 62 +++++---- src/protean/cli/__init__.py | 17 +++ src/protean/core/command_handler.py | 10 ++ src/protean/core/entity.py | 105 +++++++-------- src/protean/domain/__init__.py | 121 ++++++++---------- src/protean/fields/basic.py | 37 +++--- src/protean/utils/__init__.py | 1 - src/protean/utils/mixins.py | 14 +- .../model/elasticsearch_model/tests.py | 4 +- .../test_event_association_with_aggregate.py | 61 +++++++++ .../test_aggregate_initialization.py | 9 ++ tests/command_handler/test_basics.py | 83 +++++++++++- .../test_retrieving_handlers_by_command.py | 6 + tests/domain/tests.py | 6 + tests/entity/test_fields_cache.py | 42 ++++++ .../test_event_handler_options.py | 7 +- tests/field/test_field_validators.py | 60 +++++++++ tests/field/test_identifier.py | 21 ++- tests/message/test_message_to_object.py | 17 +++ tests/message/test_object_to_message.py | 14 ++ 22 files changed, 513 insertions(+), 190 deletions(-) create mode 100644 tests/aggregate/events/test_event_association_with_aggregate.py create mode 100644 tests/entity/test_fields_cache.py diff --git a/.coveragerc b/.coveragerc index 8481cc63..6adc759f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -18,4 +18,6 @@ omit = show_missing = true precision = 2 omit = *migrations* - +exclude_lines = + pragma: no cover + if TYPE_CHECKING: diff --git a/.vscode/launch.json b/.vscode/launch.json index b64d53d0..86c57f24 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -108,7 +108,7 @@ "module": "pytest", "justMyCode": false, "args": [ - "tests/adapters/model/elasticsearch_model/tests.py::TestDefaultModel::test_dynamically_constructed_model_attributes", + "tests/adapters/model/elasticsearch_model/tests.py::TestModelWithVO::test_conversion_from_model_to_entity", "--elasticsearch" ] }, diff --git a/src/protean/adapters/event_store/__init__.py b/src/protean/adapters/event_store/__init__.py index be7be755..9b315dc3 100644 --- a/src/protean/adapters/event_store/__init__.py +++ b/src/protean/adapters/event_store/__init__.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import importlib import logging from collections import defaultdict -from typing import List, Optional, Type +from typing import TYPE_CHECKING, DefaultDict, List, Optional, Set, Type from protean import BaseEvent, BaseEventHandler from protean.core.command import BaseCommand @@ -14,6 +16,10 @@ from protean.utils import fqn from protean.utils.mixins import Message +if TYPE_CHECKING: + from protean.domain import Domain + from protean.port.event_store import BaseEventStore + logger = logging.getLogger(__name__) EVENT_STORE_PROVIDERS = { @@ -24,45 +30,47 @@ class EventStore: def __init__(self, domain): - self.domain = domain - self._event_store = None - self._event_streams = None - self._command_streams = None + self.domain: Domain = domain + self._event_store: BaseEventStore = None + self._event_streams: DefaultDict[str, Set[BaseEventHandler]] = defaultdict(set) + self._command_streams: DefaultDict[str, Set[BaseCommandHandler]] = defaultdict( + set + ) @property def store(self): return self._event_store - def _initialize(self): - logger.debug("Initializing Event Store...") - + def _initialize_event_store(self) -> BaseEventStore: configured_event_store = self.domain.config["event_store"] - if configured_event_store and isinstance(configured_event_store, dict): - event_store_full_path = EVENT_STORE_PROVIDERS[ - configured_event_store["provider"] - ] - event_store_module, event_store_class = event_store_full_path.rsplit( - ".", maxsplit=1 - ) + event_store_full_path = EVENT_STORE_PROVIDERS[ + configured_event_store["provider"] + ] + event_store_module, event_store_class = event_store_full_path.rsplit( + ".", maxsplit=1 + ) - event_store_cls = getattr( - importlib.import_module(event_store_module), event_store_class - ) + event_store_cls = getattr( + importlib.import_module(event_store_module), event_store_class + ) + + store = event_store_cls(self.domain, configured_event_store) - store = event_store_cls(self.domain, configured_event_store) - else: - raise ConfigurationError("Configure at least one event store in the domain") + return store + + def _initialize(self) -> None: + logger.debug("Initializing Event Store...") - self._event_store = store + # Initialize the Event Store + # + # An event store is always present by default. If not configured explicitly, + # a memory-based event store is used. + self._event_store = self._initialize_event_store() self._initialize_event_streams() self._initialize_command_streams() - return self._event_store - def _initialize_event_streams(self): - self._event_streams = defaultdict(set) - for _, record in self.domain.registry.event_handlers.items(): stream_name = ( record.cls.meta_.stream_name @@ -71,8 +79,6 @@ def _initialize_event_streams(self): self._event_streams[stream_name].add(record.cls) def _initialize_command_streams(self): - self._command_streams = defaultdict(set) - for _, record in self.domain.registry.command_handlers.items(): self._command_streams[record.cls.meta_.part_of.meta_.stream_name].add( record.cls diff --git a/src/protean/cli/__init__.py b/src/protean/cli/__init__.py index 861b7e8d..e4d2d2e4 100644 --- a/src/protean/cli/__init__.py +++ b/src/protean/cli/__init__.py @@ -47,6 +47,7 @@ class Category(str, Enum): CORE = "CORE" EVENTSTORE = "EVENTSTORE" DATABASE = "DATABASE" + COVERAGE = "COVERAGE" FULL = "FULL" @@ -119,6 +120,22 @@ def test( for store in ["MESSAGE_DB"]: print(f"Running tests for EVENTSTORE: {store}...") subprocess.call(commands + ["-m", "eventstore", f"--store={store}"]) + case "COVERAGE": + subprocess.call( + commands + + [ + "--slow", + "--sqlite", + "--postgresql", + "--elasticsearch", + "--redis", + "--message_db", + "--cov=protean", + "--cov-config", + ".coveragerc", + "tests", + ] + ) case _: print("Running core tests...") subprocess.call(commands) diff --git a/src/protean/core/command_handler.py b/src/protean/core/command_handler.py index 2a2e3706..f56a0263 100644 --- a/src/protean/core/command_handler.py +++ b/src/protean/core/command_handler.py @@ -86,6 +86,16 @@ def command_handler_factory(element_cls, **kwargs): } ) + 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" + ] + } + ) + # 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 18b08e9d..bc7115d7 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -138,21 +138,6 @@ def _default_options(cls): ("schema_name", inflection.underscore(cls.__name__)), ] - @classmethod - def _extract_options(cls, **opts): - """A stand-in method for setting customized options on the Domain Element - - Empty by default. To be overridden in each Element that expects or needs - specific options. - """ - for key, default in cls._default_options(): - value = ( - opts.pop(key, None) - or (hasattr(cls.meta_, key) and getattr(cls.meta_, key)) - or default - ) - setattr(cls.meta_, key, value) - def __init__(self, *template, **kwargs): # noqa: C901 """ Initialise the entity object. @@ -217,6 +202,9 @@ def __init__(self, *template, **kwargs): # noqa: C901 id_field_obj = id_field(self) id_field_name = id_field_obj.field_name + ############ + # ID Value # + ############ # Look for id field in kwargs and load value if present if kwargs and id_field_name in kwargs: setattr(self, id_field_name, kwargs.pop(id_field_name)) @@ -225,7 +213,7 @@ def __init__(self, *template, **kwargs): # noqa: C901 # Look for id field in template dictionary and load value if present for dictionary in template: if id_field_name in dictionary: - setattr(self, id_field_name, dictionary[id_field_name]) + setattr(self, id_field_name, dictionary.pop(id_field_name)) loaded_fields.append(id_field_name) break else: @@ -242,33 +230,40 @@ def __init__(self, *template, **kwargs): # noqa: C901 ) loaded_fields.append(id_field_name) - # Load the attributes based on the template + ######################## + # Load supplied values # + ######################## + # Gather values from template + template_values = {} for dictionary in template: if not isinstance(dictionary, dict): raise AssertionError( - f'Positional argument "{dictionary}" passed must be a dict.' + f"Positional argument {dictionary} passed must be a dict. " f"This argument serves as a template for loading common " f"values.", ) for field_name, val in dictionary.items(): - if field_name not in kwargs and field_name not in loaded_fields: - kwargs[field_name] = val - - # Now load against the keyword arguments - for field_name, val in kwargs.items(): - if field_name not in loaded_fields: - try: - setattr(self, field_name, val) - except ValidationError as err: - for field_name in err.messages: - self.errors[field_name].extend(err.messages[field_name]) - finally: - loaded_fields.append(field_name) + template_values[field_name] = val - # Also note reference field name if its attribute was loaded - if field_name in reference_attributes: - loaded_fields.append(reference_attributes[field_name]) + supplied_values = {**template_values, **kwargs} + # Now load the attributes from template and kwargs + for field_name, val in supplied_values.items(): + try: + setattr(self, field_name, val) + except ValidationError as err: + for field_name in err.messages: + self.errors[field_name].extend(err.messages[field_name]) + finally: + loaded_fields.append(field_name) + + # Also note reference field name if its attribute was loaded + if field_name in reference_attributes: + loaded_fields.append(reference_attributes[field_name]) + + ###################### + # Load value objects # + ###################### # Load Value Objects from associated fields # This block will dynamically construct value objects from field values # and associated the vo with the entity @@ -279,7 +274,9 @@ def __init__(self, *template, **kwargs): # noqa: C901 (embedded_field.field_name, embedded_field.attribute_name) for embedded_field in field_obj.embedded_fields.values() ] - kwargs_values = {name: kwargs.get(attr) for name, attr in attrs} + kwargs_values = { + name: supplied_values.get(attr) for name, attr in attrs + } # Check if any of the values in `values` are not None # If all values are None, it means that the value object is not being set @@ -290,17 +287,17 @@ def __init__(self, *template, **kwargs): # noqa: C901 if any(kwargs_values.values()): try: value_object = field_obj.value_object_cls(**kwargs_values) - - # Set VO value only if the value object is not None/Empty - if value_object: - setattr(self, field_name, value_object) - loaded_fields.append(field_name) + setattr(self, field_name, value_object) + loaded_fields.append(field_name) except ValidationError as err: for sub_field_name in err.messages: self.errors[ "{}_{}".format(field_name, sub_field_name) ].extend(err.messages[sub_field_name]) + ############################# + # Generate other identities # + ############################# # Load other identities for field_name, field_obj in declared_fields(self).items(): if ( @@ -308,19 +305,20 @@ def __init__(self, *template, **kwargs): # noqa: C901 and type(field_obj) is Auto and not field_obj.increment ): - if not getattr(self, field_obj.field_name, None): - setattr( - self, - field_obj.field_name, - generate_identity( - field_obj.identity_strategy, - field_obj.identity_function, - field_obj.identity_type, - ), - ) + setattr( + self, + field_obj.field_name, + generate_identity( + field_obj.identity_strategy, + field_obj.identity_function, + field_obj.identity_type, + ), + ) loaded_fields.append(field_obj.field_name) - # Load Associations + ##################### + # Load Associations # + ##################### for field_name, field_obj in declared_fields(self).items(): if isinstance(field_obj, Association): getattr(self, field_name) # This refreshes the values in associations @@ -343,6 +341,9 @@ def __init__(self, *template, **kwargs): # noqa: C901 self.defaults() + ################################# + # Mark remaining fields as None # + ################################# # Now load the remaining fields with a None value, which will fail # for required fields for field_name, field_obj in fields(self).items(): @@ -529,7 +530,7 @@ def _update_data(self, *data_dict, **kwargs): for data in data_dict: if not isinstance(data, dict): raise AssertionError( - f'Positional argument "{data}" passed must be a dict.' + f"Positional argument {data} passed must be a dict. " f"This argument serves as a template for loading common " f"values.", ) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 1a4f4d64..25b0a4ec 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -15,6 +15,7 @@ from protean.adapters import Brokers, Caches, EmailProviders, Providers from protean.adapters.event_store import EventStore +from protean.container import Element from protean.core.aggregate import element_to_fact_event from protean.core.command import BaseCommand from protean.core.command_handler import BaseCommandHandler @@ -303,17 +304,13 @@ def _initialize(self): self.brokers._initialize() self.event_store._initialize() - def make_config(self): - """Used to construct the config; invoked by the Domain constructor.""" + def load_config(self, load_toml=True): + """Load configuration from dist or a .toml file.""" defaults = dict(self.default_config) defaults["env"] = get_env() defaults["debug"] = get_debug_flag() - return self.config_class(self.root_path, defaults) - - def load_config(self, load_toml=True): - """Load configuration from dist or a .toml file.""" if load_toml: - config = Config2.load_from_path(self.root_path, dict(self.default_config)) + config = Config2.load_from_path(self.root_path, defaults) else: config = Config2.load_from_dict(dict(self.default_config)) @@ -498,40 +495,39 @@ def _resolve_references(self): """ for name in list(self._pending_class_resolutions.keys()): for resolution_type, params in self._pending_class_resolutions[name]: - if resolution_type == "Association": - field_obj, owner_cls = params - to_cls = self.fetch_element_cls_from_registry( - field_obj.to_cls, - ( - DomainObjects.AGGREGATE, - DomainObjects.EVENT_SOURCED_AGGREGATE, - DomainObjects.ENTITY, - ), - ) - field_obj._resolve_to_cls(self, to_cls, owner_cls) - elif resolution_type == "ValueObject": - field_obj, owner_cls = params - to_cls = self.fetch_element_cls_from_registry( - field_obj.value_object_cls, - (DomainObjects.VALUE_OBJECT,), - ) - field_obj._resolve_to_cls(self, to_cls, owner_cls) - elif resolution_type == "AggregateCls": - cls = params - to_cls = self.fetch_element_cls_from_registry( - cls.meta_.part_of, - ( - DomainObjects.AGGREGATE, - DomainObjects.EVENT_SOURCED_AGGREGATE, - ), - ) - cls.meta_.part_of = to_cls - - # Also set the stream name if there is a `stream_name` option in `meta_` - # FIXME Could this task be pushed to the element itself, so each element - # can do stuff beyond `stream_name`? - if hasattr(cls.meta_, "stream_name") and not cls.meta_.stream_name: - cls.meta_.stream_name = to_cls.meta_.stream_name + match resolution_type: + case "Association": + field_obj, owner_cls = params + to_cls = self.fetch_element_cls_from_registry( + field_obj.to_cls, + ( + DomainObjects.AGGREGATE, + DomainObjects.EVENT_SOURCED_AGGREGATE, + DomainObjects.ENTITY, + ), + ) + field_obj._resolve_to_cls(self, to_cls, owner_cls) + case "ValueObject": + field_obj, owner_cls = params + to_cls = self.fetch_element_cls_from_registry( + field_obj.value_object_cls, + (DomainObjects.VALUE_OBJECT,), + ) + field_obj._resolve_to_cls(self, to_cls, owner_cls) + case "AggregateCls": + cls = params + to_cls = self.fetch_element_cls_from_registry( + cls.meta_.part_of, + ( + DomainObjects.AGGREGATE, + DomainObjects.EVENT_SOURCED_AGGREGATE, + ), + ) + cls.meta_.part_of = to_cls + case _: + raise NotSupportedError( + f"Resolution Type {resolution_type} not supported" + ) # Remove from pending list now that the class has been resolved del self._pending_class_resolutions[name] @@ -579,40 +575,23 @@ def register(self, element_cls: Any, **kwargs: dict) -> Any: return self._register_element(element_cls.element_type, element_cls, **kwargs) - def delist(self, element_cls): - """Delist a Domain Element. - - This method will result in a no-op if the entity class was not found - in the registry for whatever reason. - """ - if getattr(element_cls, "element_type", None) not in [ - element for element in DomainObjects - ]: - raise NotImplementedError - - self._domain_registry.dei_element(element_cls.element_type, element_cls) - def fetch_element_cls_from_registry( - self, element: Union[str, Any], element_types: Tuple[DomainObjects, ...] - ) -> Any: + self, element: str, element_types: Tuple[DomainObjects, ...] + ) -> Element: """Util Method to fetch an Element's class from its name""" - if isinstance(element, str): + try: + # Try fetching by class name + return self._get_element_by_name(element_types, element).cls + except ConfigurationError: try: - # Try fetching by class name - return self._get_element_by_name(element_types, element).cls + # Try fetching by fully qualified class name + return self._get_element_by_fully_qualified_name( + element_types, element + ).cls except ConfigurationError: - try: - # Try fetching by fully qualified class name - return self._get_element_by_fully_qualified_name( - element_types, element - ).cls - except ConfigurationError: - # Element has not been registered - # FIXME print a helpful debug message - raise - else: - # FIXME Check if entity is subclassed from BaseEntity - return element + # Element has not been registered + # FIXME print a helpful debug message + raise def _get_element_by_name(self, element_types, element_name): """Fetch Domain record with the provided Element name""" diff --git a/src/protean/fields/basic.py b/src/protean/fields/basic.py index d50dcb99..1eae0104 100644 --- a/src/protean/fields/basic.py +++ b/src/protean/fields/basic.py @@ -398,23 +398,26 @@ def _cast_to_type(self, value): self.identity_type = current_domain.config["identity_type"] # Ensure that the value is of the right type - if self.identity_type == IdentityType.UUID.value: - if not isinstance(value, UUID): - try: - value = UUID(value) - except (ValueError, AttributeError): - self.fail("invalid", value=value) - elif self.identity_type == IdentityType.INTEGER.value: - if not isinstance(value, int): - try: - value = int(value) - except ValueError: - self.fail("invalid", value=value) - elif self.identity_type == IdentityType.STRING.value: - if not isinstance(value, str): - value = str(value) - else: - raise ValidationError({"identity_type": ["Identity type not supported"]}) + match self.identity_type: + case IdentityType.UUID.value: + if not isinstance(value, UUID): + try: + value = UUID(value) + except (ValueError, AttributeError): + self.fail("invalid", value=value) + case IdentityType.INTEGER.value: + if not isinstance(value, int): + try: + value = int(value) + except ValueError: + self.fail("invalid", value=value) + case IdentityType.STRING.value: + if not isinstance(value, str): + value = str(value) + case _: + raise ValidationError( + {"identity_type": ["Identity type not supported"]} + ) return value diff --git a/src/protean/utils/__init__.py b/src/protean/utils/__init__.py index ec58710b..b4b92be0 100644 --- a/src/protean/utils/__init__.py +++ b/src/protean/utils/__init__.py @@ -212,7 +212,6 @@ def generate_identity( "TypeMatcher", "convert_str_values_to_list", "derive_element_class", - "fetch_element_cls_from_registry", "fully_qualified_name", "generate_identity", "get_version", diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 32b0b823..aaa2968f 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -11,7 +11,7 @@ from protean.core.command import BaseCommand from protean.core.event import BaseEvent, Metadata from protean.core.unit_of_work import UnitOfWork -from protean.exceptions import ConfigurationError +from protean.exceptions import ConfigurationError, InvalidDataError from protean.globals import current_domain from protean.utils import fully_qualified_name @@ -86,21 +86,17 @@ def to_object(self) -> Union[BaseEvent, BaseCommand]: elif self.metadata.kind == MessageType.COMMAND.value: element_record = current_domain.registry.commands[self.type] else: - raise NotImplementedError # FIXME Handle unknown messages better - - if not element_record: - raise ConfigurationError( - f"Element {self.type.split('.')[-1]} is not registered with the domain" + raise InvalidDataError( + {"_message": ["Message type is not supported for deserialization"]} ) return element_record.cls(**self.data) @classmethod def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: - if not message_object.meta_.part_of.meta_.stream_name: + if not message_object.meta_.part_of: raise ConfigurationError( - f"No stream name found for `{message_object.__class__.__name__}`. " - "Either specify an explicit stream name or associate the event with an aggregate." + f"`{message_object.__class__.__name__}` is not associated with an aggregate." ) # Set the expected version of the stream diff --git a/tests/adapters/model/elasticsearch_model/tests.py b/tests/adapters/model/elasticsearch_model/tests.py index 44723d61..ca5df047 100644 --- a/tests/adapters/model/elasticsearch_model/tests.py +++ b/tests/adapters/model/elasticsearch_model/tests.py @@ -229,7 +229,7 @@ def test_that_model_class_is_created_automatically(self, test_domain): assert issubclass(model_cls, ElasticsearchModel) assert model_cls.__name__ == "ComplexUserModel" - def test_conversation_from_entity_to_model(self, test_domain): + def test_conversion_from_entity_to_model(self, test_domain): model_cls = test_domain.repository_for(ComplexUser)._model user1 = ComplexUser(email_address="john.doe@gmail.com", password="d4e5r6") @@ -255,7 +255,7 @@ def test_conversation_from_entity_to_model(self, test_domain): assert hasattr(user1_model_obj, "email") is False assert hasattr(user2_model_obj, "email") is False - def test_conversation_from_model_to_entity(self, test_domain): + def test_conversion_from_model_to_entity(self, test_domain): model_cls = test_domain.repository_for(ComplexUser)._model user1 = ComplexUser(email_address="john.doe@gmail.com", password="d4e5r6") user1_model_obj = model_cls.from_entity(user1) diff --git a/tests/aggregate/events/test_event_association_with_aggregate.py b/tests/aggregate/events/test_event_association_with_aggregate.py new file mode 100644 index 00000000..f80250a7 --- /dev/null +++ b/tests/aggregate/events/test_event_association_with_aggregate.py @@ -0,0 +1,61 @@ +import pytest + +from protean import BaseEvent, BaseEventSourcedAggregate +from protean.exceptions import ConfigurationError +from protean.fields import Identifier, String + + +class UserRegistered(BaseEvent): + user_id = Identifier(required=True) + name = String(max_length=50, required=True) + email = String(required=True) + + +class UserActivated(BaseEvent): + user_id = Identifier(required=True) + + +class UserRenamed(BaseEvent): + user_id = Identifier(required=True) + name = String(required=True, max_length=50) + + +class UserArchived(BaseEvent): + user_id = Identifier(required=True) + + +class User(BaseEventSourcedAggregate): + user_id = Identifier(identifier=True) + name = String(max_length=50, required=True) + email = String(required=True) + status = String(choices=["ACTIVE", "INACTIVE", "ARCHIVED"]) + + @classmethod + def register(cls, user_id, name, email): + user = cls(user_id=user_id, name=name, email=email) + user.raise_(UserRegistered(user_id=user_id, name=name, email=email)) + return user + + def activate(self): + self.raise_(UserActivated(user_id=self.user_id)) + + def change_name(self, name): + self.raise_(UserRenamed(user_id=self.user_id, name=name)) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User) + test_domain.register(UserRegistered, part_of=User) + test_domain.register(UserActivated, part_of=User) + test_domain.register(UserRenamed, part_of=User) + + +def test_an_unassociated_event_throws_error(test_domain): + user = User.register(user_id="1", name="", email="") + with pytest.raises(ConfigurationError) as exc: + user.raise_(UserArchived(user_id=user.user_id)) + + assert exc.value.args[0] == ( + "Event `UserArchived` is not associated with aggregate `User`" + ) diff --git a/tests/aggregate/test_aggregate_initialization.py b/tests/aggregate/test_aggregate_initialization.py index 82b68382..5d1a68c9 100644 --- a/tests/aggregate/test_aggregate_initialization.py +++ b/tests/aggregate/test_aggregate_initialization.py @@ -137,6 +137,15 @@ def test_initialization_from_dict_template(self): assert person.last_name == "Doe" assert person.age == 23 + def test_template_param_is_a_dict(self): + with pytest.raises(AssertionError) as exc: + Person(["John", "Doe", 23]) + + assert str(exc.value) == ( + "Positional argument ['John', 'Doe', 23] passed must be a dict. " + "This argument serves as a template for loading common values." + ) + def test_error_message_content_on_validation_error(self): # Single error message try: diff --git a/tests/command_handler/test_basics.py b/tests/command_handler/test_basics.py index 77a56d2b..73eeb4c1 100644 --- a/tests/command_handler/test_basics.py +++ b/tests/command_handler/test_basics.py @@ -1,9 +1,88 @@ import pytest -from protean import BaseCommandHandler -from protean.exceptions import NotSupportedError +from protean import BaseAggregate, BaseCommand, BaseCommandHandler, BaseEvent, handle +from protean.exceptions import IncorrectUsageError, NotSupportedError +from protean.fields import Identifier, String + + +class User(BaseAggregate): + email = String() + name = String() + + +class Register(BaseCommand): + id = Identifier() + email = String() + name = String() + + +class Registered(BaseEvent): + id = Identifier() + email = String() + name = String() def test_that_base_command_handler_cannot_be_instantianted(): with pytest.raises(NotSupportedError): BaseCommandHandler() + + +def test_only_commands_can_be_associated_with_command_handlers(test_domain): + class UserCommandHandlers(BaseCommandHandler): + @handle(Registered) + def something(self, _: Registered): + pass + + test_domain.register(User) + with pytest.raises(IncorrectUsageError) as exc: + test_domain.register(UserCommandHandlers, part_of=User) + + assert exc.value.messages == { + "_command_handler": [ + "Method `something` in Command Handler `UserCommandHandlers` is not associated with a command" + ] + } + + +def test_commands_have_to_be_registered_with_an_aggregate(test_domain): + class UserCommandHandlers(BaseCommandHandler): + @handle(Register) + def something(self, _: Register): + pass + + test_domain.register(User) + with pytest.raises(IncorrectUsageError) as exc: + test_domain.register(UserCommandHandlers, part_of=User) + + assert exc.value.messages == { + "_command_handler": [ + "Command `Register` in Command Handler `UserCommandHandlers` is not associated with an aggregate" + ] + } + + +def test_command_and_command_handler_have_to_be_associated_with_same_aggregate( + test_domain, +): + class UserCommandHandlers(BaseCommandHandler): + @handle(Register) + def something(self, _: Register): + pass + + class User2(BaseAggregate): + email = String() + name = String() + + test_domain.register(User) + test_domain.register(User2) + test_domain.register(Register, part_of=User) + with pytest.raises(IncorrectUsageError) as exc: + test_domain.register(UserCommandHandlers, part_of=User2) + + assert exc.value.messages == { + "_command_handler": [ + "Command `Register` in Command Handler `UserCommandHandlers` is not associated with the same aggregate as the Command Handler" + ] + } + + test_domain.register(UserCommandHandlers, part_of=User) diff --git a/tests/command_handler/test_retrieving_handlers_by_command.py b/tests/command_handler/test_retrieving_handlers_by_command.py index 738c0478..3e4962f2 100644 --- a/tests/command_handler/test_retrieving_handlers_by_command.py +++ b/tests/command_handler/test_retrieving_handlers_by_command.py @@ -72,6 +72,12 @@ def test_retrieving_handler_by_command(test_domain): def test_for_no_errors_when_no_handler_method_has_not_been_defined_for_a_command( test_domain, ): + test_domain.register(User) + test_domain.register(Register, part_of=User) + test_domain.register(ChangeAddress, part_of=User) + test_domain.register(UserCommandHandlers, part_of=User) + test_domain.init(traverse=False) + assert test_domain.command_handler_for(ChangeAddress) is None diff --git a/tests/domain/tests.py b/tests/domain/tests.py index b77210e1..d3121f42 100644 --- a/tests/domain/tests.py +++ b/tests/domain/tests.py @@ -13,6 +13,12 @@ from .elements import UserAggregate, UserEntity, UserFoo, UserVO +def test_domain_name_string(): + domain = Domain(__file__, "Foo", load_toml=False) + + assert str(domain) == "Domain: Foo" + + class TestElementRegistration: def test_that_only_recognized_element_types_can_be_registered(self, test_domain): with pytest.raises(NotSupportedError) as exc: diff --git a/tests/entity/test_fields_cache.py b/tests/entity/test_fields_cache.py new file mode 100644 index 00000000..65c3ddee --- /dev/null +++ b/tests/entity/test_fields_cache.py @@ -0,0 +1,42 @@ +from protean.core.entity import _FieldsCacheDescriptor + + +# A dummy class to test the descriptor +class TestClass: + fields_cache = _FieldsCacheDescriptor() + + +def test_get_with_none_instance(): + descriptor = _FieldsCacheDescriptor() + result = descriptor.__get__(None) + assert result is descriptor + + +def test_get_with_instance(): + instance = TestClass() + descriptor = TestClass.fields_cache + result = descriptor.__get__(instance) + assert isinstance(result, dict) + assert result == {} + assert instance.fields_cache == result + + +def test_fields_cache_initialized_correctly(): + instance = TestClass() + assert hasattr(instance, "fields_cache") + assert isinstance(instance.fields_cache, dict) + assert instance.fields_cache == {} + + +def test_multiple_instances(): + instance1 = TestClass() + instance2 = TestClass() + + assert instance1.fields_cache is not instance2.fields_cache + + +def test_cache_persistence(): + instance = TestClass() + initial_cache = instance.fields_cache + initial_cache["key"] = "value" + assert instance.fields_cache["key"] == "value" diff --git a/tests/event_handler/test_event_handler_options.py b/tests/event_handler/test_event_handler_options.py index c1ac6a7d..e001c36d 100644 --- a/tests/event_handler/test_event_handler_options.py +++ b/tests/event_handler/test_event_handler_options.py @@ -1,7 +1,7 @@ import pytest from protean import BaseAggregate, BaseEvent, BaseEventHandler -from protean.exceptions import IncorrectUsageError +from protean.exceptions import IncorrectUsageError, NotSupportedError from protean.fields import Identifier, String @@ -15,6 +15,11 @@ class Registered(BaseEvent): email = String() +def test_that_base_command_handler_cannot_be_instantianted(): + with pytest.raises(NotSupportedError): + BaseEventHandler() + + def test_part_of_specified_during_registration(test_domain): class UserEventHandlers(BaseEventHandler): pass diff --git a/tests/field/test_field_validators.py b/tests/field/test_field_validators.py index 3a41ad02..1ca31acf 100644 --- a/tests/field/test_field_validators.py +++ b/tests/field/test_field_validators.py @@ -18,9 +18,18 @@ (MinLengthValidator(5), "abcde", None), (MinLengthValidator(5), "abcdef", None), (MinLengthValidator(5), "abcd", ValidationError), + (MinLengthValidator(0), "", None), # Minimum length of 0 with empty string + ( + MinLengthValidator(1), + "", + ValidationError, + ), # Minimum length of 1 with empty string (MaxLengthValidator(10), "abcde", None), (MaxLengthValidator(10), "abcdefghij", None), (MaxLengthValidator(10), "abcdefghijkl", ValidationError), + (MaxLengthValidator(0), "", None), # Maximum length of 0 with empty string + (MaxLengthValidator(1), "a", None), # Maximum length of 1 with single character + (MaxLengthValidator(1), "ab", ValidationError), # Exceeding maximum length (MinValueValidator(100), 100, None), (MinValueValidator(100), 101, None), (MinValueValidator(100), 99, ValidationError), @@ -28,6 +37,13 @@ (MaxValueValidator(100), 100, None), (MaxValueValidator(100), 101, ValidationError), (MaxValueValidator(100), 99, None), + (MinValueValidator(-10), -10, None), # Minimum value with negative number + (MaxValueValidator(0), 0, None), # Maximum value with zero + ( + MaxValueValidator(0), + 1, + ValidationError, + ), # Exceeding maximum value with positive number (RegexValidator(), "", None), (RegexValidator(), "x1x2", None), (RegexValidator("[0-9]+"), "xxxxxx", ValidationError), @@ -45,6 +61,40 @@ (RegexValidator("x", flags=re.IGNORECASE), "y", ValidationError), (RegexValidator("a"), "A", ValidationError), (RegexValidator("a", flags=re.IGNORECASE), "A", None), + (RegexValidator("[a-z]+"), "abc", None), # Matching regex with lowercase letters + ( + RegexValidator("[a-z]+"), + "ABC", + ValidationError, + ), # Not matching regex with uppercase letters + ( + RegexValidator("[a-z]+", flags=re.IGNORECASE), + "ABC", + None, + ), # Matching regex with ignore case flag + ( + RegexValidator("[a-z]+", inverse_match=True), + "123", + None, + ), # Inverse match with non-matching string + ( + RegexValidator("[a-z]+", inverse_match=True), + "abc", + ValidationError, + ), # Inverse match with matching string + (RegexValidator(r"\d+"), "123abc", None), # Regex with digits in a mixed string + ( + RegexValidator(r"\d+", inverse_match=True), + "123abc", + ValidationError, + ), # Inverse match with digits in a mixed string + (RegexValidator("[0-9]+", message="Digits only!"), "abcd", ValidationError), + (RegexValidator("[0-9]+", message="Digits only!"), "abcd", ValidationError), + ( + RegexValidator("[0-9]+", message="Digits only!", code="invalid_digits"), + "abcd", + ValidationError, + ), ] @@ -70,3 +120,13 @@ def test_validators(self): validator(value) else: assert validator(value) is None + + def test_regex_type_error_exception(self): + with pytest.raises(TypeError) as exc: + # Test case for TypeError when flags are set and regex is not a string + RegexValidator(re.compile("[0-9]+"), flags=re.IGNORECASE) + + assert ( + exc.value.args[0] + == "If flags are set, regex must be a regular expression string." + ) diff --git a/tests/field/test_identifier.py b/tests/field/test_identifier.py index 697396c2..5de997a1 100644 --- a/tests/field/test_identifier.py +++ b/tests/field/test_identifier.py @@ -69,9 +69,11 @@ def test_with_identity_type_as_string(self): assert identifier.as_dict(value) == "42" def test_with_identity_type_as_invalid(self): - with pytest.raises(ValidationError): + with pytest.raises(ValidationError) as exc: Identifier(identity_type="invalid") + assert exc.value.messages == {"identity_type": ["Identity type not supported"]} + def test_with_invalid_value_for_uuid_identity_type(self): identifier = Identifier(identity_type=IdentityType.UUID.value) with pytest.raises(ValidationError): @@ -106,6 +108,19 @@ def test_int_and_uuid_values_for_string_identity_type(self): # With STRING, an INTEGER will be converted to a string identifier._load(42) == "42" + def test_invalid_identity_type_in_domain_config(self): + domain = Domain(__file__, load_toml=False) + domain.config["identity_type"] = "invalid" + + with domain.domain_context(): + identifier = Identifier() + with pytest.raises(ValidationError) as exc: + identifier._load(42) + + assert exc.value.messages == { + "identity_type": ["Identity type not supported"] + } + def test_that_default_is_picked_from_domain_config(self): domain = Domain(__file__, load_toml=False) @@ -136,7 +151,3 @@ def test_that_default_is_picked_from_domain_config(self): assert identifier._load(uuid_val) == uuid_val assert identifier.identity_type == IdentityType.UUID.value assert identifier.as_dict(uuid_val) == str(uuid_val) - - def test_invalid_identity_type(self): - with pytest.raises(ValidationError): - Identifier(identity_type="invalid") diff --git a/tests/message/test_message_to_object.py b/tests/message/test_message_to_object.py index 72f4e281..ae0eb16d 100644 --- a/tests/message/test_message_to_object.py +++ b/tests/message/test_message_to_object.py @@ -3,6 +3,8 @@ import pytest from protean import BaseCommand, BaseEvent, BaseEventSourcedAggregate +from protean.core.event import Metadata +from protean.exceptions import InvalidDataError from protean.fields import Identifier, String from protean.utils.mixins import Message @@ -18,6 +20,10 @@ class Register(BaseCommand): name = String() +class Activate(BaseCommand): + id = Identifier() + + class Registered(BaseEvent): id = Identifier() email = String() @@ -64,3 +70,14 @@ def test_construct_command_from_message(): reconstructed_command = message.to_object() assert isinstance(reconstructed_command, Register) assert reconstructed_command.id == identifier + + +def test_invalid_message_throws_exception(): + message = Message(metadata=Metadata(kind="INVALID")) + + with pytest.raises(InvalidDataError) as exc: + message.to_object() + + assert exc.value.messages == { + "_message": ["Message type is not supported for deserialization"] + } diff --git a/tests/message/test_object_to_message.py b/tests/message/test_object_to_message.py index 31ca12d8..c208a464 100644 --- a/tests/message/test_object_to_message.py +++ b/tests/message/test_object_to_message.py @@ -3,6 +3,7 @@ import pytest 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 @@ -19,6 +20,10 @@ class Register(BaseCommand): name = String() +class Activate(BaseCommand): + id = Identifier() + + class Registered(BaseEvent): id = Identifier(identifier=True) email = String() @@ -163,3 +168,12 @@ def test_construct_message_from_either_event_or_command(test_domain): assert message.metadata.kind == "EVENT" assert message.data == event.to_dict() assert message.time is None + + +def test_object_is_registered_with_domain(): + command = Activate(id=str(uuid4())) + + with pytest.raises(ConfigurationError) as exc: + Message.to_message(command) + + assert exc.value.args[0] == "`Activate` is not associated with an aggregate."