diff --git a/docs/guides/domain-behavior/aggregate-mutation.md b/docs/guides/domain-behavior/aggregate-mutation.md new file mode 100644 index 00000000..27710500 --- /dev/null +++ b/docs/guides/domain-behavior/aggregate-mutation.md @@ -0,0 +1 @@ +# Mutating Aggregates \ No newline at end of file diff --git a/docs/guides/domain-definition/domain-services.md b/docs/guides/domain-behavior/domain-services.md similarity index 100% rename from docs/guides/domain-definition/domain-services.md rename to docs/guides/domain-behavior/domain-services.md diff --git a/docs/guides/domain-behavior/index.md b/docs/guides/domain-behavior/index.md new file mode 100644 index 00000000..900defa0 --- /dev/null +++ b/docs/guides/domain-behavior/index.md @@ -0,0 +1 @@ +# Adding Rules and Behavior \ No newline at end of file diff --git a/docs/guides/domain-behavior/invariants.md b/docs/guides/domain-behavior/invariants.md new file mode 100644 index 00000000..a162d50f --- /dev/null +++ b/docs/guides/domain-behavior/invariants.md @@ -0,0 +1 @@ +# Invariants \ No newline at end of file diff --git a/docs/guides/domain-behavior/raising-events.md b/docs/guides/domain-behavior/raising-events.md new file mode 100644 index 00000000..228b28ec --- /dev/null +++ b/docs/guides/domain-behavior/raising-events.md @@ -0,0 +1 @@ +# Raising Events \ No newline at end of file diff --git a/docs/guides/domain-definition/events.md b/docs/guides/domain-definition/events.md index 2d7cfb97..12866be9 100644 --- a/docs/guides/domain-definition/events.md +++ b/docs/guides/domain-definition/events.md @@ -1,2 +1,75 @@ # Events +Most applications have a definite state - they reflect past user input and +interactions in their current state. It is advantageous to model these past +changes as a series of discrete events. Domain events happen to be those +activities that domain experts care about and represent what happened as-is. + +In Protean, an `Event` is an immutable object that represents a significant +occurrence or change in the domain. Events are raised by aggregates to signal +that something noteworthy has happened, allowing other parts of the system to +react - and sync - to these changes in a decoupled manner. + +Events have a few primary functions: + +1. **Events allows different components to communicate with each other.** + + Within a domain or across, events can be used as a mechanism to implement + eventual consistency, in the same bounded context or across. This promotes + loose coupling by decoupling the producer (e.g., an aggregate that raises + an event) from the consumers (e.g., various components that handle the + event). + + Such a design eliminates the need for two-phase commits (global + transactions) across bounded contexts, optimizing performance at the level + of each transaction. + +2. **Events act as API contracts.** + + Events define a clear and consistent structure for data that is shared + between different components of the system. This promotes system-wide + interoperability and integration between components. + +3. **Events help preserve context boundaries.** + + Events propagate information across bounded contexts, thus helping to + sync changes throughout the application domain. This allows each domain + to be modeled in the architecture pattern that is most appropriate for its + use case. + +## Defining Events + +Event names should be descriptive and convey the specific change or occurrence +in the domain clearly, ensuring that the purpose of the event is immediately +understandable. Events are named as past-tense verbs to clearly indicate +that an event has already occurred, such as `OrderPlaced` or `PaymentProcessed`. + +You can define an event with the `Domain.event` decorator: + +```python hl_lines="22 26 29-31 34-37" +{! docs_src/guides/domain-definition/events/001.py !} +``` + +Events are always connected to an Aggregate class, specified with the +`aggregate_cls` param in the decorator. An exception to this rule is when the +Event class has been marked _Abstract_. + +## Event Facts + +- Events should be named in past tense, because we observe domain events _after +the fact_. `StockDepleted` is a better choice than the imperative +`DepleteStock` as an event name. +- An event is associated with an aggregate or a stream, specified with +`aggregate_cls` or `stream` parameters to the decorator, as above. We will +dive deeper into these parameters in the Processing Events section. + +- Events are essentially Data Transfer Objects (DTO)- they can only hold +simple fields. +- Events should only contain information directly relevant to the event. A +receiver that needs more information should be listening to other pertinent +events and add read-only structures to its own state to take decisions later. +A receiver should not query the current state from the sender because the +sender's state could have already mutated. + +## Immutability + diff --git a/docs/guides/domain-definition/index.md b/docs/guides/domain-definition/index.md index bad6001b..8e05200e 100644 --- a/docs/guides/domain-definition/index.md +++ b/docs/guides/domain-definition/index.md @@ -5,6 +5,10 @@ and translating them as closely as possible - terminology, structure, and behavior - in code. Protean supports the tactical patterns outlined by DDD to mirror the domain model in [code model](../../glossary.md#code-model). +In this section, we will talk about the foundational structures that make up +the domain model. In the next, we will explore how to define behavior and +set up invariants (business rules) that bring the Domain model to life. + ## Domain Layer One of the most important building block of a domain model is the Aggregate. diff --git a/docs/guides/domain-definition/value-objects.md b/docs/guides/domain-definition/value-objects.md index 443ab362..a1720ffe 100644 --- a/docs/guides/domain-definition/value-objects.md +++ b/docs/guides/domain-definition/value-objects.md @@ -63,6 +63,19 @@ Value Objects can be embedded into Aggregates and Entities with the {! docs_src/guides/domain-definition/009.py !} ``` +!!!note + You can also specify a Value Object's class name as input to the + `ValueObject` field, which will be resolved when the domain is initialized. + This can help avoid the problem of circular references. + + ```python + @domain.aggregate + class User: + email = ValueObject("Email") + name = String(max_length=30) + timezone = String(max_length=30) + ``` + An email address can be supplied during user object creation, and the value object takes care of its own validations. diff --git a/docs/guides/getting-started/start-here.md b/docs/guides/getting-started/start-here.md new file mode 100644 index 00000000..65e41c18 --- /dev/null +++ b/docs/guides/getting-started/start-here.md @@ -0,0 +1,3 @@ +# Start Here + +Explain the overall structure of guides and the path to follow. \ No newline at end of file diff --git a/docs_src/guides/domain-definition/events/001.py b/docs_src/guides/domain-definition/events/001.py new file mode 100644 index 00000000..f1d431ad --- /dev/null +++ b/docs_src/guides/domain-definition/events/001.py @@ -0,0 +1,37 @@ +from enum import Enum + +from protean import Domain +from protean.fields import String, Identifier + +domain = Domain(__file__) + + +class UserStatus(Enum): + ACTIVE = "ACTIVE" + ARCHIVED = "ARCHIVED" + + +@domain.event(aggregate_cls="User") +class UserActivated: + user_id = Identifier(required=True) + + +@domain.event(aggregate_cls="User") +class UserRenamed: + user_id = Identifier(required=True) + name = String(required=True, max_length=50) + + +@domain.aggregate +class User: + name = String(max_length=50, required=True) + email = String(required=True) + status = String(choices=UserStatus) + + def activate(self) -> None: + self.status = UserStatus.ACTIVE.value + self.raise_(UserActivated(user_id=self.id)) + + def change_name(self, name: str) -> None: + self.name = name + self.raise_(UserRenamed(user_id=self.id, name=name)) diff --git a/mkdocs.yml b/mkdocs.yml index c18cca2d..de8d6fc6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,9 +82,8 @@ nav: # - core-concepts/event-sourcing/projections.md - Guides: - guides/index.md - - Getting Started: - # - guides/getting-started/index.md - - guides/getting-started/installation.md + - guides/getting-started/start-here.md + - guides/getting-started/installation.md # - guides/getting-started/quickstart.md - Compose a Domain: - guides/compose-a-domain/index.md @@ -95,7 +94,7 @@ nav: - guides/compose-a-domain/element-decorators.md - guides/compose-a-domain/object-model.md # - guides/compose-a-domain/configuration.md - - Defining Domain Concepts: + - Defining Concepts: - guides/domain-definition/index.md - Fields: - guides/domain-definition/fields/index.md @@ -106,9 +105,11 @@ nav: - guides/domain-definition/aggregates.md - guides/domain-definition/entities.md - guides/domain-definition/value-objects.md - - guides/domain-definition/domain-services.md - guides/domain-definition/events.md - guides/domain-definition/repositories.md + - Adding Behavior: + - guides/domain-behavior/index.md + - guides/domain-behavior/domain-services.md # - Application Layer: # - guides/app-layer/index.md # - guides/app-layer/application-services.md diff --git a/src/protean/adapters/broker/celery.py b/src/protean/adapters/broker/celery.py index 98167cd5..26b79202 100644 --- a/src/protean/adapters/broker/celery.py +++ b/src/protean/adapters/broker/celery.py @@ -10,7 +10,6 @@ from protean.port.broker import BaseBroker from protean.utils import ( DomainObjects, - fetch_element_cls_from_registry, fully_qualified_name, ) from protean.utils.inflection import underscore @@ -95,7 +94,7 @@ def register(self, initiator_cls, consumer_cls): ) def publish(self, message: Message) -> None: - event_cls = fetch_element_cls_from_registry( + event_cls = self.domain.fetch_element_cls_from_registry( message.type, (DomainObjects.EVENT,) ) for subscriber in self._subscribers[fully_qualified_name(event_cls)]: diff --git a/src/protean/adapters/repository/elasticsearch.py b/src/protean/adapters/repository/elasticsearch.py index 9780a8dd..b1f80bd6 100644 --- a/src/protean/adapters/repository/elasticsearch.py +++ b/src/protean/adapters/repository/elasticsearch.py @@ -519,7 +519,7 @@ def _drop_database_artifacts(self): } for _, element_record in elements.items(): model_cls = self.domain.repository_for(element_record.cls)._model - provider = current_domain.providers[element_record.cls.meta_.provider] + provider = self.domain.providers[element_record.cls.meta_.provider] if provider.conn_info[ "DATABASE" ] == Database.ELASTICSEARCH.value and model_cls._index.exists(using=conn): diff --git a/src/protean/cli/__init__.py b/src/protean/cli/__init__.py index e3412e1a..b358c5a2 100644 --- a/src/protean/cli/__init__.py +++ b/src/protean/cli/__init__.py @@ -136,12 +136,8 @@ def server( # FIXME Accept MAX_WORKERS as command-line input as well try: domain = derive_domain(domain) - except NoDomainException: - logger.error( - "Could not locate a Protean domain. You should provide a domain in" - '"PROTEAN_DOMAIN" environment variable or pass a domain file in options ' - 'and a "domain.py" module was not found in the current directory.' - ) + except NoDomainException as exc: + logger.error(f"Error loading Protean domain: {exc.messages}") raise typer.Abort() from protean.server import Engine diff --git a/src/protean/cli/generate.py b/src/protean/cli/generate.py index bf5b2600..3a8de8b1 100644 --- a/src/protean/cli/generate.py +++ b/src/protean/cli/generate.py @@ -34,11 +34,8 @@ def docker_compose( """Generate a `docker-compose.yml` from Domain config""" try: domain_instance = derive_domain(domain) - except NoDomainException: - logger.error( - "Could not locate a Protean domain. You should provide a domain in" - '"PROTEAN_DOMAIN" environment variable or pass a domain file in options' - ) + except NoDomainException as exc: + logger.error(f"Error loading Protean domain: {exc.messages}") raise typer.Abort() print(f"Generating docker-compose.yml for domain at {domain}") diff --git a/src/protean/cli/shell.py b/src/protean/cli/shell.py index 7c293867..81ab3f5a 100644 --- a/src/protean/cli/shell.py +++ b/src/protean/cli/shell.py @@ -31,19 +31,15 @@ def shell( ): try: domain_instance = derive_domain(domain) - except NoDomainException: - logger.error( - "Could not locate a Protean domain. You should provide a domain in" - '"PROTEAN_DOMAIN" environment variable or pass a domain file in options' - ) + except NoDomainException as exc: + logger.error(f"Error loading Protean domain: {exc.messages}") raise typer.Abort() if traverse: print("Traversing directory to load all modules...") + domain_instance.init(traverse=traverse) with domain_instance.domain_context(): - domain_instance.init(traverse=traverse) - ctx: dict[str, typing.Any] = {} ctx.update(domain_instance.make_shell_context()) diff --git a/src/protean/core/command.py b/src/protean/core/command.py index fcac5a02..600ba043 100644 --- a/src/protean/core/command.py +++ b/src/protean/core/command.py @@ -42,15 +42,15 @@ def __setattr__(self, name, value): else: raise IncorrectUsageError( { - "_value_object": [ - "Value Objects are immutable and cannot be modified once created" + "_command": [ + "Command Objects are immutable and cannot be modified once created" ] } ) @classmethod def _default_options(cls): - return [("aggregate_cls", None), ("stream_name", None)] + return [("abstract", False), ("aggregate_cls", None), ("stream_name", None)] @classmethod def __track_id_field(subclass): @@ -76,4 +76,16 @@ def __track_id_field(subclass): def command_factory(element_cls, **kwargs): element_cls = derive_element_class(element_cls, BaseCommand, **kwargs) + if ( + not (element_cls.meta_.aggregate_cls or element_cls.meta_.stream_name) + and not element_cls.meta_.abstract + ): + raise IncorrectUsageError( + { + "_command": [ + f"Command `{element_cls.__name__}` needs to be associated with an aggregate or a stream" + ] + } + ) + return element_cls diff --git a/src/protean/core/event.py b/src/protean/core/event.py index b8a674a5..23a73ca1 100644 --- a/src/protean/core/event.py +++ b/src/protean/core/event.py @@ -43,15 +43,15 @@ def __setattr__(self, name, value): else: raise IncorrectUsageError( { - "_value_object": [ - "Value Objects are immutable and cannot be modified once created" + "_event": [ + "Event Objects are immutable and cannot be modified once created" ] } ) @classmethod def _default_options(cls): - return [("aggregate_cls", None), ("stream_name", None)] + return [("abstract", False), ("aggregate_cls", None), ("stream_name", None)] @classmethod def __track_id_field(subclass): @@ -75,4 +75,18 @@ def __track_id_field(subclass): def domain_event_factory(element_cls, **kwargs): - return derive_element_class(element_cls, BaseEvent, **kwargs) + element_cls = derive_element_class(element_cls, BaseEvent, **kwargs) + + if ( + not (element_cls.meta_.aggregate_cls or element_cls.meta_.stream_name) + and not element_cls.meta_.abstract + ): + raise IncorrectUsageError( + { + "_event": [ + f"Event `{element_cls.__name__}` needs to be associated with an aggregate or a stream" + ] + } + ) + + return element_cls diff --git a/src/protean/core/event_sourced_aggregate.py b/src/protean/core/event_sourced_aggregate.py index 87dca84c..99faa737 100644 --- a/src/protean/core/event_sourced_aggregate.py +++ b/src/protean/core/event_sourced_aggregate.py @@ -181,25 +181,17 @@ def event_sourced_aggregate_factory(element_cls, **opts): ) # Associate Event with the aggregate class + # + # This can potentially cause a problem because an Event can only be associated + # with one aggregate class, but multiple event handlers can consume it. + # By resetting the event's aggregate class, its previous association is lost. + # We catch this problem during domain validation. + # + # The domain validation should check for the same event class being present + # in `_events_cls_map` of multiple aggregate classes. if inspect.isclass(method._event_cls) and issubclass( method._event_cls, BaseEvent ): - # An Event can only be associated with one aggregate class, but multiple event handlers - # can consume it. - if ( - method._event_cls.meta_.aggregate_cls - and method._event_cls.meta_.aggregate_cls != element_cls - ): - raise IncorrectUsageError( - { - "_entity": [ - f"{method._event_cls.__name__} Event cannot be associated with" - f" {element_cls.__name__} because it is already associated with" - f" {method._event_cls.meta_.aggregate_cls.__name__}" - ] - } - ) - method._event_cls.meta_.aggregate_cls = element_cls return element_cls diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index c1e40317..51417da9 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -8,7 +8,7 @@ from collections import defaultdict from functools import lru_cache -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from werkzeug.datastructures import ImmutableDict @@ -21,19 +21,18 @@ from protean.core.model import BaseModel from protean.domain.registry import _DomainRegistry from protean.exceptions import ConfigurationError, IncorrectUsageError -from protean.fields import HasMany, HasOne, Reference +from protean.fields import HasMany, HasOne, Reference, ValueObject from protean.globals import current_domain from protean.reflection import declared_fields, has_fields from protean.utils import ( CommandProcessing, DomainObjects, EventProcessing, - fetch_element_cls_from_registry, fqn, ) from .config import Config, ConfigAttribute -from .context import DomainContext, _DomainContextGlobals, has_domain_context +from .context import DomainContext, _DomainContextGlobals from .helpers import get_debug_flag, get_env logger = logging.getLogger(__name__) @@ -193,60 +192,66 @@ def init(self, traverse=True): # noqa: C901 This method bubbles up circular import issues, if present, in the domain code. """ - # Initialize domain dependencies and adapters - self._initialize() - if traverse is True: - # Standard Library Imports - import importlib.util - import os - import pathlib - - dir_name = pathlib.PurePath(pathlib.Path(self.root_path).resolve()).parent - path = pathlib.Path(dir_name) # Resolve the domain file's directory - system_folder_path = ( - path.parent - ) # Get the directory of the domain file to traverse from - - logger.debug(f"Loading domain from {dir_name}...") - - for root, _, files in os.walk(dir_name): - if pathlib.PurePath(root).name not in ["__pycache__"]: - package_path = root[len(str(system_folder_path)) + 1 :] - module_name = package_path.replace(os.sep, ".") - - for file in files: - file_base_name = os.path.basename(file) - - # Ignore if the file is not a python file - if os.path.splitext(file_base_name)[1] != ".py": - continue - - # Construct the module path to import from - if file_base_name != "__init__": - sub_module_name = os.path.splitext(file_base_name)[0] - file_module_name = module_name + "." + sub_module_name - else: - file_module_name = module_name - full_file_path = os.path.join(root, file) - - try: - if ( - full_file_path != self.root_path - ): # Don't load the domain file itself again - spec = importlib.util.spec_from_file_location( - file_module_name, full_file_path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + self._traverse() - logger.debug(f"Loaded {file_module_name}") - except ModuleNotFoundError as exc: - logger.error(f"Error while loading a module: {exc}") + # Resolve all pending references + self._resolve_references() + + # Run Validations + self._validate_domain() # Initialize adapters after loading domain self._initialize() + def _traverse(self): + # Standard Library Imports + import importlib.util + import os + import pathlib + + dir_name = pathlib.PurePath(pathlib.Path(self.root_path).resolve()).parent + path = pathlib.Path(dir_name) # Resolve the domain file's directory + system_folder_path = ( + path.parent + ) # Get the directory of the domain file to traverse from + + logger.debug(f"Loading domain from {dir_name}...") + + for root, _, files in os.walk(dir_name): + if pathlib.PurePath(root).name not in ["__pycache__"]: + package_path = root[len(str(system_folder_path)) + 1 :] + module_name = package_path.replace(os.sep, ".") + + for file in files: + file_base_name = os.path.basename(file) + + # Ignore if the file is not a python file + if os.path.splitext(file_base_name)[1] != ".py": + continue + + # Construct the module path to import from + if file_base_name != "__init__": + sub_module_name = os.path.splitext(file_base_name)[0] + file_module_name = module_name + "." + sub_module_name + else: + file_module_name = module_name + full_file_path = os.path.join(root, file) + + try: + if ( + full_file_path != self.root_path + ): # Don't load the domain file itself again + spec = importlib.util.spec_from_file_location( + file_module_name, full_file_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + logger.debug(f"Loaded {file_module_name}") + except ModuleNotFoundError as exc: + logger.error(f"Error while loading a module: {exc}") + def _initialize(self): """Initialize domain dependencies and adapters.""" self.providers._initialize() @@ -388,46 +393,38 @@ def _register_element(self, element_type, element_cls, **kwargs): # noqa: C901 self._domain_registry.register_element(new_cls) # Resolve or record elements to be resolved + + # 1. Associations if has_fields(new_cls): for _, field_obj in declared_fields(new_cls).items(): if isinstance(field_obj, (HasOne, HasMany, Reference)) and isinstance( field_obj.to_cls, str ): - try: - # Attempt to resolve the destination class by querying the active domain - # if a domain is active. Otherwise, track it as part of `_pending_class_resolutions` - # for later resolution. - if has_domain_context() and current_domain == self: - to_cls = fetch_element_cls_from_registry( - field_obj.to_cls, - (DomainObjects.AGGREGATE, DomainObjects.ENTITY), - ) - field_obj._resolve_to_cls(to_cls, new_cls) - else: - self._pending_class_resolutions[field_obj.to_cls].append( - (field_obj, new_cls) - ) - except ConfigurationError: - # Class was not found yet, so we track it for future resolution - self._pending_class_resolutions[field_obj.to_cls].append( - (field_obj, new_cls) - ) + self._pending_class_resolutions[field_obj.to_cls].append( + ("Association", (field_obj, new_cls)) + ) - # Resolve known pending references by full name or class name immediately. - # Otherwise, references will be resolved automatically on domain activation. - # - # This comes handy when we are manually registering classes one after the other. - # Since the domain is already active, the classes become usable as soon as all - # referenced classes are registered. - if has_domain_context() and current_domain == self: - # Check by both the class name as well as the class' fully qualified name - for name in [fqn(new_cls), new_cls.__name__]: - if name in self._pending_class_resolutions: - for field_obj, owner_cls in self._pending_class_resolutions[name]: - field_obj._resolve_to_cls(new_cls, owner_cls) - - # Remove from pending list now that the class has been resolved - del self._pending_class_resolutions[name] + if isinstance(field_obj, ValueObject) and isinstance( + field_obj.value_object_cls, str + ): + self._pending_class_resolutions[field_obj.value_object_cls].append( + ("ValueObject", (field_obj, new_cls)) + ) + + # 2. Meta Linkages + if element_type in [ + DomainObjects.ENTITY, + DomainObjects.EVENT, + DomainObjects.EVENT_HANDLER, + DomainObjects.COMMAND, + DomainObjects.COMMAND_HANDLER, + DomainObjects.REPOSITORY, + DomainObjects.EVENT_SOURCED_REPOSITORY, + ]: + if isinstance(new_cls.meta_.aggregate_cls, str): + self._pending_class_resolutions[new_cls.meta_.aggregate_cls].append( + ("AggregateCls", (new_cls)) + ) return new_cls @@ -437,13 +434,35 @@ def _resolve_references(self): Called by the domain context when domain is activated. """ for name in list(self._pending_class_resolutions.keys()): - for field_obj, owner_cls in self._pending_class_resolutions[name]: - if isinstance(field_obj.to_cls, str): - to_cls = fetch_element_cls_from_registry( + for resolution_type, params in self._pending_class_resolutions[name]: + if resolution_type == "Association": + field_obj, owner_cls = params + to_cls = self.fetch_element_cls_from_registry( field_obj.to_cls, - (DomainObjects.AGGREGATE, DomainObjects.ENTITY), + ( + DomainObjects.AGGREGATE, + DomainObjects.EVENT_SOURCED_AGGREGATE, + DomainObjects.ENTITY, + ), ) - field_obj._resolve_to_cls(to_cls, owner_cls) + field_obj._resolve_to_cls(self, to_cls, owner_cls) + elif resolution_type == "ValueObject": + field_obj, owner_cls = params + to_cls = self.fetch_element_cls_from_registry( + field_obj.value_object_cls, + (DomainObjects.VALUE_OBJECT,), + ) + field_obj._resolve_to_cls(self, to_cls, owner_cls) + elif resolution_type == "AggregateCls": + cls = params + to_cls = self.fetch_element_cls_from_registry( + cls.meta_.aggregate_cls, + ( + DomainObjects.AGGREGATE, + DomainObjects.EVENT_SOURCED_AGGREGATE, + ), + ) + cls.meta_.aggregate_cls = to_cls # Remove from pending list now that the class has been resolved del self._pending_class_resolutions[name] @@ -502,6 +521,28 @@ def delist(self, element_cls): self._domain_registry.dei_element(element_cls.element_type, element_cls) + def fetch_element_cls_from_registry( + self, element: Union[str, Any], element_types: Tuple[DomainObjects, ...] + ) -> Any: + """Util Method to fetch an Element's class from its name""" + if isinstance(element, str): + try: + # Try fetching by class name + return self._get_element_by_name(element_types, element).cls + except ConfigurationError: + try: + # Try fetching by fully qualified class name + return self._get_element_by_fully_qualified_name( + element_types, element + ).cls + except ConfigurationError: + # Element has not been registered + # FIXME print a helpful debug message + raise + else: + # FIXME Check if entity is subclassed from BaseEntity + return element + def _get_element_by_name(self, element_types, element_name): """Fetch Domain record with the provided Element name""" try: @@ -595,6 +636,33 @@ def _validate_domain(self): } ) + # Check that no two event sourced aggregates have the same event class in their + # `_events_cls_map`. + event_sourced_aggregates = self.registry._elements[ + DomainObjects.EVENT_SOURCED_AGGREGATE.value + ] + # Collect all event class names from `_events_cls_map` of all event sourced aggregates + event_class_names = list() + for event_sourced_aggregate in event_sourced_aggregates.values(): + event_class_names.extend(event_sourced_aggregate.cls._events_cls_map.keys()) + # Check for duplicates + duplicate_event_class_names = set( + [ + event_class_name + for event_class_name in event_class_names + if event_class_names.count(event_class_name) > 1 + ] + ) + if len(duplicate_event_class_names) != 0: + raise IncorrectUsageError( + { + "_event": [ + f"Events are associated with multiple event sourced aggregates: " + f"{', '.join(duplicate_event_class_names)}" + ] + } + ) + ###################### # Element Decorators # ###################### diff --git a/src/protean/domain/context.py b/src/protean/domain/context.py index 40786619..389f15f9 100644 --- a/src/protean/domain/context.py +++ b/src/protean/domain/context.py @@ -88,7 +88,7 @@ def __init__(self, domain, **kwargs): self._ref_count = 0 def __repr__(self) -> str: - return f"Domain Context (domain={self.domain.name})" + return f"Domain Context (id={id(self)}, domain={self.domain.name})" def push(self): """Binds the domain context to the current context.""" @@ -97,13 +97,6 @@ def push(self): sys.exc_clear() _domain_context_stack.push(self) - # Resolve all pending references - # This call raises an exception if all references are not resolved - self.domain._resolve_references() - - # Run Validations - self.domain._validate_domain() - def pop(self, exc=_sentinel): """Pops the domain context.""" try: diff --git a/src/protean/fields/association.py b/src/protean/fields/association.py index dbb6a5b5..92cf6a52 100644 --- a/src/protean/fields/association.py +++ b/src/protean/fields/association.py @@ -135,7 +135,7 @@ def linked_attribute(self): else: return self.via or id_field(self.to_cls).attribute_name - def _resolve_to_cls(self, to_cls, owner_cls): + def _resolve_to_cls(self, domain, to_cls, owner_cls): assert isinstance(self.to_cls, str) self._to_cls = to_cls @@ -155,7 +155,7 @@ def _resolve_to_cls(self, to_cls, owner_cls): delattr(owner_cls, old_attribute_name) # Update domain records because we enriched the class structure - current_domain._replace_element_by_class(owner_cls) + domain._replace_element_by_class(owner_cls) def __get__(self, instance, owner): """Retrieve associated objects""" @@ -258,7 +258,7 @@ class Association(FieldBase, FieldDescriptorMixin, FieldCacheMixin): def __init__(self, to_cls, via=None, **kwargs): super().__init__(**kwargs) - self.to_cls = to_cls + self._to_cls = to_cls self.via = via # FIXME Find an elegant way to avoid these declarations in associations @@ -270,13 +270,17 @@ def __init__(self, to_cls, via=None, **kwargs): self.change = None # Used to store type of change in the association self.change_old_value = None # Used to preserve the old value that was removed - def _resolve_to_cls(self, to_cls, owner_cls): + @property + def to_cls(self): + return self._to_cls + + def _resolve_to_cls(self, domain, to_cls, owner_cls): """Resolves class references to actual class object. Called by the domain when a new element is registered, and its name matches `to_cls` """ - self.to_cls = to_cls + self._to_cls = to_cls def _cast_to_type(self, value): """Verify type of value assigned to the association field""" @@ -424,9 +428,6 @@ class HasMany(Association): **kwargs: Additional keyword arguments to be passed to the base field class. """ - def __init__(self, to_cls, via=None, **kwargs): - super().__init__(to_cls, via=via, **kwargs) - def __set__(self, instance, value): if value is not None: self.add(instance, value) diff --git a/src/protean/fields/embedded.py b/src/protean/fields/embedded.py index 7d9fa27d..52811429 100644 --- a/src/protean/fields/embedded.py +++ b/src/protean/fields/embedded.py @@ -1,8 +1,9 @@ """Module for defining embedded fields""" +from functools import lru_cache + from protean.fields import Field from protean.reflection import declared_fields -from protean.utils import DomainObjects, fetch_element_cls_from_registry class _ShadowField(Field): @@ -58,12 +59,38 @@ def __init__(self, value_object_cls, *args, **kwargs): super().__init__(*args, **kwargs) self._value_object_cls = value_object_cls - self.embedded_fields = {} + self._embedded_fields = {} + + @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) + + self._value_object_cls = value_object_cls + + self._construct_embedded_fields() + + # Refresh attribute name, now that we know `value_object_cls` class + self.attribute_name = self.get_attribute_name() + + @property + @lru_cache() + def embedded_fields(self): + """Property to retrieve embedded fields""" + if len(self._embedded_fields) == 0: + self._construct_embedded_fields() + + return self._embedded_fields + + def _construct_embedded_fields(self): + """Construct embedded fields""" for ( field_name, field_obj, ) in declared_fields(self._value_object_cls).items(): - self.embedded_fields[field_name] = _ShadowField( + self._embedded_fields[field_name] = _ShadowField( self, field_name, field_obj, @@ -73,25 +100,6 @@ def __init__(self, value_object_cls, *args, **kwargs): referenced_as=field_obj.referenced_as, ) - @property - def value_object_cls(self): - """Property to retrieve value_object_cls as a Value Object when possible""" - # Checks if ``value_object_cls`` is a string - # If it is, checks if the Value Object is imported and available - # If it is, register the class - try: - if isinstance(self._value_object_cls, str): - self._value_object_cls = fetch_element_cls_from_registry( - self._value_object_cls, (DomainObjects.VALUE_OBJECT,) - ) - except AssertionError: - # Preserve ``value_object_cls`` as a string and we will hook up the entity later - pass - - return self._value_object_cls - - def __set_name__(self, entity_cls, name): - super().__set_name__(entity_cls, name) # Refresh underlying embedded field names for embedded_field in self.embedded_fields.values(): if embedded_field.referenced_as: @@ -101,6 +109,9 @@ def __set_name__(self, entity_cls, name): self.field_name + "_" + embedded_field.field_name ) + def __set_name__(self, entity_cls, name): + super().__set_name__(entity_cls, name) + def get_shadow_fields(self): """Return shadow field Primarily used during Entity initialization to register shadow field""" @@ -133,14 +144,6 @@ def as_dict(self, value): def __set__(self, instance, value): """Override `__set__` to coordinate between value object and its embedded fields""" - if isinstance(self.value_object_cls, str): - self.value_object_cls = fetch_element_cls_from_registry( - self.value_object_cls, (DomainObjects.VALUE_OBJECT,) - ) - - # Refresh attribute name, now that we know `value_object_cls` class - self.attribute_name = self.get_attribute_name() - value = self._load(value) if value: diff --git a/src/protean/utils/__init__.py b/src/protean/utils/__init__.py index 2929fac2..b30b810e 100644 --- a/src/protean/utils/__init__.py +++ b/src/protean/utils/__init__.py @@ -10,7 +10,6 @@ from datetime import UTC, datetime from enum import Enum, auto -from typing import Any, Tuple, Union from uuid import uuid4 from protean.exceptions import ConfigurationError @@ -168,26 +167,3 @@ def generate_identity(): ) return None # Database will generate the identity - - -def fetch_element_cls_from_registry( - element: Union[str, Any], element_types: Tuple[DomainObjects, ...] -) -> Any: - """Util Method to fetch an Element's class from its name""" - if isinstance(element, str): - try: - # Try fetching by class name - return current_domain._get_element_by_name(element_types, element).cls - except ConfigurationError: - try: - # Try fetching by fully qualified class name - return current_domain._get_element_by_fully_qualified_name( - element_types, element - ).cls - except ConfigurationError: - # Element has not been registered - # FIXME print a helpful debug message - raise - else: - # FIXME Check if entity is subclassed from BaseEntity - return element diff --git a/src/protean/utils/mixins.py b/src/protean/utils/mixins.py index 662d0f32..49805d74 100644 --- a/src/protean/utils/mixins.py +++ b/src/protean/utils/mixins.py @@ -15,7 +15,7 @@ from protean.core.event_sourced_aggregate import BaseEventSourcedAggregate from protean.core.unit_of_work import UnitOfWork from protean.core.value_object import BaseValueObject -from protean.exceptions import ConfigurationError, IncorrectUsageError +from protean.exceptions import ConfigurationError from protean.globals import current_domain, g from protean.reflection import has_id_field, id_field from protean.utils import fully_qualified_name @@ -169,16 +169,6 @@ def to_object(self) -> Union[BaseEvent, BaseCommand]: @classmethod def to_message(cls, message_object: Union[BaseEvent, BaseCommand]) -> Message: - # FIXME Should one of `aggregate_cls` or `stream_name` be mandatory? - if not (message_object.meta_.aggregate_cls or message_object.meta_.stream_name): - raise IncorrectUsageError( - { - "_entity": [ - f"`{message_object.__class__.__name__}` needs to be associated with an aggregate or a stream" - ] - } - ) - if has_id_field(message_object): identifier = getattr(message_object, id_field(message_object).field_name) else: diff --git a/tests/adapters/broker/celery_broker/conftest.py b/tests/adapters/broker/celery_broker/conftest.py index ea96dc02..619f7311 100644 --- a/tests/adapters/broker/celery_broker/conftest.py +++ b/tests/adapters/broker/celery_broker/conftest.py @@ -5,18 +5,7 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Celery Broker Tests") with domain.domain_context(): yield domain - - -@pytest.fixture(scope="session", autouse=True) -def setup_redis(): - # Initialize Redis - # FIXME - - yield - - # Close connection to Redis - # FIXME diff --git a/tests/adapters/broker/redis_broker/conftest.py b/tests/adapters/broker/redis_broker/conftest.py index ea96dc02..cd4a0c1b 100644 --- a/tests/adapters/broker/redis_broker/conftest.py +++ b/tests/adapters/broker/redis_broker/conftest.py @@ -5,18 +5,7 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Redis Broker Tests") with domain.domain_context(): yield domain - - -@pytest.fixture(scope="session", autouse=True) -def setup_redis(): - # Initialize Redis - # FIXME - - yield - - # Close connection to Redis - # FIXME diff --git a/tests/adapters/cache/redis_cache/conftest.py b/tests/adapters/cache/redis_cache/conftest.py index 33f587c6..b166526b 100644 --- a/tests/adapters/cache/redis_cache/conftest.py +++ b/tests/adapters/cache/redis_cache/conftest.py @@ -5,7 +5,7 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Redis Cache Tests") with domain.domain_context(): yield domain diff --git a/tests/adapters/email/sendgrid_email/conftest.py b/tests/adapters/email/sendgrid_email/conftest.py index 33f587c6..18634318 100644 --- a/tests/adapters/email/sendgrid_email/conftest.py +++ b/tests/adapters/email/sendgrid_email/conftest.py @@ -5,7 +5,7 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Sendgrid Email Tests") with domain.domain_context(): yield domain diff --git a/tests/adapters/event_store/message_db_event_store/conftest.py b/tests/adapters/event_store/message_db_event_store/conftest.py index 957bac1a..28ee2f1b 100644 --- a/tests/adapters/event_store/message_db_event_store/conftest.py +++ b/tests/adapters/event_store/message_db_event_store/conftest.py @@ -5,7 +5,7 @@ @pytest.fixture def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Message DB Event Store Tests") with domain.domain_context(): yield domain diff --git a/tests/adapters/model/elasticsearch_model/conftest.py b/tests/adapters/model/elasticsearch_model/conftest.py index 8f5dfd5f..b212204c 100644 --- a/tests/adapters/model/elasticsearch_model/conftest.py +++ b/tests/adapters/model/elasticsearch_model/conftest.py @@ -5,15 +5,15 @@ @pytest.fixture def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Elasticsearch Model Tests") with domain.domain_context(): yield domain -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_db(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Elasticsearch Model DB Setup") with domain.domain_context(): # Create all indexes from .elements import ( diff --git a/tests/adapters/model/sqlalchemy_model/postgresql/conftest.py b/tests/adapters/model/sqlalchemy_model/postgresql/conftest.py index 1e43e65f..06af8d0b 100644 --- a/tests/adapters/model/sqlalchemy_model/postgresql/conftest.py +++ b/tests/adapters/model/sqlalchemy_model/postgresql/conftest.py @@ -5,15 +5,15 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "PostgreSQL Model Tests") with domain.domain_context(): yield domain -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_db(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "PostgreSQL Model DB Setup") with domain.domain_context(): # Create all associated tables from .elements import ( diff --git a/tests/adapters/model/sqlalchemy_model/sqlite/conftest.py b/tests/adapters/model/sqlalchemy_model/sqlite/conftest.py index c1ce6674..cf897a29 100644 --- a/tests/adapters/model/sqlalchemy_model/sqlite/conftest.py +++ b/tests/adapters/model/sqlalchemy_model/sqlite/conftest.py @@ -5,15 +5,15 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "SQLAlchemy Model Tests") with domain.domain_context(): yield domain -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_db(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "SQLAlchemy Model DB Setup") with domain.domain_context(): # Create all associated tables from .elements import ComplexUser, Person, Provider, ProviderCustomModel, User diff --git a/tests/adapters/repository/elasticsearch_repo/conftest.py b/tests/adapters/repository/elasticsearch_repo/conftest.py index f6a3d2b1..825e82df 100644 --- a/tests/adapters/repository/elasticsearch_repo/conftest.py +++ b/tests/adapters/repository/elasticsearch_repo/conftest.py @@ -5,15 +5,15 @@ @pytest.fixture def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Elasticsearch Repository Tests") with domain.domain_context(): yield domain -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_db(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "Elasticsearch Repository DB Setup") with domain.domain_context(): # Create all indexes diff --git a/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py b/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py index 23156e6a..bc73e168 100644 --- a/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py +++ b/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py @@ -5,15 +5,15 @@ @pytest.fixture def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "SQLAlchemy Postgres Repository Tests") with domain.domain_context(): yield domain -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_db(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "SQLAlchemy Postgres Repository DB Setup") with domain.domain_context(): # Create all associated tables from .elements import Alien, ComplexUser, Person, User diff --git a/tests/adapters/repository/sqlalchemy_repo/sqlite/conftest.py b/tests/adapters/repository/sqlalchemy_repo/sqlite/conftest.py index c55d4a54..8b11b67c 100644 --- a/tests/adapters/repository/sqlalchemy_repo/sqlite/conftest.py +++ b/tests/adapters/repository/sqlalchemy_repo/sqlite/conftest.py @@ -5,15 +5,15 @@ @pytest.fixture(autouse=True) def test_domain(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "SQLAlchemy SQLite Repository Tests") with domain.domain_context(): yield domain -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def setup_db(): - domain = initialize_domain(__file__) + domain = initialize_domain(__file__, "SQLAlchemy SQLite Repository DB Setup") with domain.domain_context(): # Create all associated tables from .elements import Alien, ComplexUser, Person, User diff --git a/tests/aggregate/elements.py b/tests/aggregate/elements.py index d8096412..29d80a05 100644 --- a/tests/aggregate/elements.py +++ b/tests/aggregate/elements.py @@ -193,25 +193,37 @@ class AccountViaWithReference(BaseAggregate): profile = HasOne("tests.aggregate.elements.ProfileViaWithReference", via="ac_email") -class Profile(BaseAggregate): +class Profile(BaseEntity): about_me = Text() account = Reference("tests.aggregate.elements.Account", via="username") + class Meta: + aggregate_cls = Account + -class ProfileWithAccountId(BaseAggregate): +class ProfileWithAccountId(BaseEntity): about_me = Text() account = Reference("tests.aggregate.elements.AccountWithId") + class Meta: + aggregate_cls = AccountWithId + -class ProfileVia(BaseAggregate): +class ProfileVia(BaseEntity): profile_id = String(identifier=True) about_me = Text() account_email = String(max_length=255) + class Meta: + aggregate_cls = AccountVia + -class ProfileViaWithReference(BaseAggregate): +class ProfileViaWithReference(BaseEntity): about_me = Text() ac = Reference("tests.aggregate.elements.AccountViaWithReference") + class Meta: + aggregate_cls = AccountViaWithReference + # Aggregates to test associations # END # diff --git a/tests/aggregate/test_aggregate_association.py b/tests/aggregate/test_aggregate_association.py index abe6cefb..1b9a8c3a 100644 --- a/tests/aggregate/test_aggregate_association.py +++ b/tests/aggregate/test_aggregate_association.py @@ -24,6 +24,7 @@ class TestHasOne: @pytest.fixture(autouse=True) def register_elements(self, test_domain): test_domain.register(Account) + test_domain.register(Comment) test_domain.register(Author) test_domain.register(AccountVia) test_domain.register(AccountViaWithReference) @@ -31,6 +32,7 @@ def register_elements(self, test_domain): test_domain.register(Profile) test_domain.register(ProfileVia) test_domain.register(ProfileViaWithReference) + test_domain.init(traverse=False) def test_successful_initialization_of_entity_with_has_one_association( self, test_domain @@ -98,12 +100,15 @@ def test_that_subsequent_access_after_first_retrieval_do_not_fetch_record_again( class TestHasMany: @pytest.fixture(autouse=True) def register_elements(self, test_domain): + test_domain.register(Account) + test_domain.register(Author) test_domain.register(Post) test_domain.register(PostVia) test_domain.register(PostViaWithReference) test_domain.register(Comment) test_domain.register(CommentVia) test_domain.register(CommentViaWithReference) + test_domain.init(traverse=False) @pytest.fixture def persisted_post(self, test_domain): diff --git a/tests/aggregate/test_aggregate_association_via.py b/tests/aggregate/test_aggregate_association_via.py index 06292dff..09abd037 100644 --- a/tests/aggregate/test_aggregate_association_via.py +++ b/tests/aggregate/test_aggregate_association_via.py @@ -21,6 +21,7 @@ class Meta: def register_elements(test_domain): test_domain.register(Account) test_domain.register(Profile) + test_domain.init(traverse=False) def test_successful_has_one_initialization_with_a_class_containing_via(test_domain): diff --git a/tests/aggregate/test_aggregates_with_entities.py b/tests/aggregate/test_aggregates_with_entities.py index 96e20e81..87448f49 100644 --- a/tests/aggregate/test_aggregates_with_entities.py +++ b/tests/aggregate/test_aggregates_with_entities.py @@ -9,6 +9,7 @@ def register_elements(self, test_domain): test_domain.register(Post) test_domain.register(PostMeta) test_domain.register(Comment) + test_domain.init(traverse=False) @pytest.fixture def persisted_post(self, test_domain): diff --git a/tests/aggregate/test_as_dict.py b/tests/aggregate/test_as_dict.py index b1447081..6aaa87a6 100644 --- a/tests/aggregate/test_as_dict.py +++ b/tests/aggregate/test_as_dict.py @@ -107,6 +107,7 @@ class Post(BaseAggregate): test_domain.register(Post) test_domain.register(Comment) + test_domain.init(traverse=False) post = Post(title="Test Post", slug="test-post", content="Do Re Mi Fa") comment1 = Comment(content="first comment", post=post) @@ -140,6 +141,7 @@ class Meta: test_domain.register(Post) test_domain.register(PostMeta) + test_domain.init(traverse=False) meta = PostMeta(likes=27) post = Post( diff --git a/tests/command/test_command_meta.py b/tests/command/test_command_meta.py index 090b6feb..df1dd1e2 100644 --- a/tests/command/test_command_meta.py +++ b/tests/command/test_command_meta.py @@ -22,18 +22,14 @@ class Register(BaseCommand): def test_command_definition_without_aggregate_or_stream(test_domain): test_domain.register(User) - test_domain.register(Register) with pytest.raises(IncorrectUsageError) as exc: - test_domain.process( - Register( - user_id=str(uuid4()), - email="john.doe@gmail.com", - name="John Doe", - ) - ) + test_domain.register(Register) + assert exc.value.messages == { - "_entity": ["`Register` needs to be associated with an aggregate or a stream"] + "_command": [ + "Command `Register` needs to be associated with an aggregate or a stream" + ] } diff --git a/tests/context/tests.py b/tests/context/tests.py index 196885a0..a4a046b4 100644 --- a/tests/context/tests.py +++ b/tests/context/tests.py @@ -1,4 +1,3 @@ -import mock import pytest from protean.globals import current_domain, g @@ -10,6 +9,10 @@ def test_domain_context(self, test_domain): with test_domain.domain_context() as context: yield context + def test_repr(self, test_domain): + context = test_domain.domain_context() + assert repr(context) == f"Domain Context (id={id(context)}, domain=Test)" + def test_domain_context_provides_domain_app(self, test_domain): with test_domain.domain_context(): assert current_domain._get_current_object() == test_domain @@ -153,9 +156,3 @@ def test_domain_context_globals_not_shared(self, test_domain): with test_domain.domain_context(foo="baz"): assert g.foo == "baz" - - def test_domain_context_activation_calls_validate_domain(self, test_domain): - mock_validate_domain = mock.Mock() - test_domain._validate_domain = mock_validate_domain - with test_domain.domain_context(): - mock_validate_domain.assert_called_once() diff --git a/tests/domain/test_init.py b/tests/domain/test_init.py new file mode 100644 index 00000000..5339dede --- /dev/null +++ b/tests/domain/test_init.py @@ -0,0 +1,29 @@ +import mock + + +def test_domain_init_calls_validate_domain(test_domain): + mock_validate_domain = mock.Mock() + test_domain._validate_domain = mock_validate_domain + test_domain.init(traverse=False) + mock_validate_domain.assert_called_once() + + +def test_domain_init_calls_traverse(test_domain): + mock_traverse = mock.Mock() + test_domain._traverse = mock_traverse + test_domain.init() + mock_traverse.assert_called_once() + + +def test_domain_init_does_not_call_traverse_when_false(test_domain): + mock_traverse = mock.Mock() + test_domain._traverse = mock_traverse + test_domain.init(traverse=False) + mock_traverse.assert_not_called() + + +def test_domain_init_calls_resolve_references(test_domain): + mock_resolve_references = mock.Mock() + test_domain._resolve_references = mock_resolve_references + test_domain.init(traverse=False) + mock_resolve_references.assert_called_once() diff --git a/tests/domain/tests.py b/tests/domain/tests.py index 8186e484..f68d50ce 100644 --- a/tests/domain/tests.py +++ b/tests/domain/tests.py @@ -85,11 +85,12 @@ class Post(BaseAggregate): test_domain.register(Post) assert "Comment" in test_domain._pending_class_resolutions - # The content in _pending_class_resolutions is dict -> tuple array + # The content in _pending_class_resolutions is dict -> tuple (str, tuple) array # key: field name - # value: tuple of (Field Object, Owning Domain Element) + # value: tuple of (Resolution Type, (Field Object, Owning Domain Element)) for Associations + # value: tuple of (Resolution Type, (Domain Element)) for Meta links assert ( - test_domain._pending_class_resolutions["Comment"][0][0] + test_domain._pending_class_resolutions["Comment"][0][1][0] == declared_fields(Post)["comments"] ) @@ -123,6 +124,7 @@ class Meta: # Registering `Comment` resolves references in both `Comment` and `Post` classes test_domain.register(Comment) + test_domain._resolve_references() assert declared_fields(Post)["comments"].to_cls == Comment assert declared_fields(Comment)["post"].to_cls == Post @@ -169,7 +171,7 @@ class Meta: for field_name in ["Comment", "Post"] ) - def test_that_class_reference_is_resolved_on_domain_activation(self): + def test_that_class_reference_is_resolved_on_domain_initialization(self): domain = Domain(__file__, "Inline Domain") class Post(BaseAggregate): @@ -192,12 +194,14 @@ class Meta: domain.register(Comment) - with domain.domain_context(): - # Resolved references - assert declared_fields(Post)["comments"].to_cls == Comment - assert declared_fields(Comment)["post"].to_cls == Post + # `init` resolves references + domain.init(traverse=False) - assert len(domain._pending_class_resolutions) == 0 + # Check for resolved references + assert declared_fields(Post)["comments"].to_cls == Comment + assert declared_fields(Comment)["post"].to_cls == Post + + assert len(domain._pending_class_resolutions) == 0 def test_that_domain_throws_exception_on_unknown_class_references_during_activation( self, @@ -226,8 +230,7 @@ class Meta: domain.register(Comment) with pytest.raises(ConfigurationError) as exc: - with domain.domain_context(): - pass + domain.init() assert ( exc.value.args[0]["element"] @@ -256,6 +259,7 @@ class Meta: test_domain.register(Post) test_domain.register(Comment) + test_domain._resolve_references() assert declared_fields(Post)["comments"].to_cls == Comment assert declared_fields(Comment)["post"].to_cls == Post @@ -279,6 +283,7 @@ class Meta: test_domain.register(Account) test_domain.register(Author) + test_domain._resolve_references() assert declared_fields(Account)["author"].to_cls == Author assert declared_fields(Author)["account"].to_cls == Account diff --git a/tests/event/elements.py b/tests/event/elements.py index 66d5af31..62b12618 100644 --- a/tests/event/elements.py +++ b/tests/event/elements.py @@ -41,6 +41,9 @@ class PersonAdded(BaseEvent): last_name = String(max_length=50, required=True) age = Integer(default=21) + class Meta: + aggregate_cls = Person + class PersonService(BaseApplicationService): @classmethod diff --git a/tests/event/test_event_meta.py b/tests/event/test_event_meta.py index 811456bf..fc77cca4 100644 --- a/tests/event/test_event_meta.py +++ b/tests/event/test_event_meta.py @@ -20,15 +20,13 @@ class UserLoggedIn(BaseEvent): def test_event_definition_without_aggregate_or_stream(test_domain): test_domain.register(User) - test_domain.register(UserLoggedIn) with pytest.raises(IncorrectUsageError) as exc: - identifier = str(uuid4()) - test_domain.raise_(UserLoggedIn(user_id=identifier)) + test_domain.register(UserLoggedIn) assert exc.value.messages == { - "_entity": [ - "`UserLoggedIn` needs to be associated with an aggregate or a stream" + "_event": [ + "Event `UserLoggedIn` needs to be associated with an aggregate or a stream" ] } @@ -36,6 +34,7 @@ def test_event_definition_without_aggregate_or_stream(test_domain): def test_event_definition_with_just_aggregate_cls(test_domain): test_domain.register(User) test_domain.register(UserLoggedIn, aggregate_cls=User) + test_domain.init(traverse=False) try: identifier = str(uuid4()) @@ -52,4 +51,27 @@ def test_event_definition_with_just_stream(test_domain): identifier = str(uuid4()) test_domain.raise_(UserLoggedIn(user_id=identifier)) except IncorrectUsageError: - pytest.fail("Failed raising event when associated with Aggregate") + pytest.fail("Failed raising event when associated with Stream") + + +def test_that_abstract_events_can_be_defined_without_aggregate_or_stream(test_domain): + class AbstractEvent(BaseEvent): + foo = String() + + class Meta: + abstract = True + + try: + test_domain.register(AbstractEvent) + except Exception: + pytest.fail( + "Abstract events should be definable without being associated with an aggregate or a stream" + ) + + +def test_that_aggregate_cls_is_resolved_correctly(test_domain): + test_domain.register(User) + test_domain.register(UserLoggedIn, aggregate_cls="User") + + test_domain.init(traverse=False) + assert UserLoggedIn.meta_.aggregate_cls == User diff --git a/tests/event/tests.py b/tests/event/tests.py index c919db6c..7c468075 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -19,6 +19,9 @@ class UserAdded(BaseEvent): email = ValueObject(Email, required=True) name = String(max_length=50) + class Meta: + stream_name = "user" + test_domain.register(UserAdded) event = UserAdded(email_address="john.doe@gmail.com", name="John Doe") @@ -41,6 +44,9 @@ class UserAdded(BaseEvent): email = ValueObject(Email, required=True) name = String(max_length=50) + class Meta: + stream_name = "user" + assert UserAdded( { "email": { 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 51a2efc8..57303a94 100644 --- a/tests/event_sourced_aggregates/test_event_association_with_aggregate.py +++ b/tests/event_sourced_aggregates/test_event_association_with_aggregate.py @@ -83,12 +83,13 @@ def test_that_event_is_associated_with_aggregate_by_apply_methods(): def test_that_trying_to_associate_an_event_with_multiple_aggregates_throws_an_error( test_domain, ): + test_domain.register(Email) with pytest.raises(IncorrectUsageError) as exc: - test_domain.register(Email) + test_domain.init(traverse=False) assert exc.value.messages == { - "_entity": [ - "UserRegistered Event cannot be associated with Email" - " because it is already associated with User" + "_event": [ + "Events are associated with multiple event sourced aggregates: " + "tests.event_sourced_aggregates.test_event_association_with_aggregate.UserRegistered" ] } diff --git a/tests/event_sourced_repository/test_loading_aggregates.py b/tests/event_sourced_repository/test_loading_aggregates.py index a0d659bc..9e644b81 100644 --- a/tests/event_sourced_repository/test_loading_aggregates.py +++ b/tests/event_sourced_repository/test_loading_aggregates.py @@ -24,11 +24,17 @@ class Register(BaseCommand): name = String() password_hash = String() + class Meta: + aggregate_cls = "User" + class ChangeAddress(BaseCommand): user_id = Identifier() address = String() + class Meta: + aggregate_cls = "User" + class Registered(BaseEvent): user_id = Identifier() @@ -36,11 +42,17 @@ class Registered(BaseEvent): name = String() password_hash = String() + class Meta: + aggregate_cls = "User" + class AddressChanged(BaseEvent): user_id = Identifier() address = String() + class Meta: + aggregate_cls = "User" + class User(BaseEventSourcedAggregate): user_id = Identifier(identifier=True) diff --git a/tests/event_store/test_appending_commands.py b/tests/event_store/test_appending_commands.py index a1139e2c..ea73a6c5 100644 --- a/tests/event_store/test_appending_commands.py +++ b/tests/event_store/test_appending_commands.py @@ -22,18 +22,14 @@ class Register(BaseCommand): def test_command_submission_without_aggregate(test_domain): test_domain.register(User) - test_domain.register(Register) with pytest.raises(IncorrectUsageError) as exc: - test_domain.process( - Register( - user_id=str(uuid4()), - email="john.doe@gmail.com", - name="John Doe", - ) - ) + test_domain.register(Register) + assert exc.value.messages == { - "_entity": ["`Register` needs to be associated with an aggregate or a stream"] + "_command": [ + "Command `Register` needs to be associated with an aggregate or a stream" + ] } diff --git a/tests/field/test_has_many.py b/tests/field/test_has_many.py index b8d1a4b8..e2d1f950 100644 --- a/tests/field/test_has_many.py +++ b/tests/field/test_has_many.py @@ -22,6 +22,7 @@ class Meta: def register_elements(test_domain): test_domain.register(Post) test_domain.register(Comment) + test_domain.init(traverse=False) class TestHasManyFieldInProperties: diff --git a/tests/field/test_has_one.py b/tests/field/test_has_one.py index c4093469..4a365f27 100644 --- a/tests/field/test_has_one.py +++ b/tests/field/test_has_one.py @@ -1,7 +1,7 @@ import pytest from protean import BaseAggregate, BaseEntity -from protean.exceptions import IncorrectUsageError +from protean.exceptions import ConfigurationError from protean.fields import HasOne, Reference, String from protean.reflection import attributes, declared_fields @@ -23,6 +23,7 @@ class Meta: def register(test_domain): test_domain.register(Book) test_domain.register(Author) + test_domain.init(traverse=False) class TestHasOneFieldsInProperties: @@ -45,10 +46,13 @@ class InvalidAggregate(BaseAggregate): author = HasOne("Book") test_domain.register(InvalidAggregate) - with pytest.raises(IncorrectUsageError): + with pytest.raises(ConfigurationError) as exc: # The `author` HasOne field is invalid because it is linked to an Aggregate test_domain._validate_domain() + assert exc.value.args[0]["element"] == "Unresolved references in domain Test" + assert "Book" in exc.value.args[0]["unresolved"] + class TestHasOnePersistence: def test_that_has_one_field_is_persisted_along_with_aggregate(self, test_domain): diff --git a/tests/field/test_has_one_without_explicit_reference.py b/tests/field/test_has_one_without_explicit_reference.py index 09596644..f9ebe964 100644 --- a/tests/field/test_has_one_without_explicit_reference.py +++ b/tests/field/test_has_one_without_explicit_reference.py @@ -27,6 +27,7 @@ class Meta: def register(test_domain): test_domain.register(Book) test_domain.register(Author) + test_domain.init(traverse=False) class TestHasOneFields: diff --git a/tests/repository/test_child_persistence.py b/tests/repository/test_child_persistence.py index e12010b3..a7a7305f 100644 --- a/tests/repository/test_child_persistence.py +++ b/tests/repository/test_child_persistence.py @@ -11,6 +11,7 @@ def register_elements(self, test_domain): test_domain.register(Post) test_domain.register(PostMeta) test_domain.register(Comment) + test_domain.init(traverse=False) @pytest.fixture(autouse=True) def persist_post(self, test_domain, register_elements): diff --git a/tests/server/test_error_handling.py b/tests/server/test_error_handling.py index bb0688be..99a9996c 100644 --- a/tests/server/test_error_handling.py +++ b/tests/server/test_error_handling.py @@ -10,18 +10,21 @@ from protean.utils.mixins import Message -class Registered(BaseEvent): - id = Identifier() +class User(BaseEventSourcedAggregate): email = String() name = String() password_hash = String() -class User(BaseEventSourcedAggregate): +class Registered(BaseEvent): + id = Identifier() email = String() name = String() password_hash = String() + class Meta: + aggregate_cls = User + def some_function(): raise Exception("Some exception") diff --git a/tests/server/test_event_handling.py b/tests/server/test_event_handling.py index a61b5aea..38a414b1 100644 --- a/tests/server/test_event_handling.py +++ b/tests/server/test_event_handling.py @@ -12,18 +12,21 @@ counter = 0 -class Registered(BaseEvent): - id = Identifier() +class User(BaseEventSourcedAggregate): email = String() name = String() password_hash = String() -class User(BaseEventSourcedAggregate): +class Registered(BaseEvent): + id = Identifier() email = String() name = String() password_hash = String() + class Meta: + aggregate_cls = User + def count_up(): global counter diff --git a/tests/shared.py b/tests/shared.py index dd72112e..018ba6f5 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -11,9 +11,9 @@ from protean.domain import Domain -def initialize_domain(file_path): +def initialize_domain(file_path, name="Tests"): """Initialize a Protean Domain with configuration from a file""" - domain = Domain(__file__, "Tests") + domain = Domain(__file__, name=name) # Construct relative path to config file current_path = os.path.abspath(os.path.dirname(file_path)) diff --git a/tests/subscription/test_read_position_updates.py b/tests/subscription/test_read_position_updates.py index a91c7427..9acdc623 100644 --- a/tests/subscription/test_read_position_updates.py +++ b/tests/subscription/test_read_position_updates.py @@ -32,16 +32,25 @@ class Registered(BaseEvent): name = String() password_hash = String() + class Meta: + aggregate_cls = User + class Activated(BaseEvent): id = Identifier() activated_at = DateTime() + class Meta: + aggregate_cls = User + class Sent(BaseEvent): email = String() sent_at = DateTime() + class Meta: + stream_name = "email" + class UserEventHandler(BaseEventHandler): @handle(Registered) diff --git a/tests/test_aggregates.py b/tests/test_aggregates.py index 5d7f8b57..9a3e7356 100644 --- a/tests/test_aggregates.py +++ b/tests/test_aggregates.py @@ -112,6 +112,8 @@ class Comment: class Meta: aggregate_cls = Post + test_domain.init(traverse=False) + post = Post(name="The World") test_domain.repository_for(Post).add(post) diff --git a/tests/test_commands.py b/tests/test_commands.py index 67ba07b1..c9cf11b1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,18 +1,28 @@ import pytest -from protean import BaseCommand +from protean import BaseAggregate, BaseCommand from protean.exceptions import IncorrectUsageError, InvalidDataError, NotSupportedError from protean.fields import Integer, String from protean.reflection import fields from protean.utils import fully_qualified_name +class User(BaseAggregate): + email = String() + name = String() + password_hash = String() + address = String() + + class UserRegistrationCommand(BaseCommand): email = String(required=True, max_length=250) username = String(required=True, max_length=50) password = String(required=True, max_length=255) age = Integer(default=21) + class Meta: + aggregate_cls = User + class TestCommandInitialization: def test_that_command_object_class_cannot_be_instantiated(self): @@ -57,7 +67,7 @@ def test_that_command_can_be_registered_with_domain(self, test_domain): ) def test_that_command_can_be_registered_via_annotations(self, test_domain): - @test_domain.command + @test_domain.command(aggregate_cls=User) class ChangePasswordCommand: old_password = String(required=True, max_length=255) new_password = String(required=True, max_length=255) @@ -131,7 +141,7 @@ class AbstractCommand2: class Meta: abstract = True - @test_domain.command + @test_domain.command(aggregate_cls=User) class ConcreteCommand2(AbstractCommand2): bar = String() @@ -142,7 +152,7 @@ class ConcreteCommand2(AbstractCommand2): def test_inheritance_of_parent_fields_with_child_annotation_alone( self, test_domain ): - @test_domain.command + @test_domain.command(aggregate_cls=User) class ConcreteCommand3(TestCommandInheritance.AbstractCommand): bar = String() diff --git a/tests/unit_of_work/test_child_object_persistence.py b/tests/unit_of_work/test_child_object_persistence.py index c3e5d768..7e965cae 100644 --- a/tests/unit_of_work/test_child_object_persistence.py +++ b/tests/unit_of_work/test_child_object_persistence.py @@ -14,6 +14,8 @@ def register_elements(self, test_domain): test_domain.register(PostRepository, aggregate_cls=Post) + test_domain.init(traverse=False) + yield @pytest.fixture diff --git a/tests/value_object/test_class_resolution.py b/tests/value_object/test_class_resolution.py new file mode 100644 index 00000000..712c7fe7 --- /dev/null +++ b/tests/value_object/test_class_resolution.py @@ -0,0 +1,25 @@ +"""These tests ensure that the Value Object class is resolved correctly when it is specified as a string.""" + +from protean import BaseAggregate, BaseValueObject +from protean.fields import Float, String, ValueObject +from protean.reflection import declared_fields + + +class Account(BaseAggregate): + balance = ValueObject("Balance", required=True) + kind = String(max_length=15, required=True) + + +class Balance(BaseValueObject): + currency = String(max_length=3) + amount = Float() + + +def test_value_object_class_resolution(test_domain): + test_domain.register(Account) + test_domain.register(Balance) + + # This should perform the class resolution + test_domain.init(traverse=False) + + assert declared_fields(Account)["balance"].value_object_cls == Balance