From de0cb3f42d05c0897f0c37c9a96b5bd6f5466c46 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 16 May 2024 11:35:53 -0700 Subject: [PATCH] Resolve references when initializing Domain (#419) This commit moves resolving references, as well as validating domain constructs to domain initialization, not activation. This ensures they happen one time, at the beginning of domain setup. If they are in activation, the resolution would be triggered every time a domain context is activated, which would be every time the domain processes a request! This commit also has the following fixes: * Ensure Value Object field's class resolution works * All Events and Commands need to be associated with an Aggregate or a stream * Validation to ensure events are not associated with multiple event sourced aggregates * Allow Abstract events and commands to be defined without an Aggregate or a stream * Scope `setup_db` fixtures to Module level (Having them at `session` level was resulting in the wrong context being popped) * Move `fetch_element_cls_from_registry` under Domain object to avoid using `current_domain` * Pass `domain` during class resolution to avoid using `current_domain` --- .../domain-behavior/aggregate-mutation.md | 1 + .../domain-services.md | 0 docs/guides/domain-behavior/index.md | 1 + docs/guides/domain-behavior/invariants.md | 1 + docs/guides/domain-behavior/raising-events.md | 1 + docs/guides/domain-definition/events.md | 73 +++++ docs/guides/domain-definition/index.md | 4 + .../guides/domain-definition/value-objects.md | 13 + docs/guides/getting-started/start-here.md | 3 + .../guides/domain-definition/events/001.py | 37 +++ mkdocs.yml | 11 +- src/protean/adapters/broker/celery.py | 3 +- .../adapters/repository/elasticsearch.py | 2 +- src/protean/cli/__init__.py | 8 +- src/protean/cli/generate.py | 7 +- src/protean/cli/shell.py | 10 +- src/protean/core/command.py | 18 +- src/protean/core/event.py | 22 +- src/protean/core/event_sourced_aggregate.py | 24 +- src/protean/domain/__init__.py | 250 +++++++++++------- src/protean/domain/context.py | 9 +- src/protean/fields/association.py | 17 +- src/protean/fields/embedded.py | 63 ++--- src/protean/utils/__init__.py | 24 -- src/protean/utils/mixins.py | 12 +- .../adapters/broker/celery_broker/conftest.py | 13 +- .../adapters/broker/redis_broker/conftest.py | 13 +- tests/adapters/cache/redis_cache/conftest.py | 2 +- .../adapters/email/sendgrid_email/conftest.py | 2 +- .../message_db_event_store/conftest.py | 2 +- .../model/elasticsearch_model/conftest.py | 6 +- .../sqlalchemy_model/postgresql/conftest.py | 6 +- .../model/sqlalchemy_model/sqlite/conftest.py | 6 +- .../repository/elasticsearch_repo/conftest.py | 6 +- .../sqlalchemy_repo/postgresql/conftest.py | 6 +- .../sqlalchemy_repo/sqlite/conftest.py | 6 +- tests/aggregate/elements.py | 20 +- tests/aggregate/test_aggregate_association.py | 5 + .../test_aggregate_association_via.py | 1 + .../test_aggregates_with_entities.py | 1 + tests/aggregate/test_as_dict.py | 2 + tests/command/test_command_meta.py | 14 +- tests/context/tests.py | 11 +- tests/domain/test_init.py | 29 ++ tests/domain/tests.py | 27 +- tests/event/elements.py | 3 + tests/event/test_event_meta.py | 34 ++- tests/event/tests.py | 6 + .../test_event_association_with_aggregate.py | 9 +- .../test_loading_aggregates.py | 12 + tests/event_store/test_appending_commands.py | 14 +- tests/field/test_has_many.py | 1 + tests/field/test_has_one.py | 8 +- ...test_has_one_without_explicit_reference.py | 1 + tests/repository/test_child_persistence.py | 1 + tests/server/test_error_handling.py | 9 +- tests/server/test_event_handling.py | 9 +- tests/shared.py | 4 +- .../test_read_position_updates.py | 9 + tests/test_aggregates.py | 2 + tests/test_commands.py | 18 +- .../test_child_object_persistence.py | 2 + tests/value_object/test_class_resolution.py | 25 ++ 63 files changed, 629 insertions(+), 330 deletions(-) create mode 100644 docs/guides/domain-behavior/aggregate-mutation.md rename docs/guides/{domain-definition => domain-behavior}/domain-services.md (100%) create mode 100644 docs/guides/domain-behavior/index.md create mode 100644 docs/guides/domain-behavior/invariants.md create mode 100644 docs/guides/domain-behavior/raising-events.md create mode 100644 docs/guides/getting-started/start-here.md create mode 100644 docs_src/guides/domain-definition/events/001.py create mode 100644 tests/domain/test_init.py create mode 100644 tests/value_object/test_class_resolution.py 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