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