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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
import rx
|
|
7
|
+
from rx.disposable.disposable import Disposable
|
|
8
|
+
from neuroglia.data.abstractions import DomainEvent
|
|
9
|
+
from neuroglia.data.infrastructure.event_sourcing.abstractions import Aggregator, EventDescriptor, EventRecord, EventStore, EventStoreOptions, StreamDescriptor, StreamReadDirection
|
|
10
|
+
from neuroglia.hosting.abstractions import ApplicationBuilderBase
|
|
11
|
+
from neuroglia.serialization.json import JsonSerializer
|
|
12
|
+
from esdbclient import EventStoreDBClient, NewEvent, StreamState, RecordedEvent
|
|
13
|
+
from esdbclient.exceptions import AlreadyExists
|
|
14
|
+
from rx import Observable
|
|
15
|
+
from rx.subject import Subject
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ESEventStore(EventStore):
|
|
19
|
+
''' Represents the EventStore.com implementation of the EventStore abstract class '''
|
|
20
|
+
|
|
21
|
+
_metadata_type = 'type'
|
|
22
|
+
''' Gets the name of the metadata attribute used to store the qualified name of the recorded event's type ('{module_name}.{type_name}') '''
|
|
23
|
+
|
|
24
|
+
_eventstore_options: EventStoreOptions
|
|
25
|
+
''' Gets the options used to configure the EventStore '''
|
|
26
|
+
|
|
27
|
+
_eventstore_client: EventStoreDBClient
|
|
28
|
+
''' Gets the service used to interact with the EventStore DB'''
|
|
29
|
+
|
|
30
|
+
_serializer: JsonSerializer
|
|
31
|
+
''' Gets the service used to serialize/deserialize objects to/from JSON'''
|
|
32
|
+
|
|
33
|
+
def __init__(self, options: EventStoreOptions, eventstore_client: EventStoreDBClient, serializer: JsonSerializer):
|
|
34
|
+
self._eventstore_options = options
|
|
35
|
+
self._eventstore_client = eventstore_client
|
|
36
|
+
self._serializer = serializer
|
|
37
|
+
|
|
38
|
+
async def contains_async(self, stream_id: str) -> bool: return await self.get_async(stream_id) != None
|
|
39
|
+
|
|
40
|
+
async def append_async(self, stream_id: str, events: List[EventDescriptor], expected_version: Optional[int] = None):
|
|
41
|
+
if expected_version is not None:
|
|
42
|
+
expected_version = expected_version - 1
|
|
43
|
+
stream_name = self._get_stream_name(stream_id)
|
|
44
|
+
stream_state = StreamState.NO_STREAM if expected_version is None else expected_version
|
|
45
|
+
formatted_events = [NewEvent
|
|
46
|
+
(
|
|
47
|
+
type=e.type,
|
|
48
|
+
data=None if e.data == None else self._serializer.serialize(e.data),
|
|
49
|
+
metadata=self._serializer.serialize(self._build_event_metadata(e.data, e.metadata))
|
|
50
|
+
)
|
|
51
|
+
for e in events]
|
|
52
|
+
self._eventstore_client.append_to_stream(stream_name=stream_name, current_version=stream_state, events=formatted_events)
|
|
53
|
+
|
|
54
|
+
async def get_async(self, stream_id: str) -> Optional[StreamDescriptor]:
|
|
55
|
+
stream_name = self._get_stream_name(stream_id)
|
|
56
|
+
metadata, metadata_version = self._eventstore_client.get_stream_metadata(stream_name)
|
|
57
|
+
if metadata_version == StreamState.NO_STREAM:
|
|
58
|
+
return None
|
|
59
|
+
truncate_before = metadata.get('$tb')
|
|
60
|
+
offset = 0 if truncate_before is None else truncate_before
|
|
61
|
+
read_response = self._eventstore_client.read_stream(
|
|
62
|
+
stream_name=stream_name,
|
|
63
|
+
stream_position=offset,
|
|
64
|
+
backwards=False,
|
|
65
|
+
resolve_links=True,
|
|
66
|
+
limit=1
|
|
67
|
+
)
|
|
68
|
+
recorded_events = tuple(read_response)
|
|
69
|
+
first_event = recorded_events[0]
|
|
70
|
+
read_response = self._eventstore_client.read_stream(
|
|
71
|
+
stream_name=stream_name,
|
|
72
|
+
stream_position=offset,
|
|
73
|
+
backwards=True,
|
|
74
|
+
resolve_links=True,
|
|
75
|
+
limit=1
|
|
76
|
+
)
|
|
77
|
+
recorded_events = tuple(read_response)
|
|
78
|
+
last_event = recorded_events[0]
|
|
79
|
+
return StreamDescriptor(stream_id, last_event.stream_position, None, None) # todo: esdbclient does not provide timestamps
|
|
80
|
+
|
|
81
|
+
async def read_async(self, stream_id: str, read_direction: StreamReadDirection, offset: int, length: Optional[int] = None) -> List[EventRecord]:
|
|
82
|
+
stream_name = self._get_stream_name(stream_id)
|
|
83
|
+
read_response = self._eventstore_client.read_stream(
|
|
84
|
+
stream_name=stream_name,
|
|
85
|
+
stream_position=offset,
|
|
86
|
+
backwards=True if read_direction == StreamReadDirection.BACKWARDS else False,
|
|
87
|
+
resolve_links=True,
|
|
88
|
+
limit=sys.maxsize if length is None else length
|
|
89
|
+
)
|
|
90
|
+
recorded_events = tuple(read_response)
|
|
91
|
+
return [self._decode_recorded_event(stream_id, recorded_event) for recorded_event in recorded_events]
|
|
92
|
+
|
|
93
|
+
async def observe_async(self, stream_id: Optional[str], consumer_group: Optional[str] = None, offset: Optional[int] = None) -> Observable:
|
|
94
|
+
stream_name = self._get_stream_name(stream_id)
|
|
95
|
+
subscription = None
|
|
96
|
+
if consumer_group is None:
|
|
97
|
+
subscription = self._eventstore_client.subscribe_to_stream(stream_name=stream_name, resolve_links=True, stream_position=offset)
|
|
98
|
+
else:
|
|
99
|
+
try:
|
|
100
|
+
self._eventstore_client.create_subscription_to_stream(stream_name=stream_name, resolve_links=True) # todo: persistence
|
|
101
|
+
except AlreadyExists:
|
|
102
|
+
pass
|
|
103
|
+
subject = Subject()
|
|
104
|
+
thread = threading.Thread(target=self._consume_events_async, kwargs={'stream_id': stream_id, 'subject': subject, 'subscription': subscription})
|
|
105
|
+
thread.start()
|
|
106
|
+
return rx.using(lambda: Disposable(lambda: subscription.stop()), lambda s: subject)
|
|
107
|
+
|
|
108
|
+
def _build_event_metadata(self, e: DomainEvent, additional_metadata: Optional[any]):
|
|
109
|
+
module_name = inspect.getmodule(e).__name__
|
|
110
|
+
type_name = type(e).__name__
|
|
111
|
+
metadata = {self._metadata_type: f'{module_name}.{type_name}'}
|
|
112
|
+
if additional_metadata != None:
|
|
113
|
+
if isinstance(additional_metadata, dict):
|
|
114
|
+
metadata.update(additional_metadata)
|
|
115
|
+
elif hasattr(additional_metadata, '__dict__'):
|
|
116
|
+
metadata.update(additional_metadata.__dict__)
|
|
117
|
+
else:
|
|
118
|
+
raise Exception()
|
|
119
|
+
return metadata
|
|
120
|
+
|
|
121
|
+
def _decode_recorded_event(self, stream_id: str, e: RecordedEvent) -> EventRecord:
|
|
122
|
+
text = e.metadata.decode()
|
|
123
|
+
metadata = self._serializer.deserialize_from_text(text)
|
|
124
|
+
type_qualified_name_parts = metadata[self._metadata_type].split('.')
|
|
125
|
+
module_name = '.'.join(type_qualified_name_parts[:-1])
|
|
126
|
+
type_name = type_qualified_name_parts[-1]
|
|
127
|
+
module = __import__(module_name, fromlist=[type_name])
|
|
128
|
+
expected_type = getattr(module, type_name)
|
|
129
|
+
text = e.data.decode()
|
|
130
|
+
data = None if text is None or text.isspace() else self._serializer.deserialize_from_text(text, expected_type)
|
|
131
|
+
if isinstance(data, Dict) and not isinstance(data, expected_type):
|
|
132
|
+
typed_data = expected_type.__new__(expected_type)
|
|
133
|
+
typed_data.__dict__ = data
|
|
134
|
+
data = typed_data
|
|
135
|
+
return EventRecord(stream_id=stream_id, id=e.id, offset=e.stream_position, position=e.commit_position, timestamp=None, type=e.type, data=data, metadata=metadata)
|
|
136
|
+
|
|
137
|
+
def _get_stream_name(self, stream_id: str) -> str:
|
|
138
|
+
''' Converts the specified stream id to a qualified stream id, which is prefixed with the current database name, if any '''
|
|
139
|
+
return stream_id if self._eventstore_options.database_name is None or stream_id.startswith('$ce-') else f'{self._eventstore_options.database_name}-{stream_id}'
|
|
140
|
+
|
|
141
|
+
def _consume_events_async(self, stream_id: str, subject: Subject, subscription):
|
|
142
|
+
''' Asynchronously enumerate events returned by a subscription '''
|
|
143
|
+
try:
|
|
144
|
+
e: RecordedEvent
|
|
145
|
+
for e in subscription:
|
|
146
|
+
try:
|
|
147
|
+
decoded_event = self._decode_recorded_event(stream_id, e)
|
|
148
|
+
except Exception as ex:
|
|
149
|
+
logging.error(f"An exception occured while decoding event with offset '{e.stream_position}' from stream '{e.stream_name}': {ex}")
|
|
150
|
+
raise
|
|
151
|
+
try:
|
|
152
|
+
subject.on_next(decoded_event)
|
|
153
|
+
except Exception as ex:
|
|
154
|
+
logging.error(f"An exception occured while handling event with offset '{e.stream_position}' from stream '{e.stream_name}': {ex}")
|
|
155
|
+
raise
|
|
156
|
+
subject.on_completed()
|
|
157
|
+
except Exception as ex:
|
|
158
|
+
logging.error(f"An exception occured while consuming events from stream '{stream_id}', consequently to which the related subscription will be stopped: {ex}") # todo: improve feedback
|
|
159
|
+
subscription.stop()
|
|
160
|
+
|
|
161
|
+
def configure(builder: ApplicationBuilderBase, options: EventStoreOptions) -> ApplicationBuilderBase:
|
|
162
|
+
''' Registers and configures an EventStore implementation of the EventStore class.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
services (ServiceCollection): the service collection to configure
|
|
166
|
+
'''
|
|
167
|
+
connection_string_name = "eventstore"
|
|
168
|
+
connection_string = builder.settings.connection_strings.get(connection_string_name, None)
|
|
169
|
+
if connection_string is None:
|
|
170
|
+
raise Exception(f"Missing '{connection_string_name}' connection string")
|
|
171
|
+
builder.services.try_add_singleton(Aggregator)
|
|
172
|
+
builder.services.try_add_singleton(EventStoreOptions, singleton=options)
|
|
173
|
+
builder.services.try_add_singleton(EventStoreDBClient, singleton=EventStoreDBClient(uri=connection_string))
|
|
174
|
+
builder.services.try_add_singleton(EventStore, ESEventStore)
|
|
175
|
+
return builder
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import logging
|
|
4
|
+
from rx.core.typing import Disposable
|
|
5
|
+
from neuroglia.data.infrastructure.event_sourcing.abstractions import EventRecord, EventStore, EventStoreOptions
|
|
6
|
+
from neuroglia.dependency_injection.service_provider import ServiceProviderBase
|
|
7
|
+
from neuroglia.hosting.abstractions import HostedService
|
|
8
|
+
from neuroglia.mediation.mediator import Mediator
|
|
9
|
+
from neuroglia.reactive import AsyncRx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ReadModelConciliationOptions:
|
|
14
|
+
''' Represents the options used to configure the application's read model reconciliation features '''
|
|
15
|
+
|
|
16
|
+
consumer_group: str
|
|
17
|
+
''' Gets the name of the group of consumers the application's read model is maintained by '''
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReadModelReconciliator(HostedService):
|
|
21
|
+
''' Represents the service used to reconciliate the read model by streaming and handling events recorded on the application's event store '''
|
|
22
|
+
|
|
23
|
+
_service_provider: ServiceProviderBase
|
|
24
|
+
''' Gets the current service provider '''
|
|
25
|
+
|
|
26
|
+
_mediator: Mediator
|
|
27
|
+
|
|
28
|
+
_event_store_options: EventStoreOptions
|
|
29
|
+
''' Gets the options used to configure the event store '''
|
|
30
|
+
|
|
31
|
+
_event_store: EventStore
|
|
32
|
+
''' Gets the service used to persist and stream domain events '''
|
|
33
|
+
|
|
34
|
+
_subscription: Disposable
|
|
35
|
+
|
|
36
|
+
def __init__(self, service_provider: ServiceProviderBase, mediator: Mediator, event_store_options: EventStoreOptions, event_store: EventStore):
|
|
37
|
+
self._service_provider = service_provider
|
|
38
|
+
self._mediator = mediator
|
|
39
|
+
self._event_store_options = event_store_options
|
|
40
|
+
self._event_store = event_store
|
|
41
|
+
|
|
42
|
+
async def start_async(self):
|
|
43
|
+
await self.subscribe_async()
|
|
44
|
+
|
|
45
|
+
async def stop_async(self):
|
|
46
|
+
self._subscription.dispose()
|
|
47
|
+
|
|
48
|
+
async def subscribe_async(self):
|
|
49
|
+
observable = await self._event_store.observe_async(f'$ce-{self._event_store_options.database_name}')
|
|
50
|
+
self._subscription = AsyncRx.subscribe(observable, lambda e: asyncio.run(self.on_event_record_stream_next_async(e)))
|
|
51
|
+
|
|
52
|
+
async def on_event_record_stream_next_async(self, e: EventRecord):
|
|
53
|
+
try:
|
|
54
|
+
# todo: migrate event
|
|
55
|
+
await self._mediator.publish_async(e.data)
|
|
56
|
+
# todo: ack
|
|
57
|
+
except Exception as ex:
|
|
58
|
+
logging.error(f"An exception occured while publishing an event of type '{type(e.data).__name__}': {ex}")
|
|
59
|
+
pass # todo: nack
|
|
60
|
+
|
|
61
|
+
async def on_event_record_stream_error(self, ex: Exception):
|
|
62
|
+
await self.subscribe_async()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .memory_repository import MemoryRepository as MemoryRepository
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from neuroglia.data.infrastructure.abstractions import Repository
|
|
3
|
+
from neuroglia.data.abstractions import TEntity, TKey
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MemoryRepository(Repository[TEntity, TKey]):
|
|
7
|
+
|
|
8
|
+
entities: dict = {}
|
|
9
|
+
|
|
10
|
+
async def contains_async(self, id: TKey) -> bool:
|
|
11
|
+
return self.entities.get(id) is not None
|
|
12
|
+
|
|
13
|
+
async def get_async(self, id: TKey) -> Optional[TEntity]:
|
|
14
|
+
return self.entities.get(id, None)
|
|
15
|
+
|
|
16
|
+
async def add_async(self, entity: TEntity) -> TEntity:
|
|
17
|
+
if entity.id in self.entities:
|
|
18
|
+
raise Exception()
|
|
19
|
+
self.entities[entity.id] = entity
|
|
20
|
+
return entity
|
|
21
|
+
|
|
22
|
+
async def update_async(self, entity: TEntity) -> TEntity:
|
|
23
|
+
self.entities[entity.id] = entity
|
|
24
|
+
return entity
|
|
25
|
+
|
|
26
|
+
async def remove_async(self, id: TKey) -> None:
|
|
27
|
+
if not id in self.entities:
|
|
28
|
+
raise Exception()
|
|
29
|
+
del self.entities[id]
|
|
File without changes
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from inspect import isclass
|
|
3
|
+
import pymongo
|
|
4
|
+
from ast import NodeVisitor, expr
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from neuroglia.data.queryable import T, QueryProvider, Queryable
|
|
7
|
+
from neuroglia.data.infrastructure.abstractions import FlexibleRepository, QueryableRepository, Repository
|
|
8
|
+
from neuroglia.data.abstractions import TEntity, TKey, VersionedState
|
|
9
|
+
from pymongo import MongoClient
|
|
10
|
+
from pymongo.collection import Collection
|
|
11
|
+
from pymongo.cursor import Cursor
|
|
12
|
+
from pymongo.database import Database
|
|
13
|
+
from typing import Any, Dict, Generic, Optional, List, Type
|
|
14
|
+
from neuroglia.expressions.javascript_expression_translator import JavaScriptExpressionTranslator
|
|
15
|
+
from neuroglia.hosting.abstractions import ApplicationBuilderBase
|
|
16
|
+
from neuroglia.serialization.json import JsonSerializer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class MongoRepositoryOptions(Generic[TEntity, TKey]):
|
|
21
|
+
''' Represents the options used to configure a Mongo repository '''
|
|
22
|
+
|
|
23
|
+
database_name: str
|
|
24
|
+
''' Gets the name of the Mongo database to use '''
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MongoQuery(Generic[T], Queryable[T]):
|
|
28
|
+
''' Represents a Mongo query '''
|
|
29
|
+
|
|
30
|
+
def __init__(self, query_provider: 'MongoQueryProvider', expression: Optional[expr] = None):
|
|
31
|
+
super().__init__(query_provider, expression)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MongoQueryBuilder(NodeVisitor):
|
|
35
|
+
''' Represents the service used to build mongo queries '''
|
|
36
|
+
|
|
37
|
+
def __init__(self, collection: Collection, translator: JavaScriptExpressionTranslator):
|
|
38
|
+
self._collection = collection
|
|
39
|
+
self._translator = translator
|
|
40
|
+
|
|
41
|
+
_collection: Collection
|
|
42
|
+
|
|
43
|
+
_translator: JavaScriptExpressionTranslator
|
|
44
|
+
|
|
45
|
+
_order_by_clauses: Dict[str, int] = dict[str, int]()
|
|
46
|
+
|
|
47
|
+
_select_clause: Optional[List[str]] = None
|
|
48
|
+
|
|
49
|
+
_skip_clause: Optional[int] = None
|
|
50
|
+
|
|
51
|
+
_take_clause: Optional[int] = None
|
|
52
|
+
|
|
53
|
+
_where_clauses: List[str] = list[str]()
|
|
54
|
+
|
|
55
|
+
def build(self, expression: expr) -> Cursor:
|
|
56
|
+
self.visit(expression)
|
|
57
|
+
cursor: Cursor = self._collection.find(projection=self._select_clause)
|
|
58
|
+
if len(self._order_by_clauses) > 0:
|
|
59
|
+
cursor = cursor.sort(self._order_by_clauses)
|
|
60
|
+
if len(self._where_clauses) > 0:
|
|
61
|
+
cursor = cursor.where(" && ".join(self._where_clauses))
|
|
62
|
+
if self._skip_clause is not None:
|
|
63
|
+
cursor = cursor.skip(self._skip_clause)
|
|
64
|
+
if self._take_clause is not None:
|
|
65
|
+
cursor = cursor.limit(self._take_clause)
|
|
66
|
+
return cursor
|
|
67
|
+
|
|
68
|
+
def visit_Call(self, node):
|
|
69
|
+
clause = node.func.attr
|
|
70
|
+
expression = node.args[0]
|
|
71
|
+
self.visit(node.func.value)
|
|
72
|
+
javascript = self._translator.translate(expression)
|
|
73
|
+
if clause == "distinct_by":
|
|
74
|
+
self._distinct_by_clauses.append(javascript.replace("this.", ""))
|
|
75
|
+
elif clause == "first":
|
|
76
|
+
self._where_clauses.append(javascript)
|
|
77
|
+
self._take_clause = 1
|
|
78
|
+
elif clause == "last":
|
|
79
|
+
self._where_clauses.append(javascript)
|
|
80
|
+
self._take_clause = 1
|
|
81
|
+
# todo: could be anything, really
|
|
82
|
+
self._order_by_clauses["created_at"] = pymongo.DESCENDING
|
|
83
|
+
elif clause == "order_by":
|
|
84
|
+
self._order_by_clauses[javascript.replace(
|
|
85
|
+
"this.", "")] = pymongo.ASCENDING
|
|
86
|
+
elif clause == "order_by_descending":
|
|
87
|
+
self._order_by_clauses[javascript.replace(
|
|
88
|
+
"this.", "")] = pymongo.DESCENDING
|
|
89
|
+
elif clause == "select" and isinstance(expression.body, ast.List):
|
|
90
|
+
self._select_clause = [self._translator.translate(
|
|
91
|
+
elt).replace("this.", "") for elt in expression.body.elts]
|
|
92
|
+
elif clause == "skip" and isinstance(expression, ast.Constant):
|
|
93
|
+
self._skip_clause = expression.value
|
|
94
|
+
elif clause == "take" and isinstance(expression, ast.Constant):
|
|
95
|
+
self._take_clause = expression.value
|
|
96
|
+
elif clause == "where":
|
|
97
|
+
self._where_clauses.append(javascript)
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MongoQueryProvider(QueryProvider):
|
|
102
|
+
''' Represents the Mongo implementation of the QueryProvider '''
|
|
103
|
+
|
|
104
|
+
def __init__(self, collection: Collection):
|
|
105
|
+
self._collection = collection
|
|
106
|
+
|
|
107
|
+
_collection: Collection
|
|
108
|
+
|
|
109
|
+
def create_query(self, element_type: Type,
|
|
110
|
+
expression: expr) -> Queryable: return MongoQuery[element_type](self, expression)
|
|
111
|
+
|
|
112
|
+
def execute(self, expression: expr, query_type: Type) -> Any:
|
|
113
|
+
query = MongoQueryBuilder(
|
|
114
|
+
self._collection, JavaScriptExpressionTranslator()).build(expression)
|
|
115
|
+
type_ = query_type if isclass(
|
|
116
|
+
query_type) or query_type == List else type(query_type)
|
|
117
|
+
if issubclass(type_, List):
|
|
118
|
+
return list(query)
|
|
119
|
+
else:
|
|
120
|
+
return next(query, None)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MongoRepository(Generic[TEntity, TKey], QueryableRepository[TEntity, TKey]):
|
|
124
|
+
''' Represents a Mongo implementation of the repository class '''
|
|
125
|
+
|
|
126
|
+
def __init__(self, options: MongoRepositoryOptions[TEntity, TKey], mongo_client: MongoClient, serializer: JsonSerializer):
|
|
127
|
+
''' Initializes a new Mongo repository '''
|
|
128
|
+
self._options = options
|
|
129
|
+
self._mongo_client = mongo_client
|
|
130
|
+
self._mongo_database = self._mongo_client[self._options.database_name]
|
|
131
|
+
self._serializer = serializer
|
|
132
|
+
self._collection_name = None
|
|
133
|
+
|
|
134
|
+
_options: MongoRepositoryOptions[TEntity, TKey]
|
|
135
|
+
''' Gets the options used to configure the Mongo repository '''
|
|
136
|
+
|
|
137
|
+
_mongo_client: MongoClient
|
|
138
|
+
''' Gets the service used to interact with Mongo '''
|
|
139
|
+
|
|
140
|
+
_mongo_database: Database
|
|
141
|
+
''' Gets the Mongo database to use '''
|
|
142
|
+
|
|
143
|
+
_serializer: JsonSerializer
|
|
144
|
+
''' Gets the service used to serialize/deserialize to/from JSON '''
|
|
145
|
+
|
|
146
|
+
async def contains_async(self, id: TKey) -> bool: return self._get_mongo_collection().find_one({"id": id}, projection={"_id": 1})
|
|
147
|
+
|
|
148
|
+
async def get_async(self, id: TKey) -> Optional[TEntity]:
|
|
149
|
+
attributes_dictionary = self._get_mongo_collection().find_one({"id": id})
|
|
150
|
+
if (attributes_dictionary is None):
|
|
151
|
+
return None
|
|
152
|
+
json = self._serializer.serialize(attributes_dictionary)
|
|
153
|
+
entity = self._serializer.deserialize_from_text(json, self._get_entity_type())
|
|
154
|
+
return entity
|
|
155
|
+
|
|
156
|
+
async def add_async(self, entity: TEntity) -> TEntity:
|
|
157
|
+
if await self.contains_async(entity.id) is not None:
|
|
158
|
+
raise Exception(f"A {self._get_entity_type().__name__} with the specified id '{entity.id}' already exists")
|
|
159
|
+
json = self._serializer.serialize_to_text(entity)
|
|
160
|
+
attributes_dictionary = self._serializer.deserialize_from_text(
|
|
161
|
+
json, dict)
|
|
162
|
+
self._get_mongo_collection().insert_one(attributes_dictionary)
|
|
163
|
+
return entity
|
|
164
|
+
|
|
165
|
+
async def update_async(self, entity: TEntity) -> TEntity:
|
|
166
|
+
if not await self.contains_async(entity.id) is not None:
|
|
167
|
+
raise Exception(f"Failed to find a {self._get_entity_type().__name__} with the specified id '{entity.id}'")
|
|
168
|
+
query_filter = {"id": entity.id}
|
|
169
|
+
expected_version = entity.state_version if isinstance(
|
|
170
|
+
entity, VersionedState) else None
|
|
171
|
+
if expected_version is not None:
|
|
172
|
+
query_filter["state_version"] = expected_version
|
|
173
|
+
json = self._serializer.serialize_to_text(entity)
|
|
174
|
+
attributes_dictionary = self._serializer.deserialize_from_text(
|
|
175
|
+
json, dict)
|
|
176
|
+
self._get_mongo_collection().replace_one(query_filter, attributes_dictionary)
|
|
177
|
+
return entity
|
|
178
|
+
|
|
179
|
+
async def remove_async(self, id: TKey) -> None:
|
|
180
|
+
if not await self.contains_async(id) is not None:
|
|
181
|
+
raise Exception(f"Failed to find a {self._get_entity_type().__name__} with the specified id '{id}'")
|
|
182
|
+
self._get_mongo_collection().delete_one({"id": id})
|
|
183
|
+
|
|
184
|
+
async def query_async(self) -> Queryable[TEntity]: return MongoQuery[TEntity](MongoQueryProvider(self._get_mongo_collection()))
|
|
185
|
+
|
|
186
|
+
def _get_entity_type(self) -> str: return self.__orig_class__.__args__[0]
|
|
187
|
+
|
|
188
|
+
def _get_mongo_collection(self) -> Collection:
|
|
189
|
+
''' Gets the Mongo collection to use '''
|
|
190
|
+
# to get the collection_name, we need to access 'self.__orig_class__', which is not yet available in __init__, thus the need for a function
|
|
191
|
+
collection_name = self._get_entity_type().__name__.lower()
|
|
192
|
+
if collection_name.endswith("dto"):
|
|
193
|
+
collection_name = collection_name[:-3]
|
|
194
|
+
return self._mongo_database[collection_name]
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def configure(builder: ApplicationBuilderBase, entity_type: Type, key_type: Type, database_name: str) -> ApplicationBuilderBase:
|
|
198
|
+
''' Configures the specified application to use a Mongo repository implementation to manage the specified type of entity '''
|
|
199
|
+
connection_string_name = "mongo"
|
|
200
|
+
connection_string = builder.settings.connection_strings.get(
|
|
201
|
+
connection_string_name, None)
|
|
202
|
+
if connection_string is None:
|
|
203
|
+
raise Exception(
|
|
204
|
+
f"Missing '{connection_string_name}' connection string")
|
|
205
|
+
builder.services.try_add_singleton(MongoClient, singleton=MongoClient(connection_string))
|
|
206
|
+
builder.services.try_add_singleton(MongoRepositoryOptions[entity_type, key_type], singleton=MongoRepositoryOptions[entity_type, key_type](database_name))
|
|
207
|
+
builder.services.try_add_singleton(Repository[entity_type, key_type], MongoRepository[entity_type, key_type])
|
|
208
|
+
builder.services.try_add_singleton(QueryableRepository[entity_type, key_type], implementation_factory=lambda provider: provider.get_required_service(Repository[entity_type, key_type]))
|
|
209
|
+
return builder
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class FlexibleMongoRepository(MongoRepository[TEntity, TKey], FlexibleRepository[TEntity, TKey]):
|
|
213
|
+
''' Represents a Mongo implementation of the flexible repository class '''
|
|
214
|
+
|
|
215
|
+
def __init__(self, mongo_client: MongoClient, serializer: JsonSerializer):
|
|
216
|
+
''' Initializes a new Mongo repository '''
|
|
217
|
+
self._mongo_client = mongo_client
|
|
218
|
+
self._database_name = "NOTSETHERE"
|
|
219
|
+
self._mongo_database = self._mongo_client[self._database_name]
|
|
220
|
+
self._serializer = serializer
|
|
221
|
+
self._collection_name = ""
|
|
222
|
+
|
|
223
|
+
_mongo_client: MongoClient
|
|
224
|
+
''' Gets the service used to interact with Mongo '''
|
|
225
|
+
|
|
226
|
+
_mongo_database: Database
|
|
227
|
+
''' Gets the Mongo database to use '''
|
|
228
|
+
|
|
229
|
+
_serializer: JsonSerializer
|
|
230
|
+
''' Gets the service used to serialize/deserialize to/from JSON '''
|
|
231
|
+
|
|
232
|
+
_collection_name: str
|
|
233
|
+
''' Gets the name of the collection in which to CRUD the entity '''
|
|
234
|
+
|
|
235
|
+
async def set_database(self, database: str):
|
|
236
|
+
if not FlexibleMongoRepository._is_valid_database_name(database):
|
|
237
|
+
raise Exception(f"Database name {database} is invalid!")
|
|
238
|
+
self._database_name = database
|
|
239
|
+
self._mongo_database = self._mongo_client[self._database_name]
|
|
240
|
+
|
|
241
|
+
async def get_database(self) -> str:
|
|
242
|
+
return self._database_name
|
|
243
|
+
|
|
244
|
+
async def contains_by_collection_name_async(self, collection_name: str, id: TKey) -> bool:
|
|
245
|
+
if not FlexibleMongoRepository._is_valid_collection_name(collection_name):
|
|
246
|
+
raise Exception(f"Collection name {collection_name} is invalid!")
|
|
247
|
+
self._collection_name = collection_name
|
|
248
|
+
return self._get_mongo_collection().find_one({"id": id}, projection={"_id": 1})
|
|
249
|
+
|
|
250
|
+
async def get_by_collection_name_async(self, collection_name: str, id: TKey) -> Optional[TEntity]:
|
|
251
|
+
if not FlexibleMongoRepository._is_valid_collection_name(collection_name):
|
|
252
|
+
raise Exception(f"Collection name {collection_name} is invalid!")
|
|
253
|
+
self._collection_name = collection_name
|
|
254
|
+
return await self.get_async(id)
|
|
255
|
+
|
|
256
|
+
async def add_by_collection_name_async(self, collection_name: str, entity: TEntity) -> TEntity:
|
|
257
|
+
if not FlexibleMongoRepository._is_valid_collection_name(collection_name):
|
|
258
|
+
raise Exception(f"Collection name {collection_name} is invalid!")
|
|
259
|
+
self._collection_name = collection_name
|
|
260
|
+
return await self.add_async(entity)
|
|
261
|
+
|
|
262
|
+
async def update_by_collection_name_async(self, collection_name: str, entity: TEntity) -> TEntity:
|
|
263
|
+
if not FlexibleMongoRepository._is_valid_collection_name(collection_name):
|
|
264
|
+
raise Exception(f"Collection name {collection_name} is invalid!")
|
|
265
|
+
self._collection_name = collection_name
|
|
266
|
+
return await self.update_async(entity)
|
|
267
|
+
|
|
268
|
+
async def remove_by_collection_name_async(self, collection_name: str, id: TKey) -> None:
|
|
269
|
+
if not FlexibleMongoRepository._is_valid_collection_name(collection_name):
|
|
270
|
+
raise Exception(f"Collection name {collection_name} is invalid!")
|
|
271
|
+
self._collection_name = collection_name
|
|
272
|
+
return await self.remove_async(id)
|
|
273
|
+
|
|
274
|
+
async def query_by_collection_name_async(self, collection_name: str) -> Queryable[TEntity]:
|
|
275
|
+
return MongoQuery[TEntity](MongoQueryProvider(collection_name))
|
|
276
|
+
|
|
277
|
+
def _get_mongo_collection(self) -> Collection:
|
|
278
|
+
''' Gets the Mongo collection to use '''
|
|
279
|
+
if self._collection_name is not None and self._collection_name != "":
|
|
280
|
+
return self._mongo_database[self._collection_name]
|
|
281
|
+
else:
|
|
282
|
+
collection_name = self._get_entity_type().__name__.lower()
|
|
283
|
+
if collection_name.endswith("dto"):
|
|
284
|
+
collection_name = collection_name[:-3]
|
|
285
|
+
return self._mongo_database[collection_name]
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _is_valid_database_name(database_name: str) -> bool:
|
|
289
|
+
# https://www.mongodb.com/docs/manual/reference/limits/#naming-restrictions
|
|
290
|
+
# max 63chars, no `/\. "$*<>:|?`
|
|
291
|
+
assert len(database_name) < 64, f"Database name {database_name} is too long. Max 63chars."
|
|
292
|
+
assert "/" not in database_name, f"The char / (forward slash) may not be included in the database name."
|
|
293
|
+
# assert r"\ " not in database_name, f"The char \ (backward slash) may not be included in the database name." # SyntaxWarning: invalid escape sequence
|
|
294
|
+
assert "." not in database_name, f"The char . (dot) may not be included in the database name."
|
|
295
|
+
assert " " not in database_name, f"Space char may not be included in the database name."
|
|
296
|
+
assert '"' not in database_name, f"Double quote char may not be included in the database name."
|
|
297
|
+
assert '$' not in database_name, f"Dollar sign char may not be included in the database name."
|
|
298
|
+
assert '*' not in database_name, f"Asterisk char may not be included in the database name."
|
|
299
|
+
assert '<' not in database_name, f"Less-than-sign char may not be included in the database name."
|
|
300
|
+
assert '>' not in database_name, f"Larger-than-sign char may not be included in the database name."
|
|
301
|
+
assert ':' not in database_name, f"Colon char may not be included in the database name."
|
|
302
|
+
assert '|' not in database_name, f"Pipe char may not be included in the database name."
|
|
303
|
+
assert '?' not in database_name, f"Question mark char may not be included in the database name."
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _is_valid_collection_name(collection_name: str) -> bool:
|
|
308
|
+
# https://www.mongodb.com/docs/manual/reference/limits/#naming-restrictions
|
|
309
|
+
assert len(collection_name) < 254, f"Collection name {collection_name} is too long. Max 243chars."
|
|
310
|
+
assert (collection_name[0] == '_' or collection_name[0].isalpha()), f"Collection name '{collection_name}' must start with either underscore or alphanumeric char."
|
|
311
|
+
assert not collection_name[0].isdigit(), f"Collection name '{collection_name}' must start with a digit."
|
|
312
|
+
assert '$' not in collection_name, f"Dollar sign char may not be included in the collection name '{collection_name}'."
|
|
313
|
+
assert collection_name != "", f"Collection name '{collection_name}' must not be the empty string."
|
|
314
|
+
assert "\0" not in collection_name, f"Collection name '{collection_name}' must not include the Null char."
|
|
315
|
+
assert not collection_name.startswith('system'), f"Collection name '{collection_name}' must not start with the word 'system'."
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def configure(builder: ApplicationBuilderBase, entity_type: Type, key_type: Type, database_name: str) -> ApplicationBuilderBase:
|
|
320
|
+
''' Configures the specified application to use a Mongo repository implementation to manage the specified type of entity '''
|
|
321
|
+
connection_string_name = "mongo"
|
|
322
|
+
connection_string = builder.settings.connection_strings.get(
|
|
323
|
+
connection_string_name, None)
|
|
324
|
+
if connection_string is None:
|
|
325
|
+
raise Exception(
|
|
326
|
+
f"Missing '{connection_string_name}' connection string")
|
|
327
|
+
builder.services.try_add_singleton(MongoClient, singleton=MongoClient(connection_string))
|
|
328
|
+
builder.services.try_add_singleton(MongoRepositoryOptions[entity_type, key_type], singleton=MongoRepositoryOptions[entity_type, key_type](database_name))
|
|
329
|
+
builder.services.try_add_singleton(Repository[entity_type, key_type], FlexibleMongoRepository[entity_type, key_type])
|
|
330
|
+
builder.services.try_add_singleton(FlexibleRepository[entity_type, key_type], implementation_factory=lambda provider: provider.get_required_service(Repository[entity_type, key_type]))
|
|
331
|
+
return builder
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Any, Generic
|
|
2
|
+
from neuroglia.core.operation_result import OperationResult
|
|
3
|
+
from neuroglia.data.abstractions import TEntity, TKey
|
|
4
|
+
from neuroglia.data.infrastructure.abstractions import QueryableRepository, Repository
|
|
5
|
+
from neuroglia.mediation.mediator import Query, QueryHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GetByIdQuery(Generic[TEntity, TKey], Query[OperationResult[TEntity]]):
|
|
9
|
+
''' Represents the query used to get an entity by id'''
|
|
10
|
+
|
|
11
|
+
def __init__(self, id: Any):
|
|
12
|
+
self.id = id
|
|
13
|
+
|
|
14
|
+
id: Any
|
|
15
|
+
''' Gets the id of the entity to get '''
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GetByIdQueryHandler(Generic[TEntity, TKey], QueryHandler[GetByIdQuery[TEntity, TKey], OperationResult[TEntity]]):
|
|
19
|
+
''' Represents the service used to handle GetByIdQuery instances '''
|
|
20
|
+
|
|
21
|
+
def __init__(self, repository: Repository[TEntity, TKey]):
|
|
22
|
+
self.repository = repository
|
|
23
|
+
|
|
24
|
+
repository: Repository[TEntity, TKey]
|
|
25
|
+
|
|
26
|
+
async def handle_async(self, query: GetByIdQuery[TEntity, TKey]) -> OperationResult[TEntity]:
|
|
27
|
+
entity = await self.repository.get_async(query.id)
|
|
28
|
+
if entity is None:
|
|
29
|
+
return self.not_found(self.repository.__orig_class__.__args__[0], query.id)
|
|
30
|
+
return self.ok(entity)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any, Generic
|
|
2
|
+
from neuroglia.core.operation_result import OperationResult
|
|
3
|
+
from neuroglia.data.abstractions import TEntity, TKey
|
|
4
|
+
from neuroglia.data.infrastructure.abstractions import QueryableRepository
|
|
5
|
+
from neuroglia.mediation.mediator import Query, QueryHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ListQuery(Generic[TEntity, TKey], Query[OperationResult[TEntity]]):
|
|
9
|
+
''' Represents the query used to get an entity by id'''
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ListQueryHandler(Generic[TEntity, TKey], QueryHandler[ListQuery[TEntity, TKey], OperationResult[TEntity]]):
|
|
14
|
+
''' Represents the service used to handle ListQuery instances '''
|
|
15
|
+
|
|
16
|
+
def __init__(self, repository: QueryableRepository[TEntity, TKey]):
|
|
17
|
+
self.repository = repository
|
|
18
|
+
|
|
19
|
+
repository: QueryableRepository[TEntity, TKey]
|
|
20
|
+
|
|
21
|
+
async def handle_async(self, query: ListQuery[TEntity, TKey]) -> OperationResult[TEntity]:
|
|
22
|
+
res = await self.repository.query_async()
|
|
23
|
+
return self.ok(res.to_list())
|