neuroglia-python 0.0.23__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.
- neuroglia/__init__.py +0 -0
- neuroglia/core/__init__.py +4 -0
- neuroglia/core/module_loader.py +13 -0
- neuroglia/core/operation_result.py +11 -0
- neuroglia/core/problem_details.py +25 -0
- neuroglia/core/type_extensions.py +75 -0
- neuroglia/core/type_finder.py +24 -0
- neuroglia/data/__init__.py +1 -0
- neuroglia/data/abstractions.py +124 -0
- neuroglia/data/infrastructure/__init__.py +0 -0
- neuroglia/data/infrastructure/abstractions.py +78 -0
- neuroglia/data/infrastructure/event_sourcing/__init__.py +0 -0
- neuroglia/data/infrastructure/event_sourcing/abstractions.py +126 -0
- neuroglia/data/infrastructure/event_sourcing/event_sourcing_repository.py +77 -0
- neuroglia/data/infrastructure/event_sourcing/event_store/__init__.py +0 -0
- neuroglia/data/infrastructure/event_sourcing/event_store/event_store.py +175 -0
- neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +62 -0
- neuroglia/data/infrastructure/memory/__init__.py +1 -0
- neuroglia/data/infrastructure/memory/memory_repository.py +29 -0
- neuroglia/data/infrastructure/mongo/__init__.py +0 -0
- neuroglia/data/infrastructure/mongo/mongo_repository.py +331 -0
- neuroglia/data/queries/generic/__init__.py +2 -0
- neuroglia/data/queries/generic/get_by_id_query.py +30 -0
- neuroglia/data/queries/generic/list_query.py +23 -0
- neuroglia/data/queryable.py +175 -0
- neuroglia/dependency_injection/__init__.py +1 -0
- neuroglia/dependency_injection/service_provider.py +341 -0
- neuroglia/eventing/cloud_events/__init__.py +1 -0
- neuroglia/eventing/cloud_events/cloud_event.py +72 -0
- neuroglia/eventing/cloud_events/decorators.py +5 -0
- neuroglia/eventing/cloud_events/infrastructure/__init__.py +4 -0
- neuroglia/eventing/cloud_events/infrastructure/cloud_event_bus.py +11 -0
- neuroglia/eventing/cloud_events/infrastructure/cloud_event_ingestor.py +74 -0
- neuroglia/eventing/cloud_events/infrastructure/cloud_event_middleware.py +39 -0
- neuroglia/eventing/cloud_events/infrastructure/cloud_event_publisher.py +103 -0
- neuroglia/expressions/__init__.py +0 -0
- neuroglia/expressions/javascript_expression_translator.py +147 -0
- neuroglia/hosting/__init__.py +0 -0
- neuroglia/hosting/abstractions.py +141 -0
- neuroglia/hosting/configuration/data_access_layer.py +50 -0
- neuroglia/hosting/web.py +87 -0
- neuroglia/integration/__init__.py +0 -0
- neuroglia/integration/models.py +18 -0
- neuroglia/mapping/__init__.py +1 -0
- neuroglia/mapping/mapper.py +238 -0
- neuroglia/mediation/__init__.py +1 -0
- neuroglia/mediation/mediator.py +182 -0
- neuroglia/mvc/__init__.py +0 -0
- neuroglia/mvc/controller_base.py +72 -0
- neuroglia/reactive/__init__.py +1 -0
- neuroglia/reactive/rx_async.py +16 -0
- neuroglia/serialization/__init__.py +0 -0
- neuroglia/serialization/abstractions.py +30 -0
- neuroglia/serialization/json.py +212 -0
- neuroglia_python-0.0.23.dist-info/LICENSE +201 -0
- neuroglia_python-0.0.23.dist-info/METADATA +127 -0
- neuroglia_python-0.0.23.dist-info/RECORD +58 -0
- neuroglia_python-0.0.23.dist-info/WHEEL +4 -0
neuroglia/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import sys
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModuleLoader:
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def load(module_name: str) -> ModuleType:
|
|
10
|
+
module = sys.modules.get(module_name, None)
|
|
11
|
+
if module is None:
|
|
12
|
+
module = importlib.import_module(module_name)
|
|
13
|
+
return module
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Generic, Optional, TypeVar
|
|
2
|
+
from neuroglia.core.problem_details import ProblemDetails
|
|
3
|
+
|
|
4
|
+
TData = TypeVar('TData')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OperationResult(Generic[TData], ProblemDetails):
|
|
8
|
+
''' Represents the result of an operation '''
|
|
9
|
+
|
|
10
|
+
data: Optional[TData]
|
|
11
|
+
''' Gets the data, if any, returned by the operation in case of success '''
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclasses.dataclass
|
|
6
|
+
class ProblemDetails:
|
|
7
|
+
'''Represents the structure of a Problem Details response (RFC 7807).'''
|
|
8
|
+
|
|
9
|
+
title: str
|
|
10
|
+
'''A short, human-readable summary of the problem.'''
|
|
11
|
+
|
|
12
|
+
status: int
|
|
13
|
+
'''The HTTP status code for this occurrence of the problem.'''
|
|
14
|
+
|
|
15
|
+
detail: Optional[str] = None
|
|
16
|
+
'''A human-readable explanation specific to this occurrence of the problem.'''
|
|
17
|
+
|
|
18
|
+
type: Optional[str] = None
|
|
19
|
+
'''A URI reference that identifies the problem type.'''
|
|
20
|
+
|
|
21
|
+
instance: Optional[str] = None
|
|
22
|
+
'''A URI reference that identifies the specific occurrence of the problem.'''
|
|
23
|
+
|
|
24
|
+
def is_success_status_code(self) -> bool: self.status >= 200 and self.status < 300
|
|
25
|
+
''' Determines whether the operation is successfull or not '''
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Dict, List, Type, TypeVar
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TypeExtensions:
|
|
5
|
+
|
|
6
|
+
@staticmethod
|
|
7
|
+
def get_generic_implementation(type_: Type, generic_type_definition: Type, generic_args: Dict[str, Type] = None):
|
|
8
|
+
base_types = TypeExtensions._get_base_types(type_)
|
|
9
|
+
base_types = [base for base in base_types if (issubclass(base.__origin__, generic_type_definition) if hasattr(base, "__origin__") else issubclass(base, generic_type_definition))]
|
|
10
|
+
base_type = next((base for base in base_types if base == generic_type_definition or (hasattr(base, "__origin__")) and base.__origin__ == generic_type_definition), None)
|
|
11
|
+
if hasattr(type_, "__orig_class__") and hasattr(type_.__orig_class__.__origin__, "__parameters__"):
|
|
12
|
+
if generic_args is None:
|
|
13
|
+
generic_args = dict[str, Type]()
|
|
14
|
+
for i in range(len(type_.__orig_class__.__origin__.__parameters__)):
|
|
15
|
+
name = type_.__orig_class__.__origin__.__parameters__[i].__name__
|
|
16
|
+
arg_type = type_.__orig_class__.__args__[i]
|
|
17
|
+
generic_args[name] = arg_type
|
|
18
|
+
if (hasattr(type_, "__origin__") and hasattr(type_.__origin__, "__parameters__")):
|
|
19
|
+
if generic_args is None:
|
|
20
|
+
generic_args = dict[str, Type]()
|
|
21
|
+
for i in range(len(type_.__origin__.__parameters__)):
|
|
22
|
+
name = type_.__origin__.__parameters__[i].__name__
|
|
23
|
+
arg_type = type_.__args__[i]
|
|
24
|
+
generic_args[name] = arg_type
|
|
25
|
+
if base_type is not None:
|
|
26
|
+
return TypeExtensions._substitute_generic_arguments(base_type, generic_args)
|
|
27
|
+
else:
|
|
28
|
+
return next((implementation_type for implementation_type in [TypeExtensions.get_generic_implementation(base, generic_type_definition, generic_args) for base in base_types]), None)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def get_generic_arguments(type_: Type, generic_arguments: Dict[str, Type] = None) -> Dict[str, Type]:
|
|
32
|
+
if hasattr(type_, "__orig_class__") and hasattr(type_.__orig_class__.__origin__, "__parameters__"):
|
|
33
|
+
if generic_arguments is None:
|
|
34
|
+
generic_arguments = dict[str, Type]()
|
|
35
|
+
for i in range(len(type_.__orig_class__.__origin__.__parameters__)):
|
|
36
|
+
name = type_.__orig_class__.__origin__.__parameters__[i].__name__
|
|
37
|
+
arg_type = type_.__orig_class__.__args__[i]
|
|
38
|
+
generic_arguments[name] = TypeExtensions._substitute_generic_arguments(arg_type, generic_arguments)
|
|
39
|
+
if (hasattr(type_, "__origin__") and hasattr(type_.__origin__, "__parameters__")):
|
|
40
|
+
if generic_arguments is None:
|
|
41
|
+
generic_arguments = dict[str, Type]()
|
|
42
|
+
for i in range(len(type_.__origin__.__parameters__)):
|
|
43
|
+
name = type_.__origin__.__parameters__[i].__name__
|
|
44
|
+
arg_type = type_.__args__[i]
|
|
45
|
+
generic_arguments[name] = TypeExtensions._substitute_generic_arguments(arg_type, generic_arguments)
|
|
46
|
+
for base_type in TypeExtensions._get_base_types(type_):
|
|
47
|
+
generic_arguments = TypeExtensions.get_generic_arguments(base_type, generic_arguments)
|
|
48
|
+
return generic_arguments
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def get_generic_argument(type_: Type, name: str) -> Type | None:
|
|
52
|
+
generic_arguments = TypeExtensions.get_generic_arguments(type_)
|
|
53
|
+
return generic_arguments.get(name, None)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _get_base_types(type_: Type) -> List[Type]:
|
|
57
|
+
if hasattr(type_, "__origin__") and hasattr(type_.__origin__, "__orig_bases__"):
|
|
58
|
+
return type_.__origin__.__orig_bases__
|
|
59
|
+
elif hasattr(type_, "__orig_bases__"):
|
|
60
|
+
return type_.__orig_bases__
|
|
61
|
+
else:
|
|
62
|
+
return list[Type]()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _substitute_generic_arguments(type_: Type, generic_args: Dict[str, Type] | None = None) -> Type:
|
|
66
|
+
if not hasattr(type_, "__parameters__") and generic_args is not None:
|
|
67
|
+
if isinstance(type_, TypeVar):
|
|
68
|
+
return generic_args.get(type_.__name__, None)
|
|
69
|
+
else:
|
|
70
|
+
return type_
|
|
71
|
+
parameters = [param.__name__ for param in type_.__parameters__]
|
|
72
|
+
type_args = [TypeExtensions._substitute_generic_arguments(generic_arg_value, generic_args) for generic_arg_key, generic_arg_value in generic_args.items() if generic_arg_key in parameters]
|
|
73
|
+
if len(type_args) < 1:
|
|
74
|
+
return type_
|
|
75
|
+
return type_[*type_args]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import pkgutil
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
from typing import Callable, List, Type
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TypeFinder:
|
|
9
|
+
''' Represents an utility class that exposes methods to find and filter types '''
|
|
10
|
+
|
|
11
|
+
def get_types(module: ModuleType, predicate: Callable[[Type], bool] = lambda t: True, include_sub_modules=True, include_sub_packages=False) -> List[Type]:
|
|
12
|
+
''' Recursively finds all types contained in the specified module that match the specified predicate, if any '''
|
|
13
|
+
results: List[Type] = list[Type]()
|
|
14
|
+
results.extend([cls for _, cls in inspect.getmembers(module, inspect.isclass) if predicate(cls)])
|
|
15
|
+
if include_sub_modules:
|
|
16
|
+
for submodule in [submodule for _, submodule in inspect.getmembers(module, inspect.ismodule) if submodule.__name__.startswith(module.__name__)]:
|
|
17
|
+
results.extend(TypeFinder.get_types(submodule, predicate, include_sub_modules, include_sub_packages))
|
|
18
|
+
# todo: the following creates big problems, as it will actually (re)load, thus (re)run, imported packages <= it sucks
|
|
19
|
+
if include_sub_packages:
|
|
20
|
+
if hasattr(module, "__path__"):
|
|
21
|
+
sub_packages = [importlib.import_module(module_name) for _, module_name, _ in pkgutil.walk_packages(module.__path__, module.__name__ + ".") if module_name.startswith(module.__name__)]
|
|
22
|
+
for submodule in sub_packages:
|
|
23
|
+
results.extend(TypeFinder.get_types(submodule, predicate, include_sub_modules, include_sub_packages))
|
|
24
|
+
return list(set(results))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .queryable import *
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Generic, List, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
TKey = TypeVar("TKey")
|
|
6
|
+
''' Represents the generic argument used to specify the type of key to use '''
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Identifiable(Generic[TKey], ABC):
|
|
10
|
+
''' Defines the fundamentals of an object that can be identified based on a unique identifier '''
|
|
11
|
+
|
|
12
|
+
id: TKey
|
|
13
|
+
''' Gets the object's unique identifier '''
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TEntity = TypeVar("TEntity", bound=Identifiable)
|
|
17
|
+
''' Represents the generic argument used to specify the type of entity to use '''
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Entity(Generic[TKey], Identifiable[TKey], ABC):
|
|
21
|
+
''' Represents the abstract class inherited by all entities in the application '''
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.created_at = datetime.now()
|
|
26
|
+
|
|
27
|
+
created_at: datetime
|
|
28
|
+
''' Gets the date and time the entity has been created at '''
|
|
29
|
+
|
|
30
|
+
last_modified: datetime
|
|
31
|
+
''' Gets the date and time the entity was last modified at, if any '''
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VersionedState(ABC):
|
|
35
|
+
''' Represents the abstract class inherited by all versioned states '''
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.state_version = 0
|
|
39
|
+
|
|
40
|
+
state_version: int = 0
|
|
41
|
+
''' Gets the state's version '''
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AggregateState(Generic[TKey], Identifiable[TKey], VersionedState, ABC):
|
|
45
|
+
''' Represents the abstract class inherited by all aggregate root states '''
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
super().__init__()
|
|
49
|
+
|
|
50
|
+
id: TKey
|
|
51
|
+
''' Gets the id of the aggregate the state belongs to '''
|
|
52
|
+
|
|
53
|
+
created_at: datetime
|
|
54
|
+
''' Gets the date and time the aggregate has been created at '''
|
|
55
|
+
|
|
56
|
+
last_modified: datetime
|
|
57
|
+
''' Gets the date and time, if any, the aggregate was last modified at '''
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
TState = TypeVar("TState", bound=AggregateState)
|
|
61
|
+
''' Represents the generic argument used to specify the state of an aggregate root '''
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DomainEvent(Generic[TKey], ABC):
|
|
65
|
+
''' Represents the base class inherited by all domain events '''
|
|
66
|
+
|
|
67
|
+
def __init__(self, aggregate_id: TKey):
|
|
68
|
+
''' Initializes a new domain event '''
|
|
69
|
+
self.created_at = datetime.now()
|
|
70
|
+
self.aggregate_id = aggregate_id
|
|
71
|
+
|
|
72
|
+
created_at: datetime
|
|
73
|
+
''' Gets the date and time the domain event has been created at '''
|
|
74
|
+
|
|
75
|
+
aggregate_id: TKey
|
|
76
|
+
''' Gets the id of the aggregate that has produced the domain event '''
|
|
77
|
+
|
|
78
|
+
aggregate_version: int
|
|
79
|
+
''' Gets the version of the aggregate's state, after reducing the domain event '''
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
TEvent = TypeVar("TEvent", bound=DomainEvent)
|
|
83
|
+
''' Represents the generic argument used to specify the state of an aggregate root '''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AggregateRoot(Generic[TState, TKey], Entity[TKey], ABC):
|
|
87
|
+
''' Represents the base class for all aggregate roots '''
|
|
88
|
+
|
|
89
|
+
_pending_events: List[DomainEvent]
|
|
90
|
+
''' Gets a list containing all domain events pending persistence '''
|
|
91
|
+
|
|
92
|
+
def __init__(self):
|
|
93
|
+
''' Initializes a new aggregate root '''
|
|
94
|
+
self.state = object.__new__(self.__orig_bases__[0].__args__[0])
|
|
95
|
+
self.state.__init__()
|
|
96
|
+
self._pending_events = list[DomainEvent]()
|
|
97
|
+
|
|
98
|
+
def id(self):
|
|
99
|
+
''' Gets the aggregate root's id '''
|
|
100
|
+
return self.state.id
|
|
101
|
+
|
|
102
|
+
state: TState
|
|
103
|
+
''' Gets the aggregate root's state '''
|
|
104
|
+
|
|
105
|
+
def register_event(self, e: TEvent) -> TEvent:
|
|
106
|
+
''' Registers the specified domain event '''
|
|
107
|
+
if not hasattr(self, "_pending_events"):
|
|
108
|
+
self._pending_events = list[DomainEvent]()
|
|
109
|
+
self._pending_events.append(e)
|
|
110
|
+
e.aggregate_version = self.state.state_version + len(self._pending_events)
|
|
111
|
+
return e
|
|
112
|
+
|
|
113
|
+
def clear_pending_events(self):
|
|
114
|
+
''' Clears all pending domain events '''
|
|
115
|
+
self._pending_events.clear()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
TAggregate = TypeVar("TAggregate", bound=AggregateRoot)
|
|
119
|
+
''' Represents the generic argument used to specify an aggregate root type '''
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def queryable(cls):
|
|
123
|
+
cls.__queryable__ = True
|
|
124
|
+
return cls
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Generic, List, Optional
|
|
3
|
+
from neuroglia.data.abstractions import TEntity, TKey
|
|
4
|
+
from neuroglia.data.queryable import Queryable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Repository(Generic[TEntity, TKey], ABC):
|
|
8
|
+
''' Defines the fundamentals of a repository '''
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def contains_async(self, id: TKey) -> bool:
|
|
12
|
+
''' Determines whether or not the repository contains an entity with the specified id '''
|
|
13
|
+
raise NotImplementedError()
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def get_async(self, id: TKey) -> Optional[TEntity]:
|
|
17
|
+
''' Gets the entity with the specified id, if any '''
|
|
18
|
+
raise NotImplementedError()
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def add_async(self, entity: TEntity) -> TEntity:
|
|
22
|
+
''' Adds the specified entity '''
|
|
23
|
+
raise NotImplementedError()
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def update_async(self, entity: TEntity) -> TEntity:
|
|
27
|
+
''' Persists the changes that were made to the specified entity '''
|
|
28
|
+
raise NotImplementedError()
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def remove_async(self, id: TKey) -> None:
|
|
32
|
+
''' Removes the entity with the specified key '''
|
|
33
|
+
raise NotImplementedError()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class QueryableRepository(Generic[TEntity, TKey], Repository[TEntity, TKey], ABC):
|
|
37
|
+
''' Defines the fundamentals of a queryable repository '''
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def query_async(self) -> Queryable[TEntity]:
|
|
41
|
+
raise NotImplementedError()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FlexibleRepository(Generic[TEntity, TKey], Repository[TEntity, TKey], ABC):
|
|
45
|
+
''' Defines the fundamentals of a flexible repository '''
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def set_database(self, database: str) -> None:
|
|
49
|
+
raise NotImplementedError()
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
async def get_database(self) -> str:
|
|
53
|
+
raise NotImplementedError()
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def contains_by_collection_name_async(self, collection_nam: str, id: TKey) -> bool:
|
|
57
|
+
''' Determines whether or not the repository contains an entity with the specified id '''
|
|
58
|
+
raise NotImplementedError()
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def get_by_collection_name_async(self, collection_name: str, id: TKey) -> Optional[TEntity]:
|
|
62
|
+
''' Gets the entity with the specified id, if any '''
|
|
63
|
+
raise NotImplementedError()
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def add_by_collection_name_async(self, collection_name: str, entity: TEntity) -> TEntity:
|
|
67
|
+
''' Adds the specified entity '''
|
|
68
|
+
raise NotImplementedError()
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def update_by_collection_name_async(self, collection_name: str, entity: TEntity) -> TEntity:
|
|
72
|
+
''' Persists the changes that were made to the specified entity '''
|
|
73
|
+
raise NotImplementedError()
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def remove_by_collection_name_async(self, collection_name: str, id: TKey) -> None:
|
|
77
|
+
''' Removes the entity with the specified key '''
|
|
78
|
+
raise NotImplementedError()
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, List, Optional, Type
|
|
6
|
+
from neuroglia.data.abstractions import AggregateRoot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class StreamDescriptor:
|
|
11
|
+
''' Represents a class used to describe a stream of recorded events '''
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
''' Gets the stream's id '''
|
|
15
|
+
|
|
16
|
+
length: int
|
|
17
|
+
''' Gets the stream's length '''
|
|
18
|
+
|
|
19
|
+
first_event_at: Optional[datetime] = None
|
|
20
|
+
''' Gets the date and time at which the first event, if any, has been recorded to the stream '''
|
|
21
|
+
|
|
22
|
+
last_event_at: Optional[datetime] = None
|
|
23
|
+
''' Gets the date and time at which the last event, if any, has been recorded to the stream '''
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class EventDescriptor:
|
|
28
|
+
''' Represents a class used to describe an event to record '''
|
|
29
|
+
|
|
30
|
+
type: str
|
|
31
|
+
''' Gets the type of the event to record '''
|
|
32
|
+
|
|
33
|
+
data: Optional[Any] = None
|
|
34
|
+
''' Gets the data of the event to record '''
|
|
35
|
+
|
|
36
|
+
metadata: Optional[Any] = None
|
|
37
|
+
''' Gets the metadata of the event to record, if any '''
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EventRecord:
|
|
42
|
+
''' Represents a recorded event '''
|
|
43
|
+
|
|
44
|
+
stream_id: str
|
|
45
|
+
''' Gets the id of the stream the recorded event belongs to '''
|
|
46
|
+
|
|
47
|
+
id: str
|
|
48
|
+
''' Gets the id of the recorded event '''
|
|
49
|
+
|
|
50
|
+
offset: int
|
|
51
|
+
''' Gets the offset of the recorded event in the stream it belongs to '''
|
|
52
|
+
|
|
53
|
+
position: int
|
|
54
|
+
''' Gets the position of the recorded event in the global stream '''
|
|
55
|
+
|
|
56
|
+
timestamp: datetime
|
|
57
|
+
''' Gets the date and time at which the event has been recorded '''
|
|
58
|
+
|
|
59
|
+
type: str
|
|
60
|
+
''' Gets the type of the recorded event. Should be a non-versioned reverse uri made out alphanumeric, '-' and '.' characters '''
|
|
61
|
+
|
|
62
|
+
data: Optional[any] = None
|
|
63
|
+
''' Gets the recorded event's data, if any '''
|
|
64
|
+
|
|
65
|
+
metadata: Optional[any] = None
|
|
66
|
+
''' Gets the recorded event's metadadata, if any '''
|
|
67
|
+
|
|
68
|
+
replayed: bool = False
|
|
69
|
+
''' Gets a boolean indicating whether or not the recorded event is being replayed to its consumer/consumer group '''
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class StreamReadDirection(Enum):
|
|
73
|
+
''' Enumerates all directions in which a event sourcing stream can be read '''
|
|
74
|
+
FORWARDS = 0,
|
|
75
|
+
''' Indicates a forwards direction '''
|
|
76
|
+
BACKWARDS = 1
|
|
77
|
+
''' Indicates a backwards direction '''
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class EventStoreOptions:
|
|
82
|
+
|
|
83
|
+
database_name: str
|
|
84
|
+
''' Gets/sets the name of the database to use, if any '''
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class EventStore(ABC):
|
|
88
|
+
''' Defines the fundamentals of a service used to append and subscribe to sourced events '''
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
async def contains_async(self, stream_id: str) -> bool:
|
|
92
|
+
''' Determines whether or not the event store contains a stream with the specified id '''
|
|
93
|
+
raise NotImplementedError()
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
async def append_async(self, streamId: str, events: List[EventDescriptor], expectedVersion: Optional[int] = None):
|
|
97
|
+
''' Appends a list of events to the specified stream '''
|
|
98
|
+
raise NotImplementedError()
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
async def get_async(self, stream_id: str):
|
|
102
|
+
''' Gets information about the specified stream '''
|
|
103
|
+
raise NotImplementedError()
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
async def read_async(self, stream_id: str, read_direction: StreamReadDirection, offset: int, length: Optional[int] = None) -> List[EventRecord]:
|
|
107
|
+
''' Reads recorded events from the specified stream '''
|
|
108
|
+
raise NotImplementedError()
|
|
109
|
+
|
|
110
|
+
async def observe_async(self, stream_id: Optional[str], consumer_group: Optional[str] = None, offset: Optional[int] = None):
|
|
111
|
+
'''
|
|
112
|
+
Creates a new observable used to stream events published by the event store.
|
|
113
|
+
Typically, this is used by some kind of reconciliation mechanism to consume domain events then publish them to their related handlers, if any.
|
|
114
|
+
'''
|
|
115
|
+
raise NotImplementedError()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Aggregator:
|
|
119
|
+
|
|
120
|
+
def aggregate(self, events: List, aggregate_type: Type):
|
|
121
|
+
aggregate: AggregateRoot = object.__new__(aggregate_type)
|
|
122
|
+
aggregate.state = aggregate.__orig_bases__[0].__args__[0]()
|
|
123
|
+
for e in events:
|
|
124
|
+
aggregate.state.on(e.data)
|
|
125
|
+
aggregate.state.state_version = e.data.aggregate_version
|
|
126
|
+
return aggregate
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Generic, Optional, Type
|
|
3
|
+
from neuroglia.data.infrastructure.abstractions import Repository
|
|
4
|
+
from neuroglia.data.abstractions import DomainEvent, TAggregate, TKey
|
|
5
|
+
from neuroglia.data.infrastructure.event_sourcing.abstractions import Aggregator, EventDescriptor, EventStore, StreamReadDirection
|
|
6
|
+
from neuroglia.hosting.abstractions import ApplicationBuilderBase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class EventSourcingRepositoryOptions(Generic[TAggregate, TKey]):
|
|
11
|
+
''' Represents the options used to configure an event sourcing repository '''
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventSourcingRepository(Generic[TAggregate, TKey], Repository[TAggregate, TKey]):
|
|
16
|
+
''' Represents an event sourcing repository implementation '''
|
|
17
|
+
|
|
18
|
+
def __init__(self, eventstore: EventStore, aggregator: Aggregator):
|
|
19
|
+
''' Initialize a new event sourcing repository '''
|
|
20
|
+
self._eventstore = eventstore
|
|
21
|
+
self._aggregator = aggregator
|
|
22
|
+
|
|
23
|
+
_eventstore : EventStore
|
|
24
|
+
''' Gets the underlying event store '''
|
|
25
|
+
|
|
26
|
+
_aggregator : Aggregator
|
|
27
|
+
''' Gets the underlying event store '''
|
|
28
|
+
|
|
29
|
+
async def contains_async(self, id: TKey) -> bool: return self._eventstore.contains_stream(self._build_stream_id_for(id))
|
|
30
|
+
|
|
31
|
+
async def get_async(self, id: TKey) -> Optional[TAggregate]:
|
|
32
|
+
''' Gets the aggregate with the specified id, if any '''
|
|
33
|
+
stream_id = self._build_stream_id_for(id)
|
|
34
|
+
events = await self._eventstore.read_async(stream_id, StreamReadDirection.FORWARDS, 0)
|
|
35
|
+
return self._aggregator.aggregate(events, self.__orig_class__.__args__[0])
|
|
36
|
+
|
|
37
|
+
async def add_async(self, aggregate: TAggregate) -> TAggregate:
|
|
38
|
+
''' Adds and persists the specified aggregate '''
|
|
39
|
+
stream_id = self._build_stream_id_for(aggregate.id())
|
|
40
|
+
events = aggregate._pending_events
|
|
41
|
+
if len(events) < 1 : raise Exception()
|
|
42
|
+
encoded_events = [self._encode_event(e) for e in events]
|
|
43
|
+
await self._eventstore.append_async(stream_id, encoded_events)
|
|
44
|
+
aggregate.state.state_version = events[-1].aggregate_version
|
|
45
|
+
aggregate.clear_pending_events()
|
|
46
|
+
return aggregate
|
|
47
|
+
|
|
48
|
+
async def update_async(self, aggregate: TAggregate) -> TAggregate:
|
|
49
|
+
''' Perists the changes made to the specified aggregate '''
|
|
50
|
+
stream_id = self._build_stream_id_for(aggregate.id())
|
|
51
|
+
events = aggregate._pending_events
|
|
52
|
+
if len(events) < 1 : raise Exception()
|
|
53
|
+
encoded_events = [self._encode_event(e) for e in events]
|
|
54
|
+
await self._eventstore.append_async(stream_id, encoded_events, aggregate.state.state_version)
|
|
55
|
+
aggregate.state.state_version = events[-1].aggregate_version
|
|
56
|
+
aggregate.clear_pending_events()
|
|
57
|
+
return aggregate
|
|
58
|
+
|
|
59
|
+
async def remove_async(self, id: TKey) -> None:
|
|
60
|
+
''' Removes the aggregate root with the specified key, if any '''
|
|
61
|
+
raise NotImplementedError()
|
|
62
|
+
|
|
63
|
+
def _build_stream_id_for(self, aggregate_id : TKey):
|
|
64
|
+
''' Builds a new stream id for the specified aggregate '''
|
|
65
|
+
aggregate_name = self.__orig_class__.__args__[0].__name__
|
|
66
|
+
return f"{aggregate_name.lower()}-{aggregate_id}"
|
|
67
|
+
|
|
68
|
+
def _encode_event(self, e: DomainEvent):
|
|
69
|
+
''' Encodes a domain event into a new event descriptor '''
|
|
70
|
+
event_type = type(e).__name__.lower()
|
|
71
|
+
return EventDescriptor(event_type, e)
|
|
72
|
+
|
|
73
|
+
def configure(builder: ApplicationBuilderBase, entity_type : Type, key_type : Type) -> ApplicationBuilderBase:
|
|
74
|
+
''' Configures the specified application to use an event sourcing based repository implementation to manage the specified type of entity '''
|
|
75
|
+
builder.services.try_add_singleton(EventSourcingRepositoryOptions[entity_type, key_type], singleton= EventSourcingRepositoryOptions[entity_type, key_type]())
|
|
76
|
+
builder.services.try_add_singleton(Repository[entity_type, key_type], EventSourcingRepository[entity_type, key_type])
|
|
77
|
+
return builder
|
|
File without changes
|