eventsourcing 9.4.0a7__py3-none-any.whl → 9.4.0a8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of eventsourcing might be problematic. Click here for more details.
- eventsourcing/application.py +3 -7
- eventsourcing/domain.py +346 -357
- eventsourcing/persistence.py +18 -24
- eventsourcing/popo.py +5 -1
- eventsourcing/postgres.py +41 -33
- eventsourcing/projection.py +22 -19
- eventsourcing/sqlite.py +5 -1
- eventsourcing/system.py +19 -11
- eventsourcing/tests/application.py +57 -49
- eventsourcing/tests/domain.py +8 -6
- eventsourcing/tests/persistence.py +162 -143
- eventsourcing/tests/postgres_utils.py +7 -8
- eventsourcing/utils.py +15 -10
- {eventsourcing-9.4.0a7.dist-info → eventsourcing-9.4.0a8.dist-info}/METADATA +1 -1
- eventsourcing-9.4.0a8.dist-info/RECORD +26 -0
- eventsourcing-9.4.0a7.dist-info/RECORD +0 -26
- {eventsourcing-9.4.0a7.dist-info → eventsourcing-9.4.0a8.dist-info}/AUTHORS +0 -0
- {eventsourcing-9.4.0a7.dist-info → eventsourcing-9.4.0a8.dist-info}/LICENSE +0 -0
- {eventsourcing-9.4.0a7.dist-info → eventsourcing-9.4.0a8.dist-info}/WHEEL +0 -0
eventsourcing/domain.py
CHANGED
|
@@ -4,6 +4,8 @@ import dataclasses
|
|
|
4
4
|
import importlib
|
|
5
5
|
import inspect
|
|
6
6
|
import os
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from dataclasses import dataclass
|
|
7
9
|
from datetime import datetime, tzinfo
|
|
8
10
|
from functools import cache
|
|
9
11
|
from types import FunctionType, WrapperDescriptorType
|
|
@@ -22,6 +24,8 @@ from typing import (
|
|
|
22
24
|
from uuid import UUID, uuid4
|
|
23
25
|
from warnings import warn
|
|
24
26
|
|
|
27
|
+
from typing_extensions import Self
|
|
28
|
+
|
|
25
29
|
from eventsourcing.utils import get_method_name, get_topic, resolve_topic
|
|
26
30
|
|
|
27
31
|
if TYPE_CHECKING:
|
|
@@ -40,6 +44,38 @@ domain and convert to local timezones when presenting values in user interfaces.
|
|
|
40
44
|
"""
|
|
41
45
|
|
|
42
46
|
|
|
47
|
+
class EventsourcingType(type):
|
|
48
|
+
"""
|
|
49
|
+
Base type for event sourcing domain model types (aggregates and events).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_T = TypeVar("_T")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def patch_dataclasses_process_class() -> None:
|
|
57
|
+
dataclasses_module = importlib.import_module("dataclasses")
|
|
58
|
+
original_process_class_func = dataclasses_module.__dict__["_process_class"]
|
|
59
|
+
|
|
60
|
+
def _patched_dataclasses_process_class(
|
|
61
|
+
cls: type[_T], *args: Any, **kwargs: Any
|
|
62
|
+
) -> type[_T]:
|
|
63
|
+
# Avoid processing aggregate and event dataclasses twice,
|
|
64
|
+
# because doing so screws up non-init and default fields.
|
|
65
|
+
if (
|
|
66
|
+
cls
|
|
67
|
+
and isinstance(cls, EventsourcingType)
|
|
68
|
+
and "__dataclass_fields__" in cls.__dict__
|
|
69
|
+
):
|
|
70
|
+
return cls
|
|
71
|
+
return original_process_class_func(cls, *args, **kwargs)
|
|
72
|
+
|
|
73
|
+
dataclasses_module.__dict__["_process_class"] = _patched_dataclasses_process_class
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
patch_dataclasses_process_class()
|
|
77
|
+
|
|
78
|
+
|
|
43
79
|
@runtime_checkable
|
|
44
80
|
class DomainEventProtocol(Protocol):
|
|
45
81
|
"""
|
|
@@ -217,6 +253,7 @@ class CanMutateAggregate(HasOriginatorIDVersion, CanCreateTimestamp):
|
|
|
217
253
|
method that evolves the state of an aggregate.
|
|
218
254
|
"""
|
|
219
255
|
|
|
256
|
+
# Todo: Move this to a HasTimestamp? Why is it here??
|
|
220
257
|
timestamp: datetime
|
|
221
258
|
"""Timezone-aware :class:`datetime` object representing when an event occurred."""
|
|
222
259
|
|
|
@@ -326,7 +363,7 @@ class CanInitAggregate(CanMutateAggregate):
|
|
|
326
363
|
return agg
|
|
327
364
|
|
|
328
365
|
|
|
329
|
-
class MetaDomainEvent(
|
|
366
|
+
class MetaDomainEvent(EventsourcingType):
|
|
330
367
|
"""
|
|
331
368
|
Metaclass which ensures all domain event classes are frozen dataclasses.
|
|
332
369
|
"""
|
|
@@ -342,6 +379,7 @@ class MetaDomainEvent(type):
|
|
|
342
379
|
return event_cls
|
|
343
380
|
|
|
344
381
|
|
|
382
|
+
@dataclass(frozen=True)
|
|
345
383
|
class DomainEvent(CanCreateTimestamp, metaclass=MetaDomainEvent):
|
|
346
384
|
"""
|
|
347
385
|
Frozen data class representing domain model events.
|
|
@@ -363,6 +401,7 @@ class AggregateEvent(CanMutateAggregate, DomainEvent):
|
|
|
363
401
|
"""
|
|
364
402
|
|
|
365
403
|
|
|
404
|
+
@dataclass(frozen=True)
|
|
366
405
|
class AggregateCreated(CanInitAggregate, AggregateEvent):
|
|
367
406
|
"""
|
|
368
407
|
Frozen data class representing the initial creation of an aggregate.
|
|
@@ -392,10 +431,6 @@ class LogEvent(DomainEvent):
|
|
|
392
431
|
"""
|
|
393
432
|
|
|
394
433
|
|
|
395
|
-
# Deprecated: Use TDomainEvent instead.
|
|
396
|
-
TLogEvent = TypeVar("TLogEvent", bound=DomainEventProtocol)
|
|
397
|
-
|
|
398
|
-
|
|
399
434
|
def _filter_kwargs_for_method_params(
|
|
400
435
|
kwargs: dict[str, Any], method: Callable[..., Any]
|
|
401
436
|
) -> dict[str, Any]:
|
|
@@ -442,12 +477,12 @@ class CommandMethodDecorator:
|
|
|
442
477
|
elif isinstance(event_spec, type) and issubclass(
|
|
443
478
|
event_spec, CanMutateAggregate
|
|
444
479
|
):
|
|
445
|
-
if event_spec in
|
|
480
|
+
if event_spec in _given_event_classes:
|
|
446
481
|
name = event_spec.__name__
|
|
447
482
|
msg = f"{name} event class used in more than one decorator"
|
|
448
483
|
raise TypeError(msg)
|
|
449
484
|
self.given_event_cls = event_spec
|
|
450
|
-
|
|
485
|
+
_given_event_classes.add(event_spec)
|
|
451
486
|
|
|
452
487
|
# Process a decorated property.
|
|
453
488
|
if isinstance(decorated_obj, property):
|
|
@@ -730,7 +765,7 @@ class BoundCommandMethodDecorator:
|
|
|
730
765
|
kwargs = _coerce_args_to_kwargs(
|
|
731
766
|
self.event_decorator.decorated_method, args, kwargs
|
|
732
767
|
)
|
|
733
|
-
event_cls =
|
|
768
|
+
event_cls = decorator_event_classes[self.event_decorator]
|
|
734
769
|
kwargs = _filter_kwargs_for_method_params(kwargs, event_cls)
|
|
735
770
|
self.aggregate.trigger_event(event_cls, **kwargs)
|
|
736
771
|
|
|
@@ -738,14 +773,30 @@ class BoundCommandMethodDecorator:
|
|
|
738
773
|
self.trigger(*args, **kwargs)
|
|
739
774
|
|
|
740
775
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
776
|
+
class DecoratorEvent(CanMutateAggregate):
|
|
777
|
+
def apply(self, aggregate: Aggregate) -> None:
|
|
778
|
+
"""
|
|
779
|
+
Applies event to aggregate by calling method decorated by @event.
|
|
780
|
+
"""
|
|
781
|
+
# Call super method, just in case any base classes need it.
|
|
782
|
+
super().apply(aggregate)
|
|
783
|
+
|
|
784
|
+
# Identify the method that was decorated.
|
|
785
|
+
decorated_method = _decorated_methods[type(self)]
|
|
786
|
+
|
|
787
|
+
# Select event attributes mentioned in method signature.
|
|
788
|
+
kwargs = _filter_kwargs_for_method_params(self.__dict__, decorated_method)
|
|
789
|
+
|
|
790
|
+
# Call the original method with event attribute values.
|
|
791
|
+
decorated_method(aggregate, **kwargs)
|
|
744
792
|
|
|
745
793
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
] = {}
|
|
794
|
+
_given_event_classes: set[type] = set()
|
|
795
|
+
_decorated_methods: dict[type, CommandMethod] = {}
|
|
796
|
+
_created_event_classes: dict[type, list[type[CanInitAggregate]]] = {}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
decorator_event_classes: dict[CommandMethodDecorator, type[DecoratorEvent]] = {}
|
|
749
800
|
|
|
750
801
|
|
|
751
802
|
def _check_no_variable_params(method: FunctionType) -> None:
|
|
@@ -883,43 +934,86 @@ def _raise_missing_names_type_error(missing_names: list[str], msg: str) -> None:
|
|
|
883
934
|
raise TypeError(msg)
|
|
884
935
|
|
|
885
936
|
|
|
886
|
-
_annotations_mention_id: set[
|
|
887
|
-
_init_mentions_id: set[
|
|
937
|
+
_annotations_mention_id: set[type[Aggregate]] = set()
|
|
938
|
+
_init_mentions_id: set[type[Aggregate]] = set()
|
|
939
|
+
_create_id_param_names: dict[type[Aggregate], list[str]] = defaultdict(list)
|
|
888
940
|
|
|
889
941
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
and module.__dict__["dataclass"] == dataclasses.dataclass
|
|
895
|
-
and "__original_dataclass_func__" not in module.__dict__
|
|
896
|
-
):
|
|
897
|
-
module.__dict__["__original_dataclass_func__"] = module.__dict__["dataclass"]
|
|
898
|
-
module.__dict__["dataclass"] = _idempotent_dataclass
|
|
942
|
+
class MetaAggregate(EventsourcingType, Generic[TAggregate], type):
|
|
943
|
+
"""
|
|
944
|
+
Metaclass for aggregate classes.
|
|
945
|
+
"""
|
|
899
946
|
|
|
947
|
+
def _define_event_class(
|
|
948
|
+
cls,
|
|
949
|
+
name: str,
|
|
950
|
+
bases: tuple[type[CanMutateAggregate], ...],
|
|
951
|
+
apply_method: CommandMethod | None,
|
|
952
|
+
) -> type[CanMutateAggregate]:
|
|
953
|
+
# Define annotations for the event class (specs the init method).
|
|
954
|
+
annotations = {}
|
|
955
|
+
if apply_method is not None:
|
|
956
|
+
method_signature = inspect.signature(apply_method)
|
|
957
|
+
supers = {
|
|
958
|
+
s for b in bases for s in b.__mro__ if hasattr(s, "__annotations__")
|
|
959
|
+
}
|
|
960
|
+
super_annotations = {a for s in supers for a in s.__annotations__}
|
|
961
|
+
for param_name, param in list(method_signature.parameters.items())[1:]:
|
|
962
|
+
# Don't define 'id' on a "created" class.
|
|
963
|
+
if param_name == "id" and apply_method.__name__ == "__init__":
|
|
964
|
+
continue
|
|
965
|
+
# Don't override super class annotations, unless no default on param.
|
|
966
|
+
if param_name not in super_annotations or param.default == param.empty:
|
|
967
|
+
annotations[param_name] = param.annotation or "typing.Any"
|
|
968
|
+
event_cls_qualname = f"{cls.__qualname__}.{name}"
|
|
969
|
+
event_cls_dict = {
|
|
970
|
+
"__annotations__": annotations,
|
|
971
|
+
"__module__": cls.__module__,
|
|
972
|
+
"__qualname__": event_cls_qualname,
|
|
973
|
+
}
|
|
900
974
|
|
|
901
|
-
|
|
975
|
+
# Create the event class object.
|
|
976
|
+
return cast(type[CanMutateAggregate], type(name, bases, event_cls_dict))
|
|
902
977
|
|
|
903
|
-
def
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
978
|
+
def __call__(
|
|
979
|
+
cls: MetaAggregate[TAggregate], *args: Any, **kwargs: Any
|
|
980
|
+
) -> TAggregate:
|
|
981
|
+
created_event_classes = _created_event_classes[cls]
|
|
982
|
+
if len(created_event_classes) > 1:
|
|
983
|
+
msg = (
|
|
984
|
+
f"{cls.__qualname__} can't decide which of many "
|
|
985
|
+
'"created" event classes to use: '
|
|
986
|
+
f"""'{"', '".join(c.__name__ for c in created_event_classes)}'. """
|
|
987
|
+
"Please use class arg 'created_event_name' or"
|
|
988
|
+
" @event decorator on __init__ method."
|
|
989
|
+
)
|
|
990
|
+
raise TypeError(msg)
|
|
991
|
+
|
|
992
|
+
cls_init: FunctionType | WrapperDescriptorType = cls.__init__ # type: ignore
|
|
993
|
+
kwargs = _coerce_args_to_kwargs(
|
|
994
|
+
cls_init,
|
|
995
|
+
args,
|
|
996
|
+
kwargs,
|
|
997
|
+
expects_id=cls in _annotations_mention_id,
|
|
998
|
+
)
|
|
999
|
+
return cls._create(
|
|
1000
|
+
event_class=created_event_classes[0],
|
|
1001
|
+
**kwargs,
|
|
1002
|
+
)
|
|
908
1003
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1004
|
+
def _create(
|
|
1005
|
+
cls: MetaAggregate[TAggregate],
|
|
1006
|
+
event_class: type[CanInitAggregate],
|
|
1007
|
+
**kwargs: Any,
|
|
1008
|
+
) -> TAggregate:
|
|
1009
|
+
raise NotImplementedError # pragma: no cover
|
|
913
1010
|
|
|
914
|
-
|
|
915
|
-
return idempotent_wrap(cls)
|
|
1011
|
+
_created_event_class: type[CanInitAggregate]
|
|
916
1012
|
|
|
917
1013
|
|
|
918
|
-
class MetaAggregate
|
|
1014
|
+
class Aggregate(metaclass=MetaAggregate):
|
|
919
1015
|
"""
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
Initialises aggregate classes by defining event classes.
|
|
1016
|
+
Base class for aggregates.
|
|
923
1017
|
"""
|
|
924
1018
|
|
|
925
1019
|
INITIAL_VERSION = 1
|
|
@@ -930,63 +1024,197 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
930
1024
|
class Created(Event, AggregateCreated):
|
|
931
1025
|
pass
|
|
932
1026
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
super().apply(aggregate)
|
|
1027
|
+
@staticmethod
|
|
1028
|
+
def create_id(*_: Any, **__: Any) -> UUID:
|
|
1029
|
+
"""
|
|
1030
|
+
Returns a new aggregate ID.
|
|
1031
|
+
"""
|
|
1032
|
+
return uuid4()
|
|
940
1033
|
|
|
941
|
-
|
|
942
|
-
|
|
1034
|
+
@classmethod
|
|
1035
|
+
def _create(
|
|
1036
|
+
cls: type[Self],
|
|
1037
|
+
event_class: type[CanInitAggregate],
|
|
1038
|
+
*,
|
|
1039
|
+
id: UUID | None = None, # noqa: A002
|
|
1040
|
+
**kwargs: Any,
|
|
1041
|
+
) -> Self:
|
|
1042
|
+
"""
|
|
1043
|
+
Constructs a new aggregate object instance.
|
|
1044
|
+
"""
|
|
1045
|
+
# Construct the domain event with an ID and a
|
|
1046
|
+
# version, and a topic for the aggregate class.
|
|
1047
|
+
create_id_kwargs = {
|
|
1048
|
+
k: v for k, v in kwargs.items() if k in _create_id_param_names[cls]
|
|
1049
|
+
}
|
|
1050
|
+
if id is not None:
|
|
1051
|
+
originator_id = id
|
|
1052
|
+
if not isinstance(originator_id, UUID):
|
|
1053
|
+
msg = f"Given id was not a UUID: {originator_id}"
|
|
1054
|
+
raise TypeError(msg)
|
|
1055
|
+
else:
|
|
1056
|
+
originator_id = cls.create_id(**create_id_kwargs)
|
|
1057
|
+
if not isinstance(originator_id, UUID):
|
|
1058
|
+
msg = (
|
|
1059
|
+
f"{cls.create_id.__module__}.{cls.create_id.__qualname__}"
|
|
1060
|
+
f" did not return UUID, it returned: {originator_id}"
|
|
1061
|
+
)
|
|
1062
|
+
raise TypeError(msg)
|
|
943
1063
|
|
|
944
|
-
|
|
945
|
-
|
|
1064
|
+
# Impose the required common "created" event attribute values.
|
|
1065
|
+
kwargs = kwargs.copy()
|
|
1066
|
+
kwargs.update(
|
|
1067
|
+
originator_topic=get_topic(cls),
|
|
1068
|
+
originator_id=originator_id,
|
|
1069
|
+
originator_version=cls.INITIAL_VERSION,
|
|
1070
|
+
)
|
|
1071
|
+
if kwargs.get("timestamp") is None:
|
|
1072
|
+
kwargs["timestamp"] = event_class.create_timestamp()
|
|
946
1073
|
|
|
947
|
-
|
|
948
|
-
|
|
1074
|
+
try:
|
|
1075
|
+
created_event = event_class(**kwargs)
|
|
1076
|
+
except TypeError as e:
|
|
1077
|
+
msg = f"Unable to construct '{event_class.__qualname__}' event: {e}"
|
|
1078
|
+
raise TypeError(msg) from e
|
|
1079
|
+
# Construct the aggregate object.
|
|
1080
|
+
agg = cast(Self, created_event.mutate(None))
|
|
949
1081
|
|
|
950
|
-
|
|
1082
|
+
assert agg is not None
|
|
1083
|
+
# Append the domain event to pending list.
|
|
1084
|
+
agg.pending_events.append(created_event)
|
|
1085
|
+
# Return the aggregate.
|
|
1086
|
+
return agg
|
|
1087
|
+
|
|
1088
|
+
def __base_init__(
|
|
1089
|
+
self, originator_id: UUID, originator_version: int, timestamp: datetime
|
|
1090
|
+
) -> None:
|
|
1091
|
+
"""
|
|
1092
|
+
Initialises an aggregate object with an :data:`id`, a :data:`version`
|
|
1093
|
+
number, and a :data:`timestamp`.
|
|
1094
|
+
"""
|
|
1095
|
+
self._id = originator_id
|
|
1096
|
+
self._version = originator_version
|
|
1097
|
+
self._created_on = timestamp
|
|
1098
|
+
self._modified_on = timestamp
|
|
1099
|
+
self._pending_events: list[CanMutateAggregate] = []
|
|
1100
|
+
|
|
1101
|
+
@property
|
|
1102
|
+
def id(self) -> UUID:
|
|
1103
|
+
"""
|
|
1104
|
+
The ID of the aggregate.
|
|
1105
|
+
"""
|
|
1106
|
+
return self._id
|
|
1107
|
+
|
|
1108
|
+
@property
|
|
1109
|
+
def version(self) -> int:
|
|
1110
|
+
"""
|
|
1111
|
+
The version number of the aggregate.
|
|
1112
|
+
"""
|
|
1113
|
+
return self._version
|
|
1114
|
+
|
|
1115
|
+
@version.setter
|
|
1116
|
+
def version(self, version: int) -> None:
|
|
1117
|
+
self._version = version
|
|
1118
|
+
|
|
1119
|
+
@property
|
|
1120
|
+
def created_on(self) -> datetime:
|
|
1121
|
+
"""
|
|
1122
|
+
The date and time when the aggregate was created.
|
|
1123
|
+
"""
|
|
1124
|
+
return self._created_on
|
|
951
1125
|
|
|
952
|
-
|
|
1126
|
+
@property
|
|
1127
|
+
def modified_on(self) -> datetime:
|
|
953
1128
|
"""
|
|
954
|
-
|
|
1129
|
+
The date and time when the aggregate was last modified.
|
|
955
1130
|
"""
|
|
1131
|
+
return self._modified_on
|
|
956
1132
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1133
|
+
@modified_on.setter
|
|
1134
|
+
def modified_on(self, modified_on: datetime) -> None:
|
|
1135
|
+
self._modified_on = modified_on
|
|
1136
|
+
|
|
1137
|
+
@property
|
|
1138
|
+
def pending_events(self) -> list[CanMutateAggregate]:
|
|
1139
|
+
"""
|
|
1140
|
+
A list of pending events.
|
|
1141
|
+
"""
|
|
1142
|
+
return self._pending_events
|
|
1143
|
+
|
|
1144
|
+
def trigger_event(
|
|
1145
|
+
self,
|
|
1146
|
+
event_class: type[CanMutateAggregate],
|
|
1147
|
+
**kwargs: Any,
|
|
1148
|
+
) -> None:
|
|
1149
|
+
"""
|
|
1150
|
+
Triggers domain event of given type, by creating
|
|
1151
|
+
an event object and using it to mutate the aggregate.
|
|
1152
|
+
"""
|
|
1153
|
+
# Construct the domain event as the
|
|
1154
|
+
# next in the aggregate's sequence.
|
|
1155
|
+
# Use counting to generate the sequence.
|
|
1156
|
+
next_version = self.version + 1
|
|
1157
|
+
|
|
1158
|
+
# Impose the required common domain event attribute values.
|
|
1159
|
+
kwargs = kwargs.copy()
|
|
1160
|
+
kwargs.update(
|
|
1161
|
+
originator_id=self.id,
|
|
1162
|
+
originator_version=next_version,
|
|
1163
|
+
)
|
|
1164
|
+
if kwargs.get("timestamp") is None:
|
|
1165
|
+
kwargs["timestamp"] = event_class.create_timestamp()
|
|
961
1166
|
|
|
962
1167
|
try:
|
|
963
|
-
|
|
964
|
-
except
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
else:
|
|
968
|
-
try:
|
|
969
|
-
class_annotations.pop("id")
|
|
970
|
-
except KeyError:
|
|
971
|
-
annotations_mention_id = False
|
|
972
|
-
else:
|
|
973
|
-
annotations_mention_id = True
|
|
974
|
-
aggregate_cls = type.__new__(cls, *args)
|
|
975
|
-
if class_annotations or any(dataclasses.is_dataclass(base) for base in args[1]):
|
|
976
|
-
aggregate_cls = dataclasses.dataclass(eq=False, repr=False)(aggregate_cls)
|
|
977
|
-
if annotations_mention_id:
|
|
978
|
-
_annotations_mention_id.add(aggregate_cls)
|
|
979
|
-
return aggregate_cls
|
|
1168
|
+
new_event = event_class(**kwargs)
|
|
1169
|
+
except TypeError as e:
|
|
1170
|
+
msg = f"Can't construct event {event_class}: {e}"
|
|
1171
|
+
raise TypeError(msg) from None
|
|
980
1172
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1173
|
+
# Mutate aggregate with domain event.
|
|
1174
|
+
new_event.mutate(self)
|
|
1175
|
+
# Append the domain event to pending list.
|
|
1176
|
+
self._pending_events.append(new_event)
|
|
1177
|
+
|
|
1178
|
+
def collect_events(self) -> Sequence[CanMutateAggregate]:
|
|
1179
|
+
"""
|
|
1180
|
+
Collects and returns a list of pending aggregate
|
|
1181
|
+
:class:`AggregateEvent` objects.
|
|
1182
|
+
"""
|
|
1183
|
+
collected = []
|
|
1184
|
+
while self._pending_events:
|
|
1185
|
+
collected.append(self._pending_events.pop(0))
|
|
1186
|
+
return collected
|
|
1187
|
+
|
|
1188
|
+
def __eq__(self, other: object) -> bool:
|
|
1189
|
+
return type(self) is type(other) and self.__dict__ == other.__dict__
|
|
1190
|
+
|
|
1191
|
+
def __repr__(self) -> str:
|
|
1192
|
+
attrs = [
|
|
1193
|
+
f"{k.lstrip('_')}={v!r}"
|
|
1194
|
+
for k, v in self.__dict__.items()
|
|
1195
|
+
if k != "_pending_events"
|
|
1196
|
+
]
|
|
1197
|
+
return f"{type(self).__name__}({', '.join(attrs)})"
|
|
1198
|
+
|
|
1199
|
+
def __init_subclass__(
|
|
1200
|
+
cls: type[Aggregate], *, created_event_name: str | None = None
|
|
985
1201
|
) -> None:
|
|
986
1202
|
"""
|
|
987
|
-
Initialises aggregate
|
|
1203
|
+
Initialises aggregate subclass by defining __init__ method and event classes.
|
|
988
1204
|
"""
|
|
989
|
-
super().
|
|
1205
|
+
super().__init_subclass__()
|
|
1206
|
+
|
|
1207
|
+
class_annotations = cls.__dict__.get("__annotations__", {})
|
|
1208
|
+
try:
|
|
1209
|
+
class_annotations.pop("id")
|
|
1210
|
+
_annotations_mention_id.add(cls)
|
|
1211
|
+
except KeyError:
|
|
1212
|
+
pass
|
|
1213
|
+
|
|
1214
|
+
if class_annotations or any(
|
|
1215
|
+
dataclasses.is_dataclass(base) for base in cls.__bases__
|
|
1216
|
+
):
|
|
1217
|
+
dataclasses.dataclass(eq=False, repr=False)(cls)
|
|
990
1218
|
|
|
991
1219
|
# Identify or define a base event class for this aggregate.
|
|
992
1220
|
base_event_name = "Event"
|
|
@@ -1019,15 +1247,8 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1019
1247
|
if isinstance(value, type) and issubclass(value, CanInitAggregate):
|
|
1020
1248
|
created_event_classes[name] = value
|
|
1021
1249
|
|
|
1022
|
-
# Disallow using both '_created_event_class' and 'created_event_name'.
|
|
1023
|
-
created_event_class: type[CanInitAggregate] | None = cls.__dict__.get(
|
|
1024
|
-
"_created_event_class"
|
|
1025
|
-
)
|
|
1026
|
-
if created_event_class and created_event_name:
|
|
1027
|
-
msg = "Can't use both '_created_event_class' and 'created_event_name'"
|
|
1028
|
-
raise TypeError(msg)
|
|
1029
|
-
|
|
1030
1250
|
# Identify or define the aggregate's "created" event class.
|
|
1251
|
+
created_event_class: type[CanInitAggregate] | None = None
|
|
1031
1252
|
|
|
1032
1253
|
# Is the init method decorated with a CommandMethodDecorator?
|
|
1033
1254
|
if isinstance(cls.__dict__.get("__init__"), CommandMethodDecorator):
|
|
@@ -1040,16 +1261,13 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1040
1261
|
if created_event_name:
|
|
1041
1262
|
msg = "Can't use both 'created_event_name' and decorator on __init__"
|
|
1042
1263
|
raise TypeError(msg)
|
|
1043
|
-
# Disallow using both '_created_event_class' and decorator on __init__.
|
|
1044
|
-
if created_event_class:
|
|
1045
|
-
msg = "Can't use both '_created_event_class' and decorator on __init__"
|
|
1046
|
-
raise TypeError(msg)
|
|
1047
1264
|
|
|
1048
1265
|
# Does the decorator specify a "created" event class?
|
|
1049
1266
|
if init_decorator.given_event_cls:
|
|
1050
1267
|
created_event_class = cast(
|
|
1051
1268
|
type[CanInitAggregate], init_decorator.given_event_cls
|
|
1052
1269
|
)
|
|
1270
|
+
|
|
1053
1271
|
# Does the decorator specify a "created" event name?
|
|
1054
1272
|
elif init_decorator.event_cls_name:
|
|
1055
1273
|
created_event_name = init_decorator.event_cls_name
|
|
@@ -1061,7 +1279,7 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1061
1279
|
|
|
1062
1280
|
# TODO: Write a test to cover this when "Created" class is explicitly defined.
|
|
1063
1281
|
# Check if init mentions ID.
|
|
1064
|
-
for param_name in inspect.signature(cls.__init__).parameters:
|
|
1282
|
+
for param_name in inspect.signature(cls.__init__).parameters:
|
|
1065
1283
|
if param_name == "id":
|
|
1066
1284
|
_init_mentions_id.add(cls)
|
|
1067
1285
|
break
|
|
@@ -1095,17 +1313,21 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1095
1313
|
if created_event_name and len(created_event_classes) == 1:
|
|
1096
1314
|
base_created_event_cls = next(iter(created_event_classes.values()))
|
|
1097
1315
|
else:
|
|
1316
|
+
# Todo: This could probably be improved.
|
|
1317
|
+
# Look for first class in MRO that has one specified "created" class.
|
|
1098
1318
|
for base_cls in cls.__mro__:
|
|
1099
|
-
if
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
base_cls
|
|
1104
|
-
)
|
|
1105
|
-
if base_created_event_cls:
|
|
1319
|
+
if (
|
|
1320
|
+
base_cls in _created_event_classes
|
|
1321
|
+
and len(_created_event_classes[base_cls]) == 1
|
|
1322
|
+
):
|
|
1323
|
+
base_created_event_cls = _created_event_classes[base_cls][0]
|
|
1106
1324
|
break
|
|
1107
1325
|
else: # pragma: no cover
|
|
1108
|
-
|
|
1326
|
+
# Todo: Write a test to cover this.
|
|
1327
|
+
msg = (
|
|
1328
|
+
"Can't find base aggregate class with "
|
|
1329
|
+
"a specified 'created' event class"
|
|
1330
|
+
)
|
|
1109
1331
|
raise TypeError(msg)
|
|
1110
1332
|
|
|
1111
1333
|
if not created_event_name:
|
|
@@ -1141,10 +1363,10 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1141
1363
|
setattr(cls, created_event_name, created_event_class)
|
|
1142
1364
|
|
|
1143
1365
|
if created_event_class:
|
|
1144
|
-
cls
|
|
1366
|
+
_created_event_classes[cls] = [created_event_class]
|
|
1145
1367
|
else:
|
|
1146
1368
|
# Prepare to disallow ambiguity of choice between created event classes.
|
|
1147
|
-
|
|
1369
|
+
_created_event_classes[cls] = list(created_event_classes.values())
|
|
1148
1370
|
|
|
1149
1371
|
# Prepare the subsequent event classes.
|
|
1150
1372
|
for attr_name, attr_value in tuple(cls.__dict__.items()):
|
|
@@ -1198,7 +1420,7 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1198
1420
|
)
|
|
1199
1421
|
event_cls = cls._define_event_class(
|
|
1200
1422
|
event_decorator.given_event_cls.__name__,
|
|
1201
|
-
(
|
|
1423
|
+
(DecoratorEvent, given_subclass),
|
|
1202
1424
|
None,
|
|
1203
1425
|
)
|
|
1204
1426
|
|
|
@@ -1215,19 +1437,19 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1215
1437
|
# Define event class from signature of original method.
|
|
1216
1438
|
event_cls = cls._define_event_class(
|
|
1217
1439
|
event_decorator.event_cls_name,
|
|
1218
|
-
(
|
|
1440
|
+
(DecoratorEvent, base_event_cls),
|
|
1219
1441
|
event_decorator.decorated_method,
|
|
1220
1442
|
)
|
|
1221
1443
|
|
|
1222
1444
|
# Cache the decorated method for the event class to use.
|
|
1223
|
-
|
|
1445
|
+
_decorated_methods[event_cls] = event_decorator.decorated_method
|
|
1224
1446
|
|
|
1225
1447
|
# Set the event class as an attribute of the aggregate class.
|
|
1226
1448
|
setattr(cls, event_cls.__name__, event_cls)
|
|
1227
1449
|
|
|
1228
1450
|
# Remember which event class to trigger.
|
|
1229
|
-
|
|
1230
|
-
type[
|
|
1451
|
+
decorator_event_classes[event_decorator] = cast(
|
|
1452
|
+
type[DecoratorEvent], event_cls
|
|
1231
1453
|
)
|
|
1232
1454
|
|
|
1233
1455
|
# Check any create_id method defined on this class is static or class method.
|
|
@@ -1241,13 +1463,12 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1241
1463
|
raise TypeError(msg)
|
|
1242
1464
|
|
|
1243
1465
|
# Get the parameters of the create_id method that will be used by this class.
|
|
1244
|
-
cls._create_id_param_names: list[str] = []
|
|
1245
1466
|
for name, param in inspect.signature(cls.create_id).parameters.items():
|
|
1246
1467
|
if param.kind in [param.KEYWORD_ONLY, param.POSITIONAL_OR_KEYWORD]:
|
|
1247
|
-
cls.
|
|
1468
|
+
_create_id_param_names[cls].append(name)
|
|
1248
1469
|
|
|
1249
1470
|
# Define event classes for all events on bases.
|
|
1250
|
-
for aggregate_base_class in
|
|
1471
|
+
for aggregate_base_class in cls.__bases__:
|
|
1251
1472
|
for name, value in aggregate_base_class.__dict__.items():
|
|
1252
1473
|
if (
|
|
1253
1474
|
isinstance(value, type)
|
|
@@ -1260,253 +1481,20 @@ class MetaAggregate(type, Generic[TAggregate]):
|
|
|
1260
1481
|
)
|
|
1261
1482
|
setattr(cls, name, sub_class)
|
|
1262
1483
|
|
|
1263
|
-
def _define_event_class(
|
|
1264
|
-
cls,
|
|
1265
|
-
name: str,
|
|
1266
|
-
bases: tuple[type[CanMutateAggregate], ...],
|
|
1267
|
-
apply_method: CommandMethod | None,
|
|
1268
|
-
) -> type[CanMutateAggregate]:
|
|
1269
|
-
# Define annotations for the event class (specs the init method).
|
|
1270
|
-
annotations = {}
|
|
1271
|
-
if apply_method is not None:
|
|
1272
|
-
method_signature = inspect.signature(apply_method)
|
|
1273
|
-
supers = {
|
|
1274
|
-
s for b in bases for s in b.__mro__ if hasattr(s, "__annotations__")
|
|
1275
|
-
}
|
|
1276
|
-
super_annotations = {a for s in supers for a in s.__annotations__}
|
|
1277
|
-
for param_name, param in list(method_signature.parameters.items())[1:]:
|
|
1278
|
-
# Don't define 'id' on a "created" class.
|
|
1279
|
-
if param_name == "id" and apply_method.__name__ == "__init__":
|
|
1280
|
-
continue
|
|
1281
|
-
# Don't override super class annotations, unless no default on param.
|
|
1282
|
-
if param_name not in super_annotations or param.default == param.empty:
|
|
1283
|
-
annotations[param_name] = param.annotation or "typing.Any"
|
|
1284
|
-
event_cls_qualname = f"{cls.__qualname__}.{name}"
|
|
1285
|
-
event_cls_dict = {
|
|
1286
|
-
"__annotations__": annotations,
|
|
1287
|
-
"__module__": cls.__module__,
|
|
1288
|
-
"__qualname__": event_cls_qualname,
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
# Create the event class object.
|
|
1292
|
-
return cast(type[CanMutateAggregate], type(name, bases, event_cls_dict))
|
|
1293
|
-
|
|
1294
|
-
def __call__(
|
|
1295
|
-
cls: MetaAggregate[TAggregate], *args: Any, **kwargs: Any
|
|
1296
|
-
) -> TAggregate:
|
|
1297
|
-
try:
|
|
1298
|
-
created_event_classes = aggregate_has_many_created_event_classes[cls]
|
|
1299
|
-
msg = (
|
|
1300
|
-
"""Can't decide which of many "created" event classes to use: """
|
|
1301
|
-
f"""'{"', '".join(created_event_classes)}'. Please use class """
|
|
1302
|
-
"arg 'created_event_name' or @event decorator on __init__ method."
|
|
1303
|
-
)
|
|
1304
|
-
raise TypeError(msg)
|
|
1305
|
-
except KeyError:
|
|
1306
|
-
pass
|
|
1307
|
-
|
|
1308
|
-
cls_init: FunctionType | WrapperDescriptorType = cls.__init__ # type: ignore
|
|
1309
|
-
kwargs = _coerce_args_to_kwargs(
|
|
1310
|
-
cls_init,
|
|
1311
|
-
args,
|
|
1312
|
-
kwargs,
|
|
1313
|
-
expects_id=cls in _annotations_mention_id,
|
|
1314
|
-
)
|
|
1315
|
-
return cls._create(
|
|
1316
|
-
event_class=cls._created_event_class,
|
|
1317
|
-
**kwargs,
|
|
1318
|
-
)
|
|
1319
|
-
|
|
1320
|
-
def _create(
|
|
1321
|
-
cls: MetaAggregate[TAggregate],
|
|
1322
|
-
event_class: type[CanInitAggregate],
|
|
1323
|
-
**kwargs: Any,
|
|
1324
|
-
) -> TAggregate:
|
|
1325
|
-
raise NotImplementedError # pragma: no cover
|
|
1326
|
-
|
|
1327
|
-
@staticmethod
|
|
1328
|
-
def create_id(**_: Any) -> UUID:
|
|
1329
|
-
"""
|
|
1330
|
-
Returns a new aggregate ID.
|
|
1331
|
-
"""
|
|
1332
|
-
return uuid4()
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
class Aggregate(metaclass=MetaAggregate):
|
|
1336
|
-
"""
|
|
1337
|
-
Base class for aggregate roots.
|
|
1338
|
-
|
|
1339
|
-
.. automethod:: _create
|
|
1340
|
-
"""
|
|
1341
|
-
|
|
1342
|
-
@classmethod
|
|
1343
|
-
def _create(
|
|
1344
|
-
cls: type[TAggregate],
|
|
1345
|
-
event_class: type[CanInitAggregate],
|
|
1346
|
-
*,
|
|
1347
|
-
id: UUID | None = None, # noqa: A002
|
|
1348
|
-
**kwargs: Any,
|
|
1349
|
-
) -> TAggregate:
|
|
1350
|
-
"""
|
|
1351
|
-
Constructs a new aggregate object instance.
|
|
1352
|
-
"""
|
|
1353
|
-
# Construct the domain event with an ID and a
|
|
1354
|
-
# version, and a topic for the aggregate class.
|
|
1355
|
-
create_id_kwargs = {
|
|
1356
|
-
k: v for k, v in kwargs.items() if k in cls._create_id_param_names
|
|
1357
|
-
}
|
|
1358
|
-
originator_id = id or cls.create_id(**create_id_kwargs)
|
|
1359
1484
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
originator_topic=get_topic(cls),
|
|
1364
|
-
originator_id=originator_id,
|
|
1365
|
-
originator_version=cls.INITIAL_VERSION,
|
|
1366
|
-
)
|
|
1367
|
-
if kwargs.get("timestamp") is None:
|
|
1368
|
-
kwargs["timestamp"] = event_class.create_timestamp()
|
|
1485
|
+
# Special case for the Aggregate class because
|
|
1486
|
+
# it's not processed by Aggregate.__init_subclass__.
|
|
1487
|
+
_created_event_classes[Aggregate] = [Aggregate.Created]
|
|
1369
1488
|
|
|
1370
|
-
try:
|
|
1371
|
-
created_event = event_class(**kwargs)
|
|
1372
|
-
except TypeError as e:
|
|
1373
|
-
msg = f"Unable to construct '{event_class.__name__}' event: {e}"
|
|
1374
|
-
raise TypeError(msg) from None
|
|
1375
|
-
# Construct the aggregate object.
|
|
1376
|
-
agg = cast(TAggregate, created_event.mutate(None))
|
|
1377
1489
|
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
# Return the aggregate.
|
|
1382
|
-
return agg
|
|
1383
|
-
|
|
1384
|
-
def __base_init__(
|
|
1385
|
-
self, originator_id: UUID, originator_version: int, timestamp: datetime
|
|
1386
|
-
) -> None:
|
|
1387
|
-
"""
|
|
1388
|
-
Initialises an aggregate object with an :data:`id`, a :data:`version`
|
|
1389
|
-
number, and a :data:`timestamp`.
|
|
1390
|
-
"""
|
|
1391
|
-
self._id = originator_id
|
|
1392
|
-
self._version = originator_version
|
|
1393
|
-
self._created_on = timestamp
|
|
1394
|
-
self._modified_on = timestamp
|
|
1395
|
-
self._pending_events: list[CanMutateAggregate] = []
|
|
1396
|
-
|
|
1397
|
-
@property
|
|
1398
|
-
def id(self) -> UUID:
|
|
1399
|
-
"""
|
|
1400
|
-
The ID of the aggregate.
|
|
1401
|
-
"""
|
|
1402
|
-
return self._id
|
|
1403
|
-
|
|
1404
|
-
@property
|
|
1405
|
-
def version(self) -> int:
|
|
1406
|
-
"""
|
|
1407
|
-
The version number of the aggregate.
|
|
1408
|
-
"""
|
|
1409
|
-
return self._version
|
|
1410
|
-
|
|
1411
|
-
@version.setter
|
|
1412
|
-
def version(self, version: int) -> None:
|
|
1413
|
-
self._version = version
|
|
1414
|
-
|
|
1415
|
-
@property
|
|
1416
|
-
def created_on(self) -> datetime:
|
|
1417
|
-
"""
|
|
1418
|
-
The date and time when the aggregate was created.
|
|
1419
|
-
"""
|
|
1420
|
-
return self._created_on
|
|
1421
|
-
|
|
1422
|
-
@property
|
|
1423
|
-
def modified_on(self) -> datetime:
|
|
1424
|
-
"""
|
|
1425
|
-
The date and time when the aggregate was last modified.
|
|
1426
|
-
"""
|
|
1427
|
-
return self._modified_on
|
|
1428
|
-
|
|
1429
|
-
@modified_on.setter
|
|
1430
|
-
def modified_on(self, modified_on: datetime) -> None:
|
|
1431
|
-
self._modified_on = modified_on
|
|
1432
|
-
|
|
1433
|
-
@property
|
|
1434
|
-
def pending_events(self) -> list[CanMutateAggregate]:
|
|
1435
|
-
"""
|
|
1436
|
-
A list of pending events.
|
|
1437
|
-
"""
|
|
1438
|
-
return self._pending_events
|
|
1439
|
-
|
|
1440
|
-
class Event(AggregateEvent):
|
|
1441
|
-
pass
|
|
1442
|
-
|
|
1443
|
-
class Created(Event, AggregateCreated):
|
|
1444
|
-
pass
|
|
1445
|
-
|
|
1446
|
-
def __eq__(self, other: object) -> bool:
|
|
1447
|
-
return type(self) is type(other) and self.__dict__ == other.__dict__
|
|
1448
|
-
|
|
1449
|
-
def __repr__(self) -> str:
|
|
1450
|
-
attrs = [
|
|
1451
|
-
f"{k.lstrip('_')}={v!r}"
|
|
1452
|
-
for k, v in self.__dict__.items()
|
|
1453
|
-
if k != "_pending_events"
|
|
1454
|
-
]
|
|
1455
|
-
return f"{type(self).__name__}({', '.join(attrs)})"
|
|
1456
|
-
|
|
1457
|
-
def trigger_event(
|
|
1458
|
-
self,
|
|
1459
|
-
event_class: type[CanMutateAggregate],
|
|
1460
|
-
**kwargs: Any,
|
|
1461
|
-
) -> None:
|
|
1462
|
-
"""
|
|
1463
|
-
Triggers domain event of given type, by creating
|
|
1464
|
-
an event object and using it to mutate the aggregate.
|
|
1465
|
-
"""
|
|
1466
|
-
# Construct the domain event as the
|
|
1467
|
-
# next in the aggregate's sequence.
|
|
1468
|
-
# Use counting to generate the sequence.
|
|
1469
|
-
next_version = self.version + 1
|
|
1470
|
-
|
|
1471
|
-
# Impose the required common domain event attribute values.
|
|
1472
|
-
kwargs = kwargs.copy()
|
|
1473
|
-
kwargs.update(
|
|
1474
|
-
originator_id=self.id,
|
|
1475
|
-
originator_version=next_version,
|
|
1476
|
-
)
|
|
1477
|
-
if kwargs.get("timestamp") is None:
|
|
1478
|
-
kwargs["timestamp"] = event_class.create_timestamp()
|
|
1479
|
-
|
|
1480
|
-
try:
|
|
1481
|
-
new_event = event_class(**kwargs)
|
|
1482
|
-
except TypeError as e:
|
|
1483
|
-
msg = f"Can't construct event {event_class}: {e}"
|
|
1484
|
-
raise TypeError(msg) from None
|
|
1485
|
-
|
|
1486
|
-
# Mutate aggregate with domain event.
|
|
1487
|
-
new_event.mutate(self)
|
|
1488
|
-
# Append the domain event to pending list.
|
|
1489
|
-
self._pending_events.append(new_event)
|
|
1490
|
-
|
|
1491
|
-
def collect_events(self) -> Sequence[CanMutateAggregate]:
|
|
1492
|
-
"""
|
|
1493
|
-
Collects and returns a list of pending aggregate
|
|
1494
|
-
:class:`AggregateEvent` objects.
|
|
1495
|
-
"""
|
|
1496
|
-
collected = []
|
|
1497
|
-
while self._pending_events:
|
|
1498
|
-
collected.append(self._pending_events.pop(0))
|
|
1499
|
-
return collected
|
|
1490
|
+
@overload
|
|
1491
|
+
def aggregate(*, created_event_name: str) -> Callable[[Any], type[Aggregate]]:
|
|
1492
|
+
pass # pragma: no cover
|
|
1500
1493
|
|
|
1501
1494
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
#
|
|
1505
|
-
#
|
|
1506
|
-
#
|
|
1507
|
-
# @overload
|
|
1508
|
-
# def aggregate(cls: Any) -> Type[Aggregate]:
|
|
1509
|
-
# ...
|
|
1495
|
+
@overload
|
|
1496
|
+
def aggregate(cls: Any) -> type[Aggregate]:
|
|
1497
|
+
pass # pragma: no cover
|
|
1510
1498
|
|
|
1511
1499
|
|
|
1512
1500
|
def aggregate(
|
|
@@ -1652,6 +1640,7 @@ class CanSnapshotAggregate(HasOriginatorIDVersion, CanCreateTimestamp):
|
|
|
1652
1640
|
return aggregate
|
|
1653
1641
|
|
|
1654
1642
|
|
|
1643
|
+
@dataclass(frozen=True)
|
|
1655
1644
|
class Snapshot(CanSnapshotAggregate, DomainEvent):
|
|
1656
1645
|
"""
|
|
1657
1646
|
Snapshots represent the state of an aggregate at a particular
|