bestagon 0.6.0__tar.gz
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 bestagon might be problematic. Click here for more details.
- bestagon-0.6.0/PKG-INFO +9 -0
- bestagon-0.6.0/bestagon/adapters/kurrent.py +165 -0
- bestagon-0.6.0/bestagon/adapters/neo4j_db.py +139 -0
- bestagon-0.6.0/bestagon/core/aggregate.py +246 -0
- bestagon-0.6.0/bestagon/core/checkpoint_store.py +45 -0
- bestagon-0.6.0/bestagon/core/event_processor.py +160 -0
- bestagon-0.6.0/bestagon/core/event_store.py +111 -0
- bestagon-0.6.0/bestagon/core/exceptions.py +38 -0
- bestagon-0.6.0/bestagon/core/mapper.py +141 -0
- bestagon-0.6.0/bestagon/core/message.py +21 -0
- bestagon-0.6.0/bestagon/core/repository.py +73 -0
- bestagon-0.6.0/bestagon/core/system.py +155 -0
- bestagon-0.6.0/bestagon.egg-info/PKG-INFO +9 -0
- bestagon-0.6.0/bestagon.egg-info/SOURCES.txt +16 -0
- bestagon-0.6.0/bestagon.egg-info/dependency_links.txt +1 -0
- bestagon-0.6.0/bestagon.egg-info/top_level.txt +1 -0
- bestagon-0.6.0/setup.cfg +4 -0
- bestagon-0.6.0/setup.py +9 -0
bestagon-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Union, Sequence, cast, Tuple
|
|
5
|
+
|
|
6
|
+
import grpc
|
|
7
|
+
from kurrentdbclient import StreamState, NewEvent, DEFAULT_EXCLUDE_FILTER, AsyncKurrentDBClient, AsyncCatchupSubscription
|
|
8
|
+
from kurrentdbclient.common import DEFAULT_WINDOW_SIZE, DEFAULT_CHECKPOINT_INTERVAL_MULTIPLIER
|
|
9
|
+
from kurrentdbclient.exceptions import NotFoundError
|
|
10
|
+
|
|
11
|
+
from bestagon.core.event_store import EventStore, SubscriptionParameters, EventStoreSubscription, StreamEvent, \
|
|
12
|
+
NewStreamEvent
|
|
13
|
+
from bestagon.core.exceptions import IntegrityError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class KurrentDBSubscriptionParameters(SubscriptionParameters):
|
|
21
|
+
commit_position: Union[int, None] = None
|
|
22
|
+
from_end: bool = False
|
|
23
|
+
resolve_links: bool = False
|
|
24
|
+
filter_exclude: Sequence[str] = DEFAULT_EXCLUDE_FILTER
|
|
25
|
+
filter_include: Sequence[str] = ()
|
|
26
|
+
filter_by_stream_name: bool = False
|
|
27
|
+
include_checkpoints: bool = False
|
|
28
|
+
window_size: int = DEFAULT_WINDOW_SIZE
|
|
29
|
+
checkpoint_interval_multiplier: int = DEFAULT_CHECKPOINT_INTERVAL_MULTIPLIER
|
|
30
|
+
include_caught_up: bool = False
|
|
31
|
+
include_fell_behind: bool = False
|
|
32
|
+
timeout: Union[float, None] = None
|
|
33
|
+
credentials: Union[grpc.CallCredentials, None] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncKurrentDBSubscription(EventStoreSubscription):
|
|
37
|
+
def __init__(self, name: str, parameters: KurrentDBSubscriptionParameters, kdb_subscription: AsyncCatchupSubscription):
|
|
38
|
+
super().__init__(name=name, parameters=parameters)
|
|
39
|
+
self._kdb_subscription = kdb_subscription
|
|
40
|
+
|
|
41
|
+
async def next_event(self) -> StreamEvent:
|
|
42
|
+
if not self.running:
|
|
43
|
+
raise StopAsyncIteration
|
|
44
|
+
|
|
45
|
+
event = await anext(self._kdb_subscription)
|
|
46
|
+
stream_event = StreamEvent(
|
|
47
|
+
stream_name=event.stream_name,
|
|
48
|
+
stream_position=event.stream_position,
|
|
49
|
+
commit_position=event.commit_position,
|
|
50
|
+
event_type=event.type,
|
|
51
|
+
payload=event.data,
|
|
52
|
+
metadata=event.metadata
|
|
53
|
+
)
|
|
54
|
+
return stream_event
|
|
55
|
+
|
|
56
|
+
async def stop(self) -> None:
|
|
57
|
+
if self.running:
|
|
58
|
+
self._running = False
|
|
59
|
+
await self._kdb_subscription.stop()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AsyncKurrentDBEventStore(EventStore):
|
|
63
|
+
def __init__(self, client: AsyncKurrentDBClient):
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.client = client
|
|
66
|
+
|
|
67
|
+
async def append_events(self, stream_name: str, events: Tuple[NewStreamEvent]) -> None:
|
|
68
|
+
current_version = await self.client.get_current_version(stream_name=stream_name)
|
|
69
|
+
first_event = events[0]
|
|
70
|
+
if current_version == StreamState.NO_STREAM:
|
|
71
|
+
if first_event.stream_position != 0:
|
|
72
|
+
raise IntegrityError('Failed to append event to non existent stream, events position should start from 0')
|
|
73
|
+
elif first_event.stream_position <= current_version:
|
|
74
|
+
raise IntegrityError(f'Failed to append events in stream {stream_name} - event with version {first_event.stream_position} already exists in database')
|
|
75
|
+
|
|
76
|
+
new_events = list()
|
|
77
|
+
for event in events:
|
|
78
|
+
new_event = NewEvent(
|
|
79
|
+
type=event.event_type,
|
|
80
|
+
data=event.payload,
|
|
81
|
+
metadata=event.metadata
|
|
82
|
+
)
|
|
83
|
+
new_events.append(new_event)
|
|
84
|
+
|
|
85
|
+
await self.client.append_events(stream_name=stream_name, current_version=current_version, events=new_events)
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
if self. subscriptions:
|
|
89
|
+
await asyncio.gather(*[sub.stop() for sub in self.subscriptions])
|
|
90
|
+
logger.info('All subscriptions stopped')
|
|
91
|
+
|
|
92
|
+
await self.client.close()
|
|
93
|
+
logger.info('Event store closed')
|
|
94
|
+
|
|
95
|
+
async def connect(self) -> None:
|
|
96
|
+
await self.client.connect()
|
|
97
|
+
logger.info('Event store connected')
|
|
98
|
+
|
|
99
|
+
async def create_subscription(self, subscription_name: str,
|
|
100
|
+
subscription_parameters: KurrentDBSubscriptionParameters) -> AsyncKurrentDBSubscription:
|
|
101
|
+
kdb_subscription = await self.client.subscribe_to_all(
|
|
102
|
+
commit_position=subscription_parameters.commit_position,
|
|
103
|
+
from_end=subscription_parameters.from_end,
|
|
104
|
+
resolve_links=subscription_parameters.resolve_links,
|
|
105
|
+
filter_exclude=subscription_parameters.filter_exclude,
|
|
106
|
+
filter_include=subscription_parameters.filter_include,
|
|
107
|
+
filter_by_stream_name=subscription_parameters.filter_by_stream_name,
|
|
108
|
+
include_checkpoints=subscription_parameters.include_checkpoints,
|
|
109
|
+
window_size=subscription_parameters.window_size,
|
|
110
|
+
checkpoint_interval_multiplier=subscription_parameters.checkpoint_interval_multiplier,
|
|
111
|
+
include_caught_up=subscription_parameters.include_caught_up,
|
|
112
|
+
include_fell_behind=subscription_parameters.include_fell_behind,
|
|
113
|
+
timeout=subscription_parameters.timeout,
|
|
114
|
+
credentials=subscription_parameters.credentials
|
|
115
|
+
)
|
|
116
|
+
kdb_subscription = cast(AsyncCatchupSubscription, kdb_subscription)
|
|
117
|
+
|
|
118
|
+
subscription = AsyncKurrentDBSubscription(name=subscription_name, parameters=subscription_parameters, kdb_subscription=kdb_subscription)
|
|
119
|
+
self._subscriptions.append(subscription)
|
|
120
|
+
return subscription
|
|
121
|
+
|
|
122
|
+
async def create_subscription_to_all(self, subscription_name: str, start_position: int) -> 'EventStoreSubscription':
|
|
123
|
+
params = KurrentDBSubscriptionParameters(commit_position=start_position)
|
|
124
|
+
sub = await self.create_subscription(subscription_name=subscription_name, subscription_parameters=params)
|
|
125
|
+
return sub
|
|
126
|
+
|
|
127
|
+
async def create_subscription_to_events(self, subscription_name: str, events: List[str],
|
|
128
|
+
start_position: int) -> 'EventStoreSubscription':
|
|
129
|
+
params = KurrentDBSubscriptionParameters(
|
|
130
|
+
commit_position=start_position,
|
|
131
|
+
filter_include=events,
|
|
132
|
+
filter_by_stream_name=False
|
|
133
|
+
)
|
|
134
|
+
sub = await self.create_subscription(subscription_name=subscription_name, subscription_parameters=params)
|
|
135
|
+
return sub
|
|
136
|
+
|
|
137
|
+
async def create_subscription_to_stream(self, subscription_name: str, regex: str, start_position: int) -> EventStoreSubscription:
|
|
138
|
+
params = KurrentDBSubscriptionParameters(
|
|
139
|
+
commit_position=start_position,
|
|
140
|
+
filter_include=[regex],
|
|
141
|
+
filter_by_stream_name=True
|
|
142
|
+
)
|
|
143
|
+
return await self.create_subscription(subscription_name=subscription_name, subscription_parameters=params)
|
|
144
|
+
|
|
145
|
+
async def get_stream(self, stream_name: str) -> Tuple[StreamEvent]:
|
|
146
|
+
events = await self.client.get_stream(stream_name=stream_name)
|
|
147
|
+
stream_events = list()
|
|
148
|
+
for event in events:
|
|
149
|
+
stream_event = StreamEvent(
|
|
150
|
+
stream_name=stream_name,
|
|
151
|
+
stream_position=event.stream_position,
|
|
152
|
+
commit_position=event.commit_position,
|
|
153
|
+
event_type=event.type,
|
|
154
|
+
payload=event.data,
|
|
155
|
+
metadata=event.metadata
|
|
156
|
+
)
|
|
157
|
+
stream_events.append(stream_event)
|
|
158
|
+
return tuple(stream_events)
|
|
159
|
+
|
|
160
|
+
async def stream_exists(self, stream_name: str) -> bool:
|
|
161
|
+
try:
|
|
162
|
+
events = await self.client.get_stream(stream_name=stream_name, backwards=True, limit=1)
|
|
163
|
+
return bool(events)
|
|
164
|
+
except NotFoundError:
|
|
165
|
+
return False
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
import neo4j
|
|
7
|
+
|
|
8
|
+
from bestagon.core.checkpoint_store import CheckpointStore, Checkpoint
|
|
9
|
+
from bestagon.core.event_processor import Projection
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Neo4jCheckpointStore(CheckpointStore):
|
|
15
|
+
def __init__(self, driver: neo4j.AsyncDriver, database_name: str):
|
|
16
|
+
self.driver = driver
|
|
17
|
+
self.database_name = database_name
|
|
18
|
+
|
|
19
|
+
async def close(self) -> None:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
async def delete_checkpoint(self, name: str) -> None:
|
|
23
|
+
cypher = '''
|
|
24
|
+
MATCH (c:Checkpoint {name: $name})
|
|
25
|
+
DETACH DELETE c
|
|
26
|
+
'''
|
|
27
|
+
async with self.driver.session(default_access_mode=neo4j.WRITE_ACCESS, database=self.database_name) as sess:
|
|
28
|
+
await sess.run(cypher, name=name)
|
|
29
|
+
logger.info(f'Checkpoint {name} deleted')
|
|
30
|
+
|
|
31
|
+
async def get_checkpoint(self, name: str) -> Checkpoint:
|
|
32
|
+
cypher = '''
|
|
33
|
+
MATCH (c:Checkpoint {name: $name})
|
|
34
|
+
RETURN c.value
|
|
35
|
+
'''
|
|
36
|
+
async with self.driver.session(database=self.database_name, default_access_mode=neo4j.READ_ACCESS) as sess:
|
|
37
|
+
result = await sess.run(
|
|
38
|
+
cypher, # NOQA
|
|
39
|
+
name=name
|
|
40
|
+
)
|
|
41
|
+
value = await result.single()
|
|
42
|
+
if value is not None:
|
|
43
|
+
value = value.value()
|
|
44
|
+
else:
|
|
45
|
+
value = 0
|
|
46
|
+
|
|
47
|
+
checkpoint = Checkpoint(name=name, value=value)
|
|
48
|
+
return checkpoint
|
|
49
|
+
|
|
50
|
+
async def initialize(self) -> None:
|
|
51
|
+
create_cypher = "CREATE DATABASE $database_name IF NOT EXISTS"
|
|
52
|
+
async with self.driver.session() as sess:
|
|
53
|
+
await sess.run(
|
|
54
|
+
create_cypher, # NOQA
|
|
55
|
+
database_name=self.database_name
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
index_cypher = '''
|
|
59
|
+
CREATE RANGE INDEX checkpoint_name_index
|
|
60
|
+
IF NOT EXISTS
|
|
61
|
+
FOR (n:Checkpoint)
|
|
62
|
+
ON n.name
|
|
63
|
+
'''
|
|
64
|
+
async with self.driver.session(database=self.database_name) as sess:
|
|
65
|
+
await sess.run(index_cypher) # NOQA
|
|
66
|
+
|
|
67
|
+
async def list_checkpoints(self) -> Tuple[Checkpoint, ...]:
|
|
68
|
+
cypher = '''
|
|
69
|
+
MATCH (c:Checkpoint)
|
|
70
|
+
WITH {
|
|
71
|
+
name: c.name,
|
|
72
|
+
value: c.value
|
|
73
|
+
} AS data
|
|
74
|
+
RETURN data
|
|
75
|
+
'''
|
|
76
|
+
async with self.driver.session(default_access_mode=neo4j.READ_ACCESS, database=self.database_name) as sess:
|
|
77
|
+
result = await sess.run(cypher)
|
|
78
|
+
data = await result.value()
|
|
79
|
+
checkpoints = tuple(Checkpoint(**datum) for datum in data)
|
|
80
|
+
return checkpoints
|
|
81
|
+
|
|
82
|
+
async def set_checkpoint(self, checkpoint: Checkpoint) -> None:
|
|
83
|
+
cypher = '''
|
|
84
|
+
MERGE (c:Checkpoint {name: $name})
|
|
85
|
+
SET c.value = $value
|
|
86
|
+
'''
|
|
87
|
+
|
|
88
|
+
async with self.driver.session(database=self.database_name, default_access_mode=neo4j.WRITE_ACCESS) as sess:
|
|
89
|
+
await sess.run(
|
|
90
|
+
cypher, # NOQA
|
|
91
|
+
name=checkpoint.name,
|
|
92
|
+
value=checkpoint.value
|
|
93
|
+
)
|
|
94
|
+
logger.debug(f'New checkpoint set for {checkpoint.name}: {checkpoint.value}')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Neo4jProjection(Projection):
|
|
98
|
+
def __init__(self, driver: neo4j.AsyncDriver, checkpoint_store: CheckpointStore):
|
|
99
|
+
super().__init__(checkpoint_store=checkpoint_store)
|
|
100
|
+
self.driver = driver
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def database_name(self) -> str:
|
|
104
|
+
return self.get_database_name()
|
|
105
|
+
|
|
106
|
+
async def drop(self) -> None:
|
|
107
|
+
# TODO - indexes
|
|
108
|
+
logger.info(f'Dropping projection {self.name}...')
|
|
109
|
+
cypher = '''CREATE OR REPLACE DATABASE $database_name'''
|
|
110
|
+
check_cypher = '''
|
|
111
|
+
SHOW DATABASES
|
|
112
|
+
YIELD name, currentStatus
|
|
113
|
+
WHERE name = $database_name
|
|
114
|
+
RETURN currentStatus AS status
|
|
115
|
+
'''
|
|
116
|
+
async with self.driver.session() as sess:
|
|
117
|
+
await sess.run(cypher, database_name=self.database_name)
|
|
118
|
+
|
|
119
|
+
logger.info('Checking database created')
|
|
120
|
+
while True:
|
|
121
|
+
result = await sess.run(check_cypher, database_name=self.database_name)
|
|
122
|
+
record = await result.single()
|
|
123
|
+
status = record.value()
|
|
124
|
+
if status != 'online':
|
|
125
|
+
logger.info('Waiting for database')
|
|
126
|
+
await asyncio.sleep(1)
|
|
127
|
+
else:
|
|
128
|
+
logger.info(f'Database created')
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def get_database_name(self) -> str:
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
async def initialize(self) -> None:
|
|
136
|
+
# TODO - indexes
|
|
137
|
+
cypher = '''CREATE DATABASE $database_name IF NOT EXISTS'''
|
|
138
|
+
async with self.driver.session() as sess:
|
|
139
|
+
await sess.run(cypher, database_name=self.database_name)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass, asdict
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
from bestagon.core.exceptions import AggregateIDMismatch, AggregateVersionError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class DomainEventMetadata:
|
|
11
|
+
"""
|
|
12
|
+
DO NOT base business decisions on metadata
|
|
13
|
+
The correlation_id of a message always references the identifier of the message it originates from (that is, the parent message).
|
|
14
|
+
The trace_id on the other hand references to the message identifier which started the chain of messages (that is, the root message).
|
|
15
|
+
"""
|
|
16
|
+
# TODO - add correlation_id
|
|
17
|
+
# TODO - add trace_id
|
|
18
|
+
# TODO - ORLY - inherit dict and use getters to get aggregate_id, version etc.
|
|
19
|
+
|
|
20
|
+
timestamp: str # TODO - turn it into field to create automatically # TODO - also can be called audit_time - just a date and time when event occured
|
|
21
|
+
aggregate_id: str
|
|
22
|
+
aggregate_version: int
|
|
23
|
+
aggregate_type: str
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def create_timestamp() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class DomainEvent:
|
|
32
|
+
# TODO - make things simple - event should contain metadata and data (separate class)
|
|
33
|
+
metadata: DomainEventMetadata
|
|
34
|
+
|
|
35
|
+
def get_metadata_as_dict(self) -> dict:
|
|
36
|
+
# TODO - ORLY???
|
|
37
|
+
return asdict(self.metadata)
|
|
38
|
+
|
|
39
|
+
def get_payload(self) -> dict:
|
|
40
|
+
# TODO - ORLY???
|
|
41
|
+
payload = asdict(self).copy()
|
|
42
|
+
payload.pop('metadata')
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Created(DomainEvent):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Aggregate(ABC):
|
|
52
|
+
"""
|
|
53
|
+
According to Eric Evans book Domain Driven Design: Tackling Complexity in the Heart of Software:
|
|
54
|
+
An AGGREGATE is a cluster of associated objects that we treat as a unit for the purpose of data changes.
|
|
55
|
+
|
|
56
|
+
The class is an implementation of an Event Sourced Aggregate.
|
|
57
|
+
Each event sourced aggregate contains a set of predefined domain events, which discovered during modelling stage using event storming or other knowledge crunching method.
|
|
58
|
+
|
|
59
|
+
# Aggregate creation:
|
|
60
|
+
Event sourced aggregate starts its life cycle from 'Created' event. The only right way to do it currenty is to define factory method on the aggregate, this method
|
|
61
|
+
should create necessary event and then use '_create' method to instantiate aggregate.
|
|
62
|
+
|
|
63
|
+
For example, imagine you have a ShoppingCart aggregate, according to ubiquitious language of your domain it is 'created', so you can create a factory method, called 'create'
|
|
64
|
+
for the specified aggregate:
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create(cls, param_1, param_2) -> 'ShoppingCart':
|
|
68
|
+
metadata = DomainEventMetadata() # Details omitted for simplicity
|
|
69
|
+
event = cls.Created(metadata, param_1, param_2)
|
|
70
|
+
obj = cls._create(event)
|
|
71
|
+
return obj
|
|
72
|
+
|
|
73
|
+
# Consequtive events:
|
|
74
|
+
In addition to 'Created' event, aggregate can have 0 or more events that can happen after creation of aggregate. Each of these events change aggregate state in some way.
|
|
75
|
+
For example, if we talk about shopping cart, then we can 'add_item' to it, so there will be a method on aggregate:
|
|
76
|
+
|
|
77
|
+
def add_item(command: AddShoppingCartItem) -> None:
|
|
78
|
+
# First, you perform validation of input parameters (whether it is a command, entity or just a set of parameters)
|
|
79
|
+
self.validate_command(command)
|
|
80
|
+
|
|
81
|
+
# Second, you execute business logic to figure out what have happened
|
|
82
|
+
# Business logic code in here
|
|
83
|
+
|
|
84
|
+
# Third - you create an Event which records what have happened
|
|
85
|
+
event = self.ItemAdded(
|
|
86
|
+
metadata=metadata,
|
|
87
|
+
item_name=command.item_name
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Finally - you trigger event to launch change of state process
|
|
91
|
+
sef.trigger_event(event)
|
|
92
|
+
|
|
93
|
+
When you trigger event, the aggregate calls 'mutate' method which validates the event and passes it to 'apply_event' method.
|
|
94
|
+
This method must know how to change the state of the aggregate and this is your responsibility to make it happen by reimplementing 'apply_event' method.
|
|
95
|
+
As a rule of thumb there is a simple way to do it:
|
|
96
|
+
|
|
97
|
+
def apply_event(event) -> None:
|
|
98
|
+
# 1. Define routing from event type to event handler
|
|
99
|
+
routing = {
|
|
100
|
+
self.ItemAdded: self._when_item_added,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
event_handler = routing.get(type(event)) # 2. Take the corresponding event handler from routing
|
|
104
|
+
if event_handler is None:
|
|
105
|
+
super().apply_event(event) # Default implementation will raise error if there is no handler
|
|
106
|
+
else:
|
|
107
|
+
event_handler(event) # Handle event
|
|
108
|
+
|
|
109
|
+
Event handler should change the state of the aggregate:
|
|
110
|
+
|
|
111
|
+
def _when_item_added(event) -> None:
|
|
112
|
+
self._items.append(event.event_name)
|
|
113
|
+
|
|
114
|
+
ACHTUNG - your event handlers MUST NOT contain any business logic, it only should change the state of the aggregate based on what is stated in the event.
|
|
115
|
+
|
|
116
|
+
Next step - application module.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
# TODO - register aggregate type automatically
|
|
120
|
+
# TODO - event handlers take events as input and change the aggregate state
|
|
121
|
+
# TODO - command handlers take commands as input, validate business logic and trigger events
|
|
122
|
+
# TODO - aggregate state changes should occur only in event handlers, NOT command handlers, how to protect aggregate from occasional state change in command handler???
|
|
123
|
+
|
|
124
|
+
INITIAL_VERSION = 0
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Created(Created):
|
|
128
|
+
"""Use it as a base class for Created events"""
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Event(DomainEvent):
|
|
133
|
+
"""Use it as a base class for consequtive events"""
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def __init__(self, event: Created):
|
|
137
|
+
self._aggregate_id = event.metadata.aggregate_id
|
|
138
|
+
self._aggregate_version = event.metadata.aggregate_version
|
|
139
|
+
self._aggregate_created_on = datetime.fromisoformat(event.metadata.timestamp)
|
|
140
|
+
self._aggregate_modified_on = datetime.fromisoformat(event.metadata.timestamp)
|
|
141
|
+
|
|
142
|
+
self._pending_events: List[DomainEvent] = list()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def aggregate_created_on(self) -> datetime:
|
|
146
|
+
"""The date and time when the aggregate was created."""
|
|
147
|
+
return self._aggregate_created_on
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def aggregate_id(self) -> str:
|
|
151
|
+
return self._aggregate_id
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def aggregate_modified_on(self) -> datetime:
|
|
155
|
+
"""The date and time when the aggregate was modified."""
|
|
156
|
+
return self._aggregate_modified_on
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def aggregate_type(self) -> str:
|
|
160
|
+
return self.get_aggregate_type()
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def aggregate_version(self) -> int:
|
|
164
|
+
return self._aggregate_version
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def next_version(self) -> int:
|
|
168
|
+
"""Convenience property to get the next version number of aggregate"""
|
|
169
|
+
return self.aggregate_version + 1
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def pending_events(self) -> Tuple[DomainEvent, ...]:
|
|
173
|
+
return tuple(self._pending_events)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def _create(cls, event: 'Created') -> 'Aggregate':
|
|
177
|
+
"""Actually creates new aggregate. Should be used by factory method implemented on specific aggregate instance."""
|
|
178
|
+
obj = cls(event=event)
|
|
179
|
+
obj._pending_events.append(event)
|
|
180
|
+
return obj
|
|
181
|
+
|
|
182
|
+
def apply_event(self, event: DomainEvent) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Reimplement to provide change of state of the aggregate when event occur.
|
|
185
|
+
Each event must have the associated event handler.
|
|
186
|
+
"""
|
|
187
|
+
raise TypeError(f'Unknown Event {type(event)}')
|
|
188
|
+
|
|
189
|
+
def clear_events(self) -> None:
|
|
190
|
+
"""Clears all pending events on the Aggregate"""
|
|
191
|
+
self._pending_events = list()
|
|
192
|
+
|
|
193
|
+
def collect_events(self) -> List[DomainEvent]:
|
|
194
|
+
"""Returns the list of pending events in the aggregate and clears pending events"""
|
|
195
|
+
events = self._pending_events
|
|
196
|
+
self.clear_events()
|
|
197
|
+
return events
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
@abstractmethod
|
|
201
|
+
def create_id(*args, **kwargs) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Reimplement to create ID of the aggregate.
|
|
204
|
+
Aggregate ID MUST BE UNIQUE accross the same aggregate type.
|
|
205
|
+
"""
|
|
206
|
+
raise NotImplementedError
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
@abstractmethod
|
|
210
|
+
def get_aggregate_type() -> str:
|
|
211
|
+
"""
|
|
212
|
+
Aggregate type should be defined during modelling stage and MUST NOT BE CHANGED during the entire aggregate lifecycle.
|
|
213
|
+
It is used by repository to retreive specific aggregate instances and by event store as prefix to event stream.
|
|
214
|
+
"""
|
|
215
|
+
raise NotImplementedError
|
|
216
|
+
|
|
217
|
+
def mutate(self, event: DomainEvent) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Performs validation of the event and changes the state of the aggregate by calling 'apply_event'.
|
|
220
|
+
After application of the event modifies the version and modification date of the aggregate.
|
|
221
|
+
|
|
222
|
+
Validation:
|
|
223
|
+
- Event.aggregate_id should be equal to Aggregate.id. Raises AggregateIdError if not.
|
|
224
|
+
- Event.aggregate_version should be EXACTLY ONE more than Aggregate.version. Raises AggregateVersionError if not.
|
|
225
|
+
"""
|
|
226
|
+
# Event MUST belong to the aggregate
|
|
227
|
+
if self.aggregate_id != event.metadata.aggregate_id:
|
|
228
|
+
raise AggregateIDMismatch(f'ERROR - {event.__class__.__qualname__} aggregate_id does not match Aggregate ID attribute.')
|
|
229
|
+
# Version of the new event MUST BE EXACTLY ONE MORE than the current aggegate version
|
|
230
|
+
if (event.metadata.aggregate_version - self.aggregate_version) != 1:
|
|
231
|
+
raise AggregateVersionError(f'ERROR - event aggregate version should be exactly one more than the aggregate version.')
|
|
232
|
+
|
|
233
|
+
# Change the state of Aggregate
|
|
234
|
+
self.apply_event(event)
|
|
235
|
+
|
|
236
|
+
# Record new version and modification date
|
|
237
|
+
self._aggregate_version = event.metadata.aggregate_version
|
|
238
|
+
self._aggregate_modified_on = event.metadata.timestamp
|
|
239
|
+
|
|
240
|
+
def trigger_event(self, event: Event) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Should be called whenever new event occur during aggregate lifecycle.
|
|
243
|
+
Mutates aggregate state and adds event to the list of pending events
|
|
244
|
+
"""
|
|
245
|
+
self.mutate(event)
|
|
246
|
+
self._pending_events.append(event)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Checkpoint:
|
|
8
|
+
name: str
|
|
9
|
+
value: int
|
|
10
|
+
|
|
11
|
+
def __post_init__(self):
|
|
12
|
+
if not isinstance(self.name, str):
|
|
13
|
+
raise TypeError('Checkpoint name should a string')
|
|
14
|
+
if not self.name:
|
|
15
|
+
raise ValueError('Checkpoint name should be a non empty string')
|
|
16
|
+
if not isinstance(self.value, int):
|
|
17
|
+
raise TypeError('Checkpoint value should be an integer')
|
|
18
|
+
if self.value < 0:
|
|
19
|
+
raise ValueError('Checkpoint value should be a positive integer')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CheckpointStore(ABC):
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def close(self) -> None:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def delete_checkpoint(self, name: str) -> None:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def get_checkpoint(self, name: str) -> Checkpoint:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def initialize(self) -> None:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def list_checkpoints(self) -> Tuple[Checkpoint, ...]:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def set_checkpoint(self, checkpoint: Checkpoint) -> None:
|
|
45
|
+
raise NotImplementedError
|