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.
Files changed (58) hide show
  1. neuroglia/__init__.py +0 -0
  2. neuroglia/core/__init__.py +4 -0
  3. neuroglia/core/module_loader.py +13 -0
  4. neuroglia/core/operation_result.py +11 -0
  5. neuroglia/core/problem_details.py +25 -0
  6. neuroglia/core/type_extensions.py +75 -0
  7. neuroglia/core/type_finder.py +24 -0
  8. neuroglia/data/__init__.py +1 -0
  9. neuroglia/data/abstractions.py +124 -0
  10. neuroglia/data/infrastructure/__init__.py +0 -0
  11. neuroglia/data/infrastructure/abstractions.py +78 -0
  12. neuroglia/data/infrastructure/event_sourcing/__init__.py +0 -0
  13. neuroglia/data/infrastructure/event_sourcing/abstractions.py +126 -0
  14. neuroglia/data/infrastructure/event_sourcing/event_sourcing_repository.py +77 -0
  15. neuroglia/data/infrastructure/event_sourcing/event_store/__init__.py +0 -0
  16. neuroglia/data/infrastructure/event_sourcing/event_store/event_store.py +175 -0
  17. neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +62 -0
  18. neuroglia/data/infrastructure/memory/__init__.py +1 -0
  19. neuroglia/data/infrastructure/memory/memory_repository.py +29 -0
  20. neuroglia/data/infrastructure/mongo/__init__.py +0 -0
  21. neuroglia/data/infrastructure/mongo/mongo_repository.py +331 -0
  22. neuroglia/data/queries/generic/__init__.py +2 -0
  23. neuroglia/data/queries/generic/get_by_id_query.py +30 -0
  24. neuroglia/data/queries/generic/list_query.py +23 -0
  25. neuroglia/data/queryable.py +175 -0
  26. neuroglia/dependency_injection/__init__.py +1 -0
  27. neuroglia/dependency_injection/service_provider.py +341 -0
  28. neuroglia/eventing/cloud_events/__init__.py +1 -0
  29. neuroglia/eventing/cloud_events/cloud_event.py +72 -0
  30. neuroglia/eventing/cloud_events/decorators.py +5 -0
  31. neuroglia/eventing/cloud_events/infrastructure/__init__.py +4 -0
  32. neuroglia/eventing/cloud_events/infrastructure/cloud_event_bus.py +11 -0
  33. neuroglia/eventing/cloud_events/infrastructure/cloud_event_ingestor.py +74 -0
  34. neuroglia/eventing/cloud_events/infrastructure/cloud_event_middleware.py +39 -0
  35. neuroglia/eventing/cloud_events/infrastructure/cloud_event_publisher.py +103 -0
  36. neuroglia/expressions/__init__.py +0 -0
  37. neuroglia/expressions/javascript_expression_translator.py +147 -0
  38. neuroglia/hosting/__init__.py +0 -0
  39. neuroglia/hosting/abstractions.py +141 -0
  40. neuroglia/hosting/configuration/data_access_layer.py +50 -0
  41. neuroglia/hosting/web.py +87 -0
  42. neuroglia/integration/__init__.py +0 -0
  43. neuroglia/integration/models.py +18 -0
  44. neuroglia/mapping/__init__.py +1 -0
  45. neuroglia/mapping/mapper.py +238 -0
  46. neuroglia/mediation/__init__.py +1 -0
  47. neuroglia/mediation/mediator.py +182 -0
  48. neuroglia/mvc/__init__.py +0 -0
  49. neuroglia/mvc/controller_base.py +72 -0
  50. neuroglia/reactive/__init__.py +1 -0
  51. neuroglia/reactive/rx_async.py +16 -0
  52. neuroglia/serialization/__init__.py +0 -0
  53. neuroglia/serialization/abstractions.py +30 -0
  54. neuroglia/serialization/json.py +212 -0
  55. neuroglia_python-0.0.23.dist-info/LICENSE +201 -0
  56. neuroglia_python-0.0.23.dist-info/METADATA +127 -0
  57. neuroglia_python-0.0.23.dist-info/RECORD +58 -0
  58. 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,2 @@
1
+ from .get_by_id_query import *
2
+ from .list_query import *
@@ -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())