diff --git a/src/protean/core/aggregate.py b/src/protean/core/aggregate.py index 677583eb..7425860c 100644 --- a/src/protean/core/aggregate.py +++ b/src/protean/core/aggregate.py @@ -58,13 +58,14 @@ def __init__(self, *args, **kwargs): @classmethod def _default_options(cls): return [ - ("auto_add_id_field", True), - ("provider", "default"), + ("abstract", False), ("aggregate_cluster", None), + ("auto_add_id_field", True), + ("fact_events", False), ("model", None), - ("stream_name", inflection.underscore(cls.__name__)), + ("provider", "default"), ("schema_name", inflection.underscore(cls.__name__)), - ("fact_events", False), + ("stream_name", inflection.underscore(cls.__name__)), ] diff --git a/src/protean/core/command.py b/src/protean/core/command.py index 1beb8263..e846e566 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -52,9 +52,9 @@ def __setattr__(self, name, value): def _default_options(cls): return [ ("abstract", False), + ("aggregate_cluster", None), ("part_of", None), ("stream_name", None), - ("aggregate_cluster", None), ] @classmethod diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index c9b4cfa2..7f4a86af 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -119,10 +119,11 @@ def __init_subclass__(subclass) -> None: @classmethod def _default_options(cls): return [ + ("aggregate_cluster", None), ("auto_add_id_field", True), ("model", None), ("part_of", None), - ("aggregate_cluster", None), + ("provider", "default"), ("schema_name", inflection.underscore(cls.__name__)), ] diff --git a/src/protean/core/event.py b/src/protean/core/event.py index 83501c29..87facd5a 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -87,9 +87,9 @@ def _default_options(cls): return [ ("abstract", False), + ("aggregate_cluster", None), ("part_of", None), ("stream_name", part_of.meta_.stream_name if part_of else None), - ("aggregate_cluster", None), ] @classmethod diff --git a/src/protean/core/event_handler.py b/src/protean/core/event_handler.py index f4366a45..c85e0b18 100644 --- a/src/protean/core/event_handler.py +++ b/src/protean/core/event_handler.py @@ -27,8 +27,8 @@ def _default_options(cls): return [ ("part_of", None), - ("stream_name", part_of.meta_.stream_name if part_of else None), ("source_stream", None), + ("stream_name", part_of.meta_.stream_name if part_of else None), ] diff --git a/src/protean/core/event_sourced_aggregate.py b/src/protean/core/event_sourced_aggregate.py index 762c7a6f..7dca00a6 100644 --- a/src/protean/core/event_sourced_aggregate.py +++ b/src/protean/core/event_sourced_aggregate.py @@ -41,9 +41,10 @@ def __new__(cls, *args, **kwargs): @classmethod def _default_options(cls): return [ + ("aggregate_cluster", None), ("auto_add_id_field", True), + ("fact_events", False), ("stream_name", inflection.underscore(cls.__name__)), - ("aggregate_cluster", None), ] def __init_subclass__(subclass) -> None: diff --git a/src/protean/core/event_sourced_repository.py b/src/protean/core/event_sourced_repository.py index 5a5263cc..e64aff45 100644 --- a/src/protean/core/event_sourced_repository.py +++ b/src/protean/core/event_sourced_repository.py @@ -36,21 +36,32 @@ def add(self, aggregate: BaseEventSourcedAggregate) -> None: {"_entity": ["Aggregate object to persist is invalid"]} ) - # `add` is typically invoked in handler methods in Command Handlers and Event Handlers, which are - # enclosed in a UoW automatically. Therefore, if there is a UoW in progress, we can assume - # that it is the active session. If not, we will start a new UoW and commit it after the operation - # is complete. - own_current_uow = None - if not (current_uow and current_uow.in_progress): - own_current_uow = UnitOfWork() - own_current_uow.start() - - uow = current_uow or own_current_uow - uow._add_to_identity_map(aggregate) - - # If we started a UnitOfWork, commit it now - if own_current_uow: - own_current_uow.commit() + # Proceed only if aggregate has events + if len(aggregate._events) > 0: + # `add` is typically invoked in handler methods in Command Handlers and Event Handlers, which are + # enclosed in a UoW automatically. Therefore, if there is a UoW in progress, we can assume + # that it is the active session. If not, we will start a new UoW and commit it after the operation + # is complete. + own_current_uow = None + if not (current_uow and current_uow.in_progress): + own_current_uow = UnitOfWork() + own_current_uow.start() + + uow = current_uow or own_current_uow + + # If Aggregate has signed up Fact Events, raise them now + if aggregate.meta_.fact_events: + payload = aggregate.to_dict() + + # Construct and raise the Fact Event + fact_event = aggregate._fact_event_cls(**payload) + aggregate.raise_(fact_event) + + uow._add_to_identity_map(aggregate) + + # If we started a UnitOfWork, commit it now + if own_current_uow: + own_current_uow.commit() def get(self, identifier: Identifier) -> BaseEventSourcedAggregate: """Retrieve a fully-formed Aggregate from a stream of Events. diff --git a/src/protean/core/model.py b/src/protean/core/model.py index 49c61794..3fcbb7f3 100644 --- a/src/protean/core/model.py +++ b/src/protean/core/model.py @@ -20,9 +20,9 @@ def __new__(cls, *args, **kwargs): @classmethod def _default_options(cls): return [ + ("database", None), ("entity_cls", None), ("schema_name", None), - ("database", None), ] @classmethod diff --git a/src/protean/core/repository.py b/src/protean/core/repository.py index a63cb261..53efef87 100644 --- a/src/protean/core/repository.py +++ b/src/protean/core/repository.py @@ -42,7 +42,7 @@ class BaseRepository(Element, OptionsMixin): @classmethod def _default_options(cls): - return [("part_of", None), ("database", "ALL")] + return [("database", "ALL"), ("part_of", None)] def __new__(cls, *args, **kwargs): # Prevent instantiation of `BaseRepository itself` @@ -133,7 +133,7 @@ def add(self, aggregate: BaseAggregate) -> BaseAggregate: # noqa: C901 ): self._dao.save(aggregate) - # If there are Fact Events associated with the Aggregate, raise them now + # If Aggregate has signed up Fact Events, raise them now if aggregate.meta_.fact_events: payload = aggregate.to_dict() diff --git a/src/protean/core/serializer.py b/src/protean/core/serializer.py index 8b54f9bd..b34421f3 100644 --- a/src/protean/core/serializer.py +++ b/src/protean/core/serializer.py @@ -195,6 +195,10 @@ def __new__(cls, *args, **kwargs): raise NotSupportedError("BaseSerializer cannot be instantiated") return super().__new__(cls) + @classmethod + def _default_options(cls): + return [] + def serializer_factory(element_cls, **kwargs): return derive_element_class(element_cls, BaseSerializer, **kwargs) diff --git a/src/protean/core/subscriber.py b/src/protean/core/subscriber.py index 9fc5f775..a7d903b9 100644 --- a/src/protean/core/subscriber.py +++ b/src/protean/core/subscriber.py @@ -26,7 +26,7 @@ def __new__(cls, *args, **kwargs): @classmethod def _default_options(cls): - return [("event", None), ("broker", "default")] + return [("broker", "default"), ("event", None)] @abstractmethod def __call__(self, event: BaseEvent) -> Optional[Any]: diff --git a/src/protean/core/value_object.py b/src/protean/core/value_object.py index 073b32a3..6681ea7b 100644 --- a/src/protean/core/value_object.py +++ b/src/protean/core/value_object.py @@ -21,6 +21,13 @@ def __new__(cls, *args, **kwargs): raise NotSupportedError("BaseValueObject cannot be instantiated") return super().__new__(cls) + @classmethod + def _default_options(cls): + return [ + ("abstract", False), + ("part_of", None), + ] + def __init_subclass__(subclass) -> None: super().__init_subclass__() diff --git a/src/protean/core/view.py b/src/protean/core/view.py index 7257b7a0..0bb4c7e9 100644 --- a/src/protean/core/view.py +++ b/src/protean/core/view.py @@ -24,11 +24,12 @@ def __new__(cls, *args, **kwargs): @classmethod def _default_options(cls): return [ - ("provider", "default"), + ("abstract", False), ("cache", None), ("model", None), - ("schema_name", inflection.underscore(cls.__name__)), ("order_by", ()), + ("provider", "default"), + ("schema_name", inflection.underscore(cls.__name__)), ] def __init_subclass__(subclass) -> None: diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 9abd109a..5423014e 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -21,7 +21,11 @@ from protean.core.model import BaseModel from protean.core.repository import BaseRepository from protean.domain.registry import _DomainRegistry -from protean.exceptions import ConfigurationError, IncorrectUsageError +from protean.exceptions import ( + ConfigurationError, + IncorrectUsageError, + NotSupportedError, +) from protean.fields import HasMany, HasOne, Reference, ValueObject from protean.reflection import declared_fields, has_fields from protean.utils import ( @@ -561,7 +565,9 @@ def register(self, element_cls: Any, **kwargs: dict) -> Any: if getattr(element_cls, "element_type", None) not in [ element for element in DomainObjects ]: - raise NotImplementedError + raise NotSupportedError( + f"Element `{element_cls.__name__}` is not a valid element class" + ) return self._register_element(element_cls.element_type, element_cls, **kwargs) @@ -786,12 +792,18 @@ def _set_aggregate_cluster_options(self): def _generate_fact_event_classes(self): """Generate FactEvent classes for all aggregates with `fact_events` enabled""" - for _, element in self.registry._elements[ - DomainObjects.AGGREGATE.value - ].items(): - if element.cls.meta_.fact_events: - event_cls = element_to_fact_event(element.cls) - self.register(event_cls, part_of=element.cls) + for element_type in [ + DomainObjects.AGGREGATE, + DomainObjects.EVENT_SOURCED_AGGREGATE, + ]: + for _, element in self.registry._elements[element_type.value].items(): + if element.cls.meta_.fact_events: + event_cls = element_to_fact_event(element.cls) + self.register( + event_cls, + part_of=element.cls, + stream_name=element.cls.meta_.stream_name + "-fact", + ) ###################### # Element Decorators # diff --git a/src/protean/domain/registry.py b/src/protean/domain/registry.py index ffbd108f..63dbba0b 100644 --- a/src/protean/domain/registry.py +++ b/src/protean/domain/registry.py @@ -5,6 +5,7 @@ import inflection +from protean.exceptions import NotSupportedError from protean.utils import DomainObjects, fully_qualified_name logger = logging.getLogger(__name__) @@ -78,7 +79,9 @@ def _is_invalid_element_cls(self, element_cls): def register_element(self, element_cls): if self._is_invalid_element_cls(element_cls): - raise NotImplementedError + raise NotSupportedError( + f"Element `{element_cls.__name__}` is not a valid element class" + ) element_name = fully_qualified_name(element_cls) diff --git a/src/protean/utils/__init__.py b/src/protean/utils/__init__.py index 026d3dad..ec58710b 100644 --- a/src/protean/utils/__init__.py +++ b/src/protean/utils/__init__.py @@ -134,6 +134,11 @@ class DomainObjects(Enum): def derive_element_class(element_cls, base_cls, **opts): from protean.container import Options + # Ensure options being passed in are known + known_options = [name for (name, _) in base_cls._default_options()] + if not all(opt in known_options for opt in opts): + raise ConfigurationError(f"Unknown option(s) {set(opts) - set(known_options)}") + if not issubclass(element_cls, base_cls): try: new_dict = element_cls.__dict__.copy() diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 41ae709b..5beaaac9 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -151,8 +151,11 @@ def to_aggregate_event_message( **cls.derived_metadata(MessageType.EVENT.value), # schema_version=event.meta_.version, # FIXME Maintain version for event ), - # Expect the previous version - expected_version=int(event._metadata.sequence_id) - 1, + # If this is a Fact Event, don't set an expected version. + # Otherwise, expect the previous version + expected_version=None + if event.__class__.__name__.endswith("FactEvent") + else int(event._metadata.sequence_id) - 1, ) def to_object(self) -> Union[BaseEvent, BaseCommand]: diff --git a/tests/aggregate/events/test_raising_fact_events.py b/tests/aggregate/events/test_raising_fact_events.py index c32a36c3..15f31fac 100644 --- a/tests/aggregate/events/test_raising_fact_events.py +++ b/tests/aggregate/events/test_raising_fact_events.py @@ -29,7 +29,7 @@ def test_generation_of_first_fact_event_on_persistence(test_domain): test_domain.repository_for(User).add(user) # Read event from event store - event_messages = test_domain.event_store.store.read(f"user-{user.id}") + event_messages = test_domain.event_store.store.read(f"user-fact-{user.id}") assert len(event_messages) == 1 # Deserialize event diff --git a/tests/domain/tests.py b/tests/domain/tests.py index 1feb1902..b77210e1 100644 --- a/tests/domain/tests.py +++ b/tests/domain/tests.py @@ -1,7 +1,11 @@ import pytest from protean import BaseAggregate, BaseEntity, Domain -from protean.exceptions import ConfigurationError, IncorrectUsageError +from protean.exceptions import ( + ConfigurationError, + IncorrectUsageError, + NotSupportedError, +) from protean.fields import DateTime, HasMany, HasOne, Reference, String, Text from protean.reflection import declared_fields from protean.utils import fully_qualified_name @@ -9,11 +13,13 @@ from .elements import UserAggregate, UserEntity, UserFoo, UserVO -class TestDomainRegistration: +class TestElementRegistration: def test_that_only_recognized_element_types_can_be_registered(self, test_domain): - with pytest.raises(NotImplementedError): + with pytest.raises(NotSupportedError) as exc: test_domain.registry.register_element(UserFoo) + assert exc.value.args[0] == "Element `UserFoo` is not a valid element class" + def test_register_aggregate_with_domain(self, test_domain): test_domain.registry.register_element(UserAggregate) @@ -41,9 +47,20 @@ class Foo: class Bar(Foo): foo = String(max_length=50) - with pytest.raises(NotImplementedError): + with pytest.raises(NotSupportedError) as exc: test_domain.register(Bar) + assert exc.value.args[0] == "Element `Bar` is not a valid element class" + + def test_options_are_validated_on_element_registration(self, test_domain): + class Foo(BaseAggregate): + foo = String(max_length=50) + + with pytest.raises(ConfigurationError) as exc: + test_domain.register(Foo, foo="bar") + + assert exc.value.args[0] == "Unknown option(s) {'foo'}" + class TestDomainAnnotations: # Individual test cases for registering domain elements with diff --git a/tests/event_sourced_aggregates/events/test_fact_event_generation.py b/tests/event_sourced_aggregates/events/test_fact_event_generation.py new file mode 100644 index 00000000..32ca7a02 --- /dev/null +++ b/tests/event_sourced_aggregates/events/test_fact_event_generation.py @@ -0,0 +1,103 @@ +import pytest + +from protean import BaseEvent, BaseEventSourcedAggregate, apply +from protean.fields import Identifier, String +from protean.utils.mixins import Message + + +class Registered(BaseEvent): + id = Identifier() + email = String() + name = String() + + +class Renamed(BaseEvent): + id = Identifier() + name = String() + + +class User(BaseEventSourcedAggregate): + email = String() + name = String() + + @apply + def registered(self, event: Registered) -> None: + self.email = event.email + self.name = event.name + + @apply + def renamed(self, event: Renamed) -> None: + self.name = event.name + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(User, fact_events=True) + test_domain.register(Registered, part_of=User) + test_domain.register(Renamed, part_of=User) + test_domain.init(traverse=False) + + +def test_event_sourced_aggregate_can_be_marked_for_fact_event_generation(test_domain): + assert User.meta_.fact_events is True + + +def test_generation_of_first_fact_event_on_persistence(test_domain): + user = User(name="John Doe", email="john.doe@example.com") + user.raise_(Registered(id=user.id, email=user.email, name=user.name)) + + test_domain.repository_for(User).add(user) + + # Read event from event store + event_messages = test_domain.event_store.store.read(f"user-{user.id}") + assert len(event_messages) == 1 + + # Read fact events from event store + fact_event_messages = test_domain.event_store.store.read(f"user-fact-{user.id}") + assert len(fact_event_messages) == 1 + + # Deserialize event + event = Message.to_object(fact_event_messages[0]) + assert event is not None + assert event.__class__.__name__ == "UserFactEvent" + assert event.name == "John Doe" + assert event.email == "john.doe@example.com" + + +def test_generation_of_subsequent_fact_events_after_fetch(test_domain): + # Initialize and save + user = User(name="John Doe", email="john.doe@example.com") + user.raise_(Registered(id=user.id, email=user.email, name=user.name)) + + # Persist the user + test_domain.repository_for(User).add(user) + + # Fetch the aggregate + refreshed_user = test_domain.repository_for(User).get(user.id) + + # Simulate a name update + refreshed_user.name = "Jane Doe" + refreshed_user.raise_(Renamed(id=refreshed_user.id, name="Jane Doe")) + + # Store the updated user + test_domain.repository_for(User).add(refreshed_user) + + # Read event from event store + event_messages = test_domain.event_store.store.read(f"user-{user.id}") + assert len(event_messages) == 2 + + # Read fact events from event store + fact_event_messages = test_domain.event_store.store.read(f"user-fact-{user.id}") + assert len(fact_event_messages) == 2 + + # Deserialize 1st event + event = Message.to_object(fact_event_messages[0]) + assert event is not None + assert event.__class__.__name__ == "UserFactEvent" + assert event.name == "John Doe" + + # Deserialize 2nd event + event = Message.to_object(fact_event_messages[1]) + assert event is not None + assert event.__class__.__name__ == "UserFactEvent" + assert event.name == "Jane Doe" diff --git a/tests/event_sourced_repository/test_add_uow.py b/tests/event_sourced_repository/test_add_uow.py index 48945763..d2902779 100644 --- a/tests/event_sourced_repository/test_add_uow.py +++ b/tests/event_sourced_repository/test_add_uow.py @@ -1,7 +1,7 @@ import mock import pytest -from protean import BaseEventSourcedAggregate +from protean import BaseEvent, BaseEventSourcedAggregate from protean.fields import Identifier, String @@ -11,6 +11,12 @@ class User(BaseEventSourcedAggregate): name = String() +class Registered(BaseEvent): + id = Identifier() + email = String() + name = String() + + @pytest.fixture(autouse=True) def register_elements(test_domain): test_domain.register(User) @@ -27,6 +33,7 @@ def test_that_method_is_enclosed_in_uow(mock_commit, mock_start, test_domain): with test_domain.domain_context(): user = User(id=1, email="john.doe@example.com", name="John Doe") + user.raise_(Registered(id=1, email="john.doe@example.com", name="John Doe")) test_domain.repository_for(User).add(user) mock_parent.assert_has_calls( diff --git a/tests/test_registry.py b/tests/test_registry.py index 409aa402..8ab8c5ba 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -6,6 +6,7 @@ from protean import BaseAggregate, BaseEntity from protean.domain.registry import _DomainRegistry +from protean.exceptions import NotSupportedError from protean.fields import DateTime, Identifier, Integer, String from protean.utils import DomainObjects @@ -78,13 +79,13 @@ class FooBar3: register = _DomainRegistry() - with pytest.raises(NotImplementedError): + with pytest.raises(NotSupportedError): register.register_element(FooBar1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotSupportedError): register.register_element(FooBar2) - with pytest.raises(NotImplementedError): + with pytest.raises(NotSupportedError): register.register_element(FooBar3) diff --git a/tests/value_object/test_vo_in_vo.py b/tests/value_object/test_vo_in_vo.py new file mode 100644 index 00000000..5b8940be --- /dev/null +++ b/tests/value_object/test_vo_in_vo.py @@ -0,0 +1,42 @@ +import pytest + +from protean import BaseValueObject +from protean.fields import String, ValueObject +from protean.reflection import fields + + +class Address(BaseValueObject): + street = String(max_length=50) + city = String(max_length=25) + state = String(max_length=25) + zip_code = String(max_length=10) + + +class Contact(BaseValueObject): + email = String(max_length=255) + phone_number = String(max_length=255) + address = ValueObject(Address) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(Contact) + test_domain.register(Address) + test_domain.init(traverse=False) + + +def test_contact_has_address_vo(): + assert isinstance(fields(Contact)["address"], ValueObject) + assert hasattr(Contact, "address") + + +def test_outer_vo_initialization(): + contact = Contact( + email="", + phone_number="123-456-7890", + address=Address( + street="123 Main Street", city="Anytown", state="CA", zip_code="12345" + ), + ) + + assert contact is not None