eventsourcing 9.2.22__py3-none-any.whl → 9.3.0__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.

Files changed (144) hide show
  1. eventsourcing/__init__.py +1 -1
  2. eventsourcing/application.py +116 -135
  3. eventsourcing/cipher.py +15 -12
  4. eventsourcing/dispatch.py +31 -91
  5. eventsourcing/domain.py +220 -226
  6. eventsourcing/examples/__init__.py +0 -0
  7. eventsourcing/examples/aggregate1/__init__.py +0 -0
  8. eventsourcing/examples/aggregate1/application.py +27 -0
  9. eventsourcing/examples/aggregate1/domainmodel.py +16 -0
  10. eventsourcing/examples/aggregate1/test_application.py +37 -0
  11. eventsourcing/examples/aggregate2/__init__.py +0 -0
  12. eventsourcing/examples/aggregate2/application.py +27 -0
  13. eventsourcing/examples/aggregate2/domainmodel.py +22 -0
  14. eventsourcing/examples/aggregate2/test_application.py +37 -0
  15. eventsourcing/examples/aggregate3/__init__.py +0 -0
  16. eventsourcing/examples/aggregate3/application.py +27 -0
  17. eventsourcing/examples/aggregate3/domainmodel.py +38 -0
  18. eventsourcing/examples/aggregate3/test_application.py +37 -0
  19. eventsourcing/examples/aggregate4/__init__.py +0 -0
  20. eventsourcing/examples/aggregate4/application.py +27 -0
  21. eventsourcing/examples/aggregate4/domainmodel.py +114 -0
  22. eventsourcing/examples/aggregate4/test_application.py +38 -0
  23. eventsourcing/examples/aggregate5/__init__.py +0 -0
  24. eventsourcing/examples/aggregate5/application.py +27 -0
  25. eventsourcing/examples/aggregate5/domainmodel.py +131 -0
  26. eventsourcing/examples/aggregate5/test_application.py +38 -0
  27. eventsourcing/examples/aggregate6/__init__.py +0 -0
  28. eventsourcing/examples/aggregate6/application.py +30 -0
  29. eventsourcing/examples/aggregate6/domainmodel.py +123 -0
  30. eventsourcing/examples/aggregate6/test_application.py +38 -0
  31. eventsourcing/examples/aggregate6a/__init__.py +0 -0
  32. eventsourcing/examples/aggregate6a/application.py +40 -0
  33. eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
  34. eventsourcing/examples/aggregate6a/test_application.py +45 -0
  35. eventsourcing/examples/aggregate7/__init__.py +0 -0
  36. eventsourcing/examples/aggregate7/application.py +48 -0
  37. eventsourcing/examples/aggregate7/domainmodel.py +144 -0
  38. eventsourcing/examples/aggregate7/persistence.py +57 -0
  39. eventsourcing/examples/aggregate7/test_application.py +38 -0
  40. eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
  41. eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
  42. eventsourcing/examples/aggregate7a/__init__.py +0 -0
  43. eventsourcing/examples/aggregate7a/application.py +56 -0
  44. eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
  45. eventsourcing/examples/aggregate7a/test_application.py +46 -0
  46. eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
  47. eventsourcing/examples/aggregate8/__init__.py +0 -0
  48. eventsourcing/examples/aggregate8/application.py +47 -0
  49. eventsourcing/examples/aggregate8/domainmodel.py +65 -0
  50. eventsourcing/examples/aggregate8/persistence.py +57 -0
  51. eventsourcing/examples/aggregate8/test_application.py +37 -0
  52. eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
  53. eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
  54. eventsourcing/examples/bankaccounts/__init__.py +0 -0
  55. eventsourcing/examples/bankaccounts/application.py +70 -0
  56. eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
  57. eventsourcing/examples/bankaccounts/test.py +173 -0
  58. eventsourcing/examples/cargoshipping/__init__.py +0 -0
  59. eventsourcing/examples/cargoshipping/application.py +126 -0
  60. eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
  61. eventsourcing/examples/cargoshipping/interface.py +143 -0
  62. eventsourcing/examples/cargoshipping/test.py +231 -0
  63. eventsourcing/examples/contentmanagement/__init__.py +0 -0
  64. eventsourcing/examples/contentmanagement/application.py +118 -0
  65. eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
  66. eventsourcing/examples/contentmanagement/test.py +180 -0
  67. eventsourcing/examples/contentmanagement/utils.py +26 -0
  68. eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
  69. eventsourcing/examples/contentmanagementsystem/application.py +54 -0
  70. eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
  71. eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
  72. eventsourcing/examples/contentmanagementsystem/system.py +14 -0
  73. eventsourcing/examples/contentmanagementsystem/test_system.py +180 -0
  74. eventsourcing/examples/searchablecontent/__init__.py +0 -0
  75. eventsourcing/examples/searchablecontent/application.py +45 -0
  76. eventsourcing/examples/searchablecontent/persistence.py +23 -0
  77. eventsourcing/examples/searchablecontent/postgres.py +118 -0
  78. eventsourcing/examples/searchablecontent/sqlite.py +136 -0
  79. eventsourcing/examples/searchablecontent/test_application.py +110 -0
  80. eventsourcing/examples/searchablecontent/test_recorder.py +68 -0
  81. eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
  82. eventsourcing/examples/searchabletimestamps/application.py +32 -0
  83. eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
  84. eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
  85. eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
  86. eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +94 -0
  87. eventsourcing/examples/test_invoice.py +176 -0
  88. eventsourcing/examples/test_parking_lot.py +206 -0
  89. eventsourcing/interface.py +2 -2
  90. eventsourcing/persistence.py +85 -81
  91. eventsourcing/popo.py +30 -31
  92. eventsourcing/postgres.py +379 -590
  93. eventsourcing/sqlite.py +91 -99
  94. eventsourcing/system.py +52 -57
  95. eventsourcing/tests/application.py +20 -32
  96. eventsourcing/tests/application_tests/__init__.py +0 -0
  97. eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
  98. eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
  99. eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
  100. eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
  101. eventsourcing/tests/application_tests/test_cache.py +134 -0
  102. eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
  103. eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
  104. eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
  105. eventsourcing/tests/application_tests/test_processapplication.py +110 -0
  106. eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
  107. eventsourcing/tests/application_tests/test_repository.py +504 -0
  108. eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
  109. eventsourcing/tests/application_tests/test_upcasting.py +459 -0
  110. eventsourcing/tests/docs_tests/__init__.py +0 -0
  111. eventsourcing/tests/docs_tests/test_docs.py +293 -0
  112. eventsourcing/tests/domain.py +1 -1
  113. eventsourcing/tests/domain_tests/__init__.py +0 -0
  114. eventsourcing/tests/domain_tests/test_aggregate.py +1180 -0
  115. eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
  116. eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
  117. eventsourcing/tests/interface_tests/__init__.py +0 -0
  118. eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
  119. eventsourcing/tests/persistence.py +52 -50
  120. eventsourcing/tests/persistence_tests/__init__.py +0 -0
  121. eventsourcing/tests/persistence_tests/test_aes.py +93 -0
  122. eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
  123. eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
  124. eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
  125. eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
  126. eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
  127. eventsourcing/tests/persistence_tests/test_popo.py +124 -0
  128. eventsourcing/tests/persistence_tests/test_postgres.py +1119 -0
  129. eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
  130. eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
  131. eventsourcing/tests/postgres_utils.py +7 -7
  132. eventsourcing/tests/system_tests/__init__.py +0 -0
  133. eventsourcing/tests/system_tests/test_runner.py +935 -0
  134. eventsourcing/tests/system_tests/test_system.py +284 -0
  135. eventsourcing/tests/utils_tests/__init__.py +0 -0
  136. eventsourcing/tests/utils_tests/test_utils.py +226 -0
  137. eventsourcing/utils.py +47 -50
  138. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/METADATA +29 -79
  139. eventsourcing-9.3.0.dist-info/RECORD +145 -0
  140. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/WHEEL +1 -2
  141. eventsourcing-9.2.22.dist-info/RECORD +0 -25
  142. eventsourcing-9.2.22.dist-info/top_level.txt +0 -1
  143. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/AUTHORS +0 -0
  144. {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict
4
+
5
+ from eventsourcing.application import Application
6
+ from eventsourcing.examples.aggregate6.domainmodel import (
7
+ add_trick,
8
+ project_dog,
9
+ register_dog,
10
+ )
11
+
12
+ if TYPE_CHECKING: # pragma: nocover
13
+ from uuid import UUID
14
+
15
+
16
+ class DogSchool(Application):
17
+ is_snapshotting_enabled = True
18
+
19
+ def register_dog(self, name: str) -> UUID:
20
+ event = register_dog(name)
21
+ self.save(event)
22
+ return event.originator_id
23
+
24
+ def add_trick(self, dog_id: UUID, trick: str) -> None:
25
+ dog = self.repository.get(dog_id, projector_func=project_dog)
26
+ self.save(add_trick(dog, trick))
27
+
28
+ def get_dog(self, dog_id: UUID) -> Dict[str, Any]:
29
+ dog = self.repository.get(dog_id, projector_func=project_dog)
30
+ return {"name": dog.name, "tricks": dog.tricks}
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from functools import singledispatch
6
+ from typing import Callable, Iterable, Optional, Tuple, TypeVar
7
+ from uuid import UUID, uuid4
8
+
9
+ from eventsourcing.domain import Snapshot
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class DomainEvent:
14
+ originator_id: UUID
15
+ originator_version: int
16
+ timestamp: datetime
17
+
18
+
19
+ def create_timestamp() -> datetime:
20
+ return datetime.now(tz=timezone.utc)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Aggregate:
25
+ id: UUID
26
+ version: int
27
+ created_on: datetime
28
+ modified_on: datetime
29
+
30
+
31
+ TAggregate = TypeVar("TAggregate", bound=Aggregate)
32
+ MutatorFunction = Callable[..., Optional[TAggregate]]
33
+
34
+
35
+ def aggregate_projector(
36
+ mutator: MutatorFunction[TAggregate],
37
+ ) -> Callable[[TAggregate | None, Iterable[DomainEvent]], TAggregate | None]:
38
+ def project_aggregate(
39
+ aggregate: TAggregate | None, events: Iterable[DomainEvent]
40
+ ) -> TAggregate | None:
41
+ for event in events:
42
+ aggregate = mutator(event, aggregate)
43
+ return aggregate
44
+
45
+ return project_aggregate
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Dog(Aggregate):
50
+ name: str
51
+ tricks: Tuple[str, ...]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DogRegistered(DomainEvent):
56
+ name: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TrickAdded(DomainEvent):
61
+ trick: str
62
+
63
+
64
+ def register_dog(name: str) -> DomainEvent:
65
+ return DogRegistered(
66
+ originator_id=uuid4(),
67
+ originator_version=1,
68
+ timestamp=create_timestamp(),
69
+ name=name,
70
+ )
71
+
72
+
73
+ def add_trick(dog: Dog, trick: str) -> DomainEvent:
74
+ return TrickAdded(
75
+ originator_id=dog.id,
76
+ originator_version=dog.version + 1,
77
+ timestamp=create_timestamp(),
78
+ trick=trick,
79
+ )
80
+
81
+
82
+ @singledispatch
83
+ def mutate_dog(_: DomainEvent | Snapshot, __: Dog | None) -> Dog | None:
84
+ """Mutates aggregate with event."""
85
+
86
+
87
+ @mutate_dog.register
88
+ def _(event: DogRegistered, _: None) -> Dog:
89
+ return Dog(
90
+ id=event.originator_id,
91
+ version=event.originator_version,
92
+ created_on=event.timestamp,
93
+ modified_on=event.timestamp,
94
+ name=event.name,
95
+ tricks=(),
96
+ )
97
+
98
+
99
+ @mutate_dog.register
100
+ def _(event: TrickAdded, dog: Dog) -> Dog:
101
+ return Dog(
102
+ id=dog.id,
103
+ version=event.originator_version,
104
+ created_on=dog.created_on,
105
+ modified_on=event.timestamp,
106
+ name=dog.name,
107
+ tricks=(*dog.tricks, event.trick),
108
+ )
109
+
110
+
111
+ @mutate_dog.register
112
+ def _(event: Snapshot, _: None) -> Dog:
113
+ return Dog(
114
+ id=event.state["id"],
115
+ version=event.state["version"],
116
+ created_on=event.state["created_on"],
117
+ modified_on=event.state["modified_on"],
118
+ name=event.state["name"],
119
+ tricks=tuple(event.state["tricks"]), # comes back from JSON as a list
120
+ )
121
+
122
+
123
+ project_dog = aggregate_projector(mutate_dog)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest import TestCase
4
+
5
+ from eventsourcing.examples.aggregate6.application import DogSchool
6
+ from eventsourcing.examples.aggregate6.domainmodel import project_dog
7
+
8
+
9
+ class TestDogSchool(TestCase):
10
+ def test_dog_school(self) -> None:
11
+ # Construct application object.
12
+ school = DogSchool()
13
+
14
+ # Evolve application state.
15
+ dog_id = school.register_dog("Fido")
16
+ school.add_trick(dog_id, "roll over")
17
+ school.add_trick(dog_id, "play dead")
18
+
19
+ # Query application state.
20
+ dog = school.get_dog(dog_id)
21
+ assert dog["name"] == "Fido"
22
+ assert dog["tricks"] == ("roll over", "play dead")
23
+
24
+ # Select notifications.
25
+ notifications = school.notification_log.select(start=1, limit=10)
26
+ assert len(notifications) == 3
27
+
28
+ # Take snapshot.
29
+ school.take_snapshot(dog_id, version=3, projector_func=project_dog)
30
+ dog = school.get_dog(dog_id)
31
+ assert dog["name"] == "Fido"
32
+ assert dog["tricks"] == ("roll over", "play dead")
33
+
34
+ # Continue with snapshotted aggregate.
35
+ school.add_trick(dog_id, "fetch ball")
36
+ dog = school.get_dog(dog_id)
37
+ assert dog["name"] == "Fido"
38
+ assert dog["tricks"] == ("roll over", "play dead", "fetch ball")
File without changes
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar, Dict, Type
4
+
5
+ from eventsourcing.application import Application, ProjectorFunction
6
+ from eventsourcing.examples.aggregate6a.domainmodel import (
7
+ Dog,
8
+ add_trick,
9
+ project_dog,
10
+ register_dog,
11
+ )
12
+
13
+ if TYPE_CHECKING: # pragma: nocover
14
+ from uuid import UUID
15
+
16
+ from eventsourcing.domain import MutableOrImmutableAggregate
17
+
18
+
19
+ class DogSchool(Application):
20
+ is_snapshotting_enabled = True
21
+ snapshotting_intervals: ClassVar[
22
+ Dict[Type[MutableOrImmutableAggregate], int] | None
23
+ ] = {Dog: 5}
24
+ snapshotting_projectors: ClassVar[
25
+ Dict[Type[MutableOrImmutableAggregate], ProjectorFunction[Any, Any]] | None
26
+ ] = {Dog: project_dog}
27
+
28
+ def register_dog(self, name: str) -> UUID:
29
+ dog = register_dog(name)
30
+ self.save(dog)
31
+ return dog.id
32
+
33
+ def add_trick(self, dog_id: UUID, trick: str) -> None:
34
+ dog = self.repository.get(dog_id, projector_func=project_dog)
35
+ dog = add_trick(dog, trick)
36
+ self.save(dog)
37
+
38
+ def get_dog(self, dog_id: UUID) -> Dict[str, Any]:
39
+ dog = self.repository.get(dog_id, projector_func=project_dog)
40
+ return {"name": dog.name, "tricks": dog.tricks}
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from functools import singledispatch
8
+ from typing import Callable, Dict, Iterable, List, Optional, Tuple, TypeVar
9
+ from uuid import UUID, uuid4
10
+
11
+ from eventsourcing.domain import Snapshot
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DomainEvent:
16
+ originator_id: UUID
17
+ originator_version: int
18
+ timestamp: datetime
19
+
20
+
21
+ def create_timestamp() -> datetime:
22
+ return datetime.now(tz=timezone.utc)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Aggregate:
27
+ id: UUID
28
+ version: int
29
+ created_on: datetime
30
+ modified_on: datetime
31
+
32
+ def hold_event(self, event: DomainEvent) -> None:
33
+ all_pending_events[id(self)].append(event)
34
+
35
+ def collect_events(self) -> List[DomainEvent]:
36
+ try:
37
+ return all_pending_events.pop(id(self))
38
+ except KeyError: # pragma: no cover
39
+ return []
40
+
41
+ def __del__(self) -> None:
42
+ with contextlib.suppress(KeyError):
43
+ all_pending_events.pop(id(self))
44
+
45
+
46
+ TAggregate = TypeVar("TAggregate", bound=Aggregate)
47
+ MutatorFunction = Callable[..., Optional[TAggregate]]
48
+
49
+
50
+ def aggregate_projector(
51
+ mutator: MutatorFunction[TAggregate],
52
+ ) -> Callable[[TAggregate | None, Iterable[DomainEvent]], TAggregate | None]:
53
+ def project_aggregate(
54
+ aggregate: TAggregate | None, events: Iterable[DomainEvent]
55
+ ) -> TAggregate | None:
56
+ for event in events:
57
+ aggregate = mutator(event, aggregate)
58
+ return aggregate
59
+
60
+ return project_aggregate
61
+
62
+
63
+ all_pending_events: Dict[int, List[DomainEvent]] = defaultdict(list)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Dog(Aggregate):
68
+ name: str
69
+ tricks: Tuple[str, ...]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class DogRegistered(DomainEvent):
74
+ name: str
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class TrickAdded(DomainEvent):
79
+ trick: str
80
+
81
+
82
+ def register_dog(name: str) -> Dog:
83
+ event = DogRegistered(
84
+ originator_id=uuid4(),
85
+ originator_version=1,
86
+ timestamp=create_timestamp(),
87
+ name=name,
88
+ )
89
+ dog = mutate_dog(event, None)
90
+ assert isinstance(dog, Dog)
91
+ dog.hold_event(event)
92
+ return dog
93
+
94
+
95
+ def add_trick(dog: Dog, trick: str) -> Dog:
96
+ event = TrickAdded(
97
+ originator_id=dog.id,
98
+ originator_version=dog.version + 1,
99
+ timestamp=create_timestamp(),
100
+ trick=trick,
101
+ )
102
+ dog_ = mutate_dog(event, dog)
103
+ assert isinstance(dog_, Dog)
104
+ dog_.hold_event(event)
105
+ return dog_
106
+
107
+
108
+ @singledispatch
109
+ def mutate_dog(_: DomainEvent | Snapshot, __: Dog | None) -> Dog | None:
110
+ """Mutates aggregate with event."""
111
+
112
+
113
+ @mutate_dog.register
114
+ def _(event: DogRegistered, _: None) -> Dog:
115
+ return Dog(
116
+ id=event.originator_id,
117
+ version=event.originator_version,
118
+ created_on=event.timestamp,
119
+ modified_on=event.timestamp,
120
+ name=event.name,
121
+ tricks=(),
122
+ )
123
+
124
+
125
+ @mutate_dog.register
126
+ def _(event: TrickAdded, dog: Dog) -> Dog:
127
+ return Dog(
128
+ id=dog.id,
129
+ version=event.originator_version,
130
+ created_on=dog.created_on,
131
+ modified_on=event.timestamp,
132
+ name=dog.name,
133
+ tricks=(*dog.tricks, event.trick),
134
+ )
135
+
136
+
137
+ @mutate_dog.register
138
+ def _(event: Snapshot, _: None) -> Dog:
139
+ return Dog(
140
+ id=event.state["id"],
141
+ version=event.state["version"],
142
+ created_on=event.state["created_on"],
143
+ modified_on=event.state["modified_on"],
144
+ name=event.state["name"],
145
+ tricks=tuple(event.state["tricks"]), # comes back from JSON as a list
146
+ )
147
+
148
+
149
+ project_dog = aggregate_projector(mutate_dog)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest import TestCase
4
+
5
+ from eventsourcing.examples.aggregate6a.application import DogSchool
6
+ from eventsourcing.examples.aggregate6a.domainmodel import project_dog
7
+
8
+
9
+ class TestDogSchool(TestCase):
10
+ def test_dog_school(self) -> None:
11
+ # Construct application object.
12
+ school = DogSchool()
13
+
14
+ # Evolve application state.
15
+ dog_id = school.register_dog("Fido")
16
+ school.add_trick(dog_id, "roll over")
17
+ school.add_trick(dog_id, "play dead")
18
+
19
+ # Query application state.
20
+ dog = school.get_dog(dog_id)
21
+ assert dog["name"] == "Fido"
22
+ assert dog["tricks"] == ("roll over", "play dead")
23
+
24
+ # Select notifications.
25
+ notifications = school.notification_log.select(start=1, limit=10)
26
+ assert len(notifications) == 3
27
+
28
+ # Take snapshot.
29
+ assert school.snapshots is not None
30
+ assert len(list(school.snapshots.get(dog_id))) == 0
31
+ school.take_snapshot(dog_id, version=3, projector_func=project_dog)
32
+ assert len(list(school.snapshots.get(dog_id))) == 1
33
+ dog = school.get_dog(dog_id)
34
+ assert dog["name"] == "Fido"
35
+ assert dog["tricks"] == ("roll over", "play dead")
36
+
37
+ # Continue with snapshotted aggregate.
38
+ school.add_trick(dog_id, "fetch ball")
39
+ dog = school.get_dog(dog_id)
40
+ assert dog["name"] == "Fido"
41
+ assert dog["tricks"] == ("roll over", "play dead", "fetch ball")
42
+
43
+ assert len(list(school.snapshots.get(dog_id))) == 1
44
+ school.add_trick(dog_id, "jump hoop") # Version 5, autosnapshot.
45
+ assert len(list(school.snapshots.get(dog_id))) == 2
File without changes
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict
4
+
5
+ from eventsourcing.application import Application
6
+ from eventsourcing.examples.aggregate7.domainmodel import (
7
+ Snapshot,
8
+ Trick,
9
+ add_trick,
10
+ project_dog,
11
+ register_dog,
12
+ )
13
+ from eventsourcing.examples.aggregate7.persistence import (
14
+ OrjsonTranscoder,
15
+ PydanticMapper,
16
+ )
17
+
18
+ if TYPE_CHECKING: # pragma: nocover
19
+ from uuid import UUID
20
+
21
+ from eventsourcing.persistence import Mapper, Transcoder
22
+
23
+
24
+ class DogSchool(Application):
25
+ is_snapshotting_enabled = True
26
+ snapshot_class = Snapshot
27
+
28
+ def register_dog(self, name: str) -> UUID:
29
+ event = register_dog(name)
30
+ self.save(event)
31
+ return event.originator_id
32
+
33
+ def add_trick(self, dog_id: UUID, trick: str) -> None:
34
+ dog = self.repository.get(dog_id, projector_func=project_dog)
35
+ self.save(add_trick(dog, Trick(name=trick)))
36
+
37
+ def get_dog(self, dog_id: UUID) -> Dict[str, Any]:
38
+ dog = self.repository.get(dog_id, projector_func=project_dog)
39
+ return {"name": dog.name, "tricks": tuple([t.name for t in dog.tricks])}
40
+
41
+ def construct_mapper(self) -> Mapper:
42
+ return self.factory.mapper(
43
+ transcoder=self.construct_transcoder(),
44
+ mapper_class=PydanticMapper,
45
+ )
46
+
47
+ def construct_transcoder(self) -> Transcoder:
48
+ return OrjsonTranscoder()
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from functools import singledispatch
5
+ from typing import Any, Callable, Dict, Iterable, Optional, Tuple, TypeVar
6
+ from uuid import UUID, uuid4
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from eventsourcing.utils import get_topic
11
+
12
+
13
+ class DomainEvent(BaseModel):
14
+ originator_id: UUID
15
+ originator_version: int
16
+ timestamp: datetime
17
+
18
+ class Config:
19
+ frozen = True
20
+
21
+
22
+ def create_timestamp() -> datetime:
23
+ return datetime.now(tz=timezone.utc)
24
+
25
+
26
+ class Aggregate(BaseModel):
27
+ id: UUID
28
+ version: int
29
+ created_on: datetime
30
+ modified_on: datetime
31
+
32
+ class Config:
33
+ frozen = True
34
+
35
+
36
+ class Snapshot(DomainEvent):
37
+ topic: str
38
+ state: Dict[str, Any]
39
+
40
+ @classmethod
41
+ def take(cls, aggregate: Aggregate) -> Snapshot:
42
+ return Snapshot(
43
+ originator_id=aggregate.id,
44
+ originator_version=aggregate.version,
45
+ timestamp=create_timestamp(),
46
+ topic=get_topic(type(aggregate)),
47
+ state=aggregate.model_dump(),
48
+ )
49
+
50
+
51
+ TAggregate = TypeVar("TAggregate", bound=Aggregate)
52
+ MutatorFunction = Callable[..., Optional[TAggregate]]
53
+
54
+
55
+ def aggregate_projector(
56
+ mutator: MutatorFunction[TAggregate],
57
+ ) -> Callable[[TAggregate | None, Iterable[DomainEvent]], TAggregate | None]:
58
+ def project_aggregate(
59
+ aggregate: TAggregate | None, events: Iterable[DomainEvent]
60
+ ) -> TAggregate | None:
61
+ for event in events:
62
+ aggregate = mutator(event, aggregate)
63
+ return aggregate
64
+
65
+ return project_aggregate
66
+
67
+
68
+ class Trick(BaseModel):
69
+ name: str
70
+
71
+
72
+ class Dog(Aggregate):
73
+ name: str
74
+ tricks: Tuple[Trick, ...]
75
+
76
+
77
+ class DogRegistered(DomainEvent):
78
+ name: str
79
+
80
+
81
+ class TrickAdded(DomainEvent):
82
+ trick: Trick
83
+
84
+
85
+ def register_dog(name: str) -> DomainEvent:
86
+ return DogRegistered(
87
+ originator_id=uuid4(),
88
+ originator_version=1,
89
+ timestamp=create_timestamp(),
90
+ name=name,
91
+ )
92
+
93
+
94
+ def add_trick(dog: Dog, trick: Trick) -> DomainEvent:
95
+ return TrickAdded(
96
+ originator_id=dog.id,
97
+ originator_version=dog.version + 1,
98
+ timestamp=create_timestamp(),
99
+ trick=trick,
100
+ )
101
+
102
+
103
+ @singledispatch
104
+ def mutate_dog(_: DomainEvent, __: Dog | None) -> Dog | None:
105
+ """Mutates aggregate with event."""
106
+
107
+
108
+ @mutate_dog.register
109
+ def _(event: DogRegistered, _: None) -> Dog:
110
+ return Dog(
111
+ id=event.originator_id,
112
+ version=event.originator_version,
113
+ created_on=event.timestamp,
114
+ modified_on=event.timestamp,
115
+ name=event.name,
116
+ tricks=(),
117
+ )
118
+
119
+
120
+ @mutate_dog.register
121
+ def _(event: TrickAdded, dog: Dog) -> Dog:
122
+ return Dog(
123
+ id=dog.id,
124
+ version=event.originator_version,
125
+ created_on=dog.created_on,
126
+ modified_on=event.timestamp,
127
+ name=dog.name,
128
+ tricks=(*dog.tricks, event.trick),
129
+ )
130
+
131
+
132
+ @mutate_dog.register
133
+ def _(event: Snapshot, _: None) -> Dog:
134
+ return Dog(
135
+ id=event.state["id"],
136
+ version=event.state["version"],
137
+ created_on=event.state["created_on"],
138
+ modified_on=event.state["modified_on"],
139
+ name=event.state["name"],
140
+ tricks=event.state["tricks"],
141
+ )
142
+
143
+
144
+ project_dog = aggregate_projector(mutate_dog)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, cast
4
+
5
+ import orjson
6
+ from pydantic import BaseModel
7
+
8
+ from eventsourcing.persistence import (
9
+ Mapper,
10
+ ProgrammingError,
11
+ StoredEvent,
12
+ Transcoder,
13
+ Transcoding,
14
+ )
15
+ from eventsourcing.utils import get_topic, resolve_topic
16
+
17
+ if TYPE_CHECKING: # pragma: nocover
18
+ from eventsourcing.domain import DomainEventProtocol
19
+
20
+
21
+ class PydanticMapper(Mapper):
22
+ def to_stored_event(self, domain_event: DomainEventProtocol) -> StoredEvent:
23
+ topic = get_topic(domain_event.__class__)
24
+ event_state = cast(BaseModel, domain_event).model_dump()
25
+ stored_state = self.transcoder.encode(event_state)
26
+ if self.compressor:
27
+ stored_state = self.compressor.compress(stored_state)
28
+ if self.cipher:
29
+ stored_state = self.cipher.encrypt(stored_state)
30
+ return StoredEvent(
31
+ originator_id=domain_event.originator_id,
32
+ originator_version=domain_event.originator_version,
33
+ topic=topic,
34
+ state=stored_state,
35
+ )
36
+
37
+ def to_domain_event(self, stored: StoredEvent) -> DomainEventProtocol:
38
+ stored_state = stored.state
39
+ if self.cipher:
40
+ stored_state = self.cipher.decrypt(stored_state)
41
+ if self.compressor:
42
+ stored_state = self.compressor.decompress(stored_state)
43
+ event_state: Dict[str, Any] = self.transcoder.decode(stored_state)
44
+ cls = resolve_topic(stored.topic)
45
+ return cls(**event_state)
46
+
47
+
48
+ class OrjsonTranscoder(Transcoder):
49
+ def encode(self, obj: Any) -> bytes:
50
+ return orjson.dumps(obj)
51
+
52
+ def decode(self, data: bytes) -> Any:
53
+ return orjson.loads(data)
54
+
55
+ def register(self, _: Transcoding) -> None: # pragma: no cover
56
+ msg = "Please use Pydantic BaseModel"
57
+ raise ProgrammingError(msg)