From 50806f2ad89c2e7ea6d45bab82bd20280164c88e Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Wed, 21 Dec 2022 16:19:41 +0100 Subject: [PATCH 01/12] Added count() function to repository, made LazyCommand an iterable object, added lazy queries to all repositories --- assimilator/alchemy/database/repository.py | 32 +++++++------ assimilator/core/database/repository.py | 36 ++++++++++++--- assimilator/core/patterns/mixins.py | 2 +- assimilator/internal/database/repository.py | 51 ++++++++++++++------- assimilator/redis/database/repository.py | 29 ++++++++---- setup.py | 2 +- 6 files changed, 105 insertions(+), 47 deletions(-) diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index 2b93b21..943634c 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -1,16 +1,20 @@ -from typing import Type +from typing import Type, Union +from sqlalchemy import func, select from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import Query from assimilator.alchemy.database.specifications import AlchemySpecificationList -from assimilator.core.database import BaseRepository, Specification, SpecificationList +from assimilator.core.database import BaseRepository, Specification, SpecificationList, LazyCommand from assimilator.core.database.exceptions import NotFoundError class AlchemyRepository(BaseRepository): def __init__( - self, session, initial_query: Query = None, specifications: Type[SpecificationList] = AlchemySpecificationList, + self, + session, + initial_query: Query = None, + specifications: Type[SpecificationList] = AlchemySpecificationList, ): super(AlchemyRepository, self).__init__( session=session, @@ -21,23 +25,22 @@ def __init__( def _execute_query(self, query): return self.session.execute(query) - def get(self, *specifications: Specification, lazy=False): + def get(self, *specifications: Specification, lazy: bool = False, initial_query=None): try: - data = self._execute_query(self._apply_specifications(specifications)) if lazy: - return data - - return data.one()[0] + return LazyCommand(self.get, *specifications, initial_query=initial_query, lazy=False) + query = self._apply_specifications(specifications, initial_query=initial_query) + return self._execute_query(query).one()[0] except NoResultFound as exc: raise NotFoundError(exc) - def filter(self, *specifications: Specification, lazy=False): - data = self._execute_query(self._apply_specifications(specifications)) + def filter(self, *specifications: Specification, lazy: bool = False, initial_query=None): if lazy: - return data + return LazyCommand(self.filter, *specifications, initial_query=initial_query, lazy=False) - return [result[0] for result in data] + query = self._apply_specifications(specifications, initial_query=initial_query) + return [result[0] for result in self._execute_query(query)] def update(self, obj): """ We don't do anything, as the object is going to be updated with the obj.key = value """ @@ -51,9 +54,12 @@ def refresh(self, obj): def delete(self, obj): self.session.delete(obj) - def is_modified(self, obj): + def is_modified(self, obj) -> bool: return self.session.is_modified(obj) + def count(self, *specifications, lazy: bool = False) -> Union[LazyCommand, int]: + return self.get(*specifications, lazy=lazy, initial_query=select(func.count())) + __all__ = [ 'AlchemyRepository', diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index 475624b..0c76176 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Union, Any, Optional, Callable, Iterable, Type +from typing import Union, Any, Optional, Callable, Iterable, Type, Container from assimilator.core.database.specifications import SpecificationList, SpecificationType @@ -9,9 +9,25 @@ def __init__(self, command: Callable, *args, **kwargs): self.command = command self.args = args self.kwargs = kwargs + self._results = None - def __call__(self): - return self.command(*self.args, **self.kwargs) + def __call__(self) -> Union[Container, Any]: + if self._results is not None: + return self._results + + self._results = self.command(*self.args, **self.kwargs) + return self._results + + def __iter__(self): + results = self() + + if not isinstance(results, Iterable): # get() command + raise StopIteration("Results are not iterable") + + return iter(results) # filter() command + + def __bool__(self): + return bool(self()) class BaseRepository(ABC): @@ -26,8 +42,8 @@ def _get_initial_query(self): else: raise NotImplementedError("You must either pass the initial query or define get_initial_query()") - def _apply_specifications(self, specifications: Iterable[SpecificationType]) -> Any: - query = self._get_initial_query() + def _apply_specifications(self, specifications: Iterable[SpecificationType], initial_query=None) -> Any: + query = self._get_initial_query() if initial_query is None else initial_query for specification in specifications: query = specification(query) @@ -35,11 +51,13 @@ def _apply_specifications(self, specifications: Iterable[SpecificationType]) -> return query @abstractmethod - def get(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, Any]: + def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None)\ + -> Union[LazyCommand, Any]: raise NotImplementedError("get() is not implemented()") @abstractmethod - def filter(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, Iterable]: + def filter(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None)\ + -> Union[LazyCommand, Container]: raise NotImplementedError("filter() is not implemented()") @abstractmethod @@ -62,6 +80,10 @@ def is_modified(self, obj) -> bool: def refresh(self, obj) -> None: raise NotImplementedError("refresh() is not implemented in the repository") + @abstractmethod + def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: + raise NotImplementedError("count() is not implemented in the repository") + __all__ = [ 'LazyCommand', diff --git a/assimilator/core/patterns/mixins.py b/assimilator/core/patterns/mixins.py index 447c4cb..5cb34b5 100644 --- a/assimilator/core/patterns/mixins.py +++ b/assimilator/core/patterns/mixins.py @@ -8,7 +8,7 @@ class JSONParsedMixin: @classmethod - def from_json(cls: Type['BaseModel'], data: str) -> 'BaseModel': + def from_json(cls: Type['BaseModel'], data: str): try: return cls(**json.loads(data)) except ValidationError as exc: diff --git a/assimilator/internal/database/repository.py b/assimilator/internal/database/repository.py index 0529099..d30897f 100644 --- a/assimilator/internal/database/repository.py +++ b/assimilator/internal/database/repository.py @@ -1,35 +1,45 @@ import re -from typing import Type +from typing import Type, Union -from assimilator.core.database import BaseRepository, SpecificationList from assimilator.core.database.exceptions import NotFoundError +from assimilator.core.database import BaseRepository, SpecificationList, SpecificationType, LazyCommand from assimilator.internal.database.specifications import InternalSpecification, InternalSpecificationList class InternalRepository(BaseRepository): - def __init__(self, session: dict, specifications: Type[SpecificationList] = InternalSpecificationList): - super(InternalRepository, self).__init__(session=session, initial_query='', specifications=specifications) - - def get(self, *specifications: InternalSpecification, lazy: bool = False): + def __init__( + self, + session: dict, + specifications: Type[SpecificationList] = InternalSpecificationList, + initial_keyname: str = '', + ): + super(InternalRepository, self).__init__( + session=session, + initial_query=initial_keyname, + specifications=specifications, + ) + + def get(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None): try: - return self.session[self._apply_specifications(specifications)] + if lazy: + return LazyCommand(self.get, *specifications, lazy=False, initial_query=initial_query) + + return self.session[self._apply_specifications(specifications, initial_query=initial_query)] except (KeyError, TypeError) as exc: raise NotFoundError(exc) - def filter(self, *specifications: InternalSpecification, lazy: bool = False): + def filter(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None): + if lazy: + return LazyCommand(self.filter, *specifications, lazy=False, initial_query=initial_query) + if not specifications: return list(self.session.values()) - key_mask = self._apply_specifications(specifications) - if lazy: - return key_mask - + key_mask = self._apply_specifications(specifications, initial_query=initial_query) models = [] for key, value in self.session.items(): - if not re.match(key, key_mask): - pass - - models.append(value) + if re.match(key_mask, key): + models.append(value) return models @@ -48,6 +58,15 @@ def is_modified(self, obj): def refresh(self, obj): obj.value = self.get(self.specifications.filter(obj.id)) + def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: + if lazy: + return LazyCommand(self.count, *specifications, lazy=False) + elif not specifications: + return len(self.session) + + results: LazyCommand = self.filter(*specifications, lazy=True) + return len(results()) + __all__ = [ 'InternalRepository', diff --git a/assimilator/redis/database/repository.py b/assimilator/redis/database/repository.py index 34fafb8..7b71651 100644 --- a/assimilator/redis/database/repository.py +++ b/assimilator/redis/database/repository.py @@ -1,8 +1,8 @@ -from typing import Type +from typing import Type, Union, Iterable import redis -from assimilator.core.database import SpecificationList +from assimilator.core.database import SpecificationList, SpecificationType from assimilator.redis.database import RedisModel from assimilator.core.database.repository import BaseRepository, LazyCommand from assimilator.internal.database.specifications import InternalSpecification, InternalSpecificationList @@ -18,17 +18,20 @@ def __init__( super(RedisRepository, self).__init__(session, initial_query='', specifications=specifications) self.model = model - def get(self, *specifications: InternalSpecification, lazy: bool = False): - key_name = self._apply_specifications(specifications) + def get(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None)\ + -> Union[LazyCommand, RedisModel]: + key_name = self._apply_specifications(specifications, initial_query=initial_query) if lazy: - return LazyCommand(self.session.get, key_name) - return self.session.get(key_name) + return LazyCommand(lambda: self.model.from_json(self.session.get(key_name))) - def filter(self, *specifications: InternalSpecification, lazy: bool = False): - key_name = self._apply_specifications(specifications) + return self.model.from_json(self.session.get(key_name)) + + def filter(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None)\ + -> Union[LazyCommand, Iterable['RedisModel']]: if lazy: - return LazyCommand(self.session.keys, key_name) + return LazyCommand(self.filter, *specifications, lazy=False, initial_query=initial_query) + key_name = self._apply_specifications(specifications, initial_query=initial_query) return [self.model.from_json(value) for value in self.session.mget(self.session.keys(key_name))] def save(self, obj: RedisModel): @@ -49,6 +52,14 @@ def refresh(self, obj: RedisModel): for key, value in fresh_obj.dict().items(): setattr(obj, key, value) + def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: + if lazy: + return LazyCommand(self.count, *specifications, lazy=False) + elif not specifications: + return self.session.dbsize() + else: + return len(self.session.keys(self._apply_specifications(specifications))) + __all__ = [ 'RedisRepository', diff --git a/setup.py b/setup.py index fdbb6a1..02242c7 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='py_assimilator', - version='0.2.3', + version='0.2.5', author='Andrey Ivanov', author_email='python.on.papyrus@gmail.com', url='https://pypi.python.org/pypi/py_assimilator/', From dd92e02e82a28d54829d779510418b370ed51c23 Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Thu, 22 Dec 2022 19:55:46 +0100 Subject: [PATCH 02/12] Fixed event_bus imports, started working with events documentation, added LazyCommand to database documentation --- assimilator/core/events/__init__.py | 1 + docs/patterns/database.md | 80 ++++++--- docs/patterns/events.md | 258 ++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 docs/patterns/events.md diff --git a/assimilator/core/events/__init__.py b/assimilator/core/events/__init__.py index 0e082f0..960a193 100644 --- a/assimilator/core/events/__init__.py +++ b/assimilator/core/events/__init__.py @@ -1,3 +1,4 @@ from assimilator.core.events.outbox_relay import * from assimilator.core.events.events import * from assimilator.core.events.exceptions import * +from assimilator.core.events.events_bus import * diff --git a/docs/patterns/database.md b/docs/patterns/database.md index f554c40..7faceef 100644 --- a/docs/patterns/database.md +++ b/docs/patterns/database.md @@ -8,7 +8,7 @@ functions that help us change and query our data from any source. The beauty of is that you can use it with SQL, text files, cache, S3, external API's or any kind of data storage. -###### `__init__(session, specifications: SpecificationList, initial_query)` +###### `__init__()` - `session` - each repository has a session that works as the primary data source. It can be your database connection, a text file or a data structure. - `initial_query` - the initial query that you use in the data storage. We will show how it works later. It can be an SQL query, a key in the dictionary or anything else. - `specifications: SpecificationList` - an object that contains links to the specifications that are used to create your queries @@ -16,7 +16,7 @@ is that you can use it with SQL, text files, cache, S3, external API's or any ki ###### `_get_initial_query()` returns the initial query used in the `_apply_specifications()` -###### `_apply_specifications(specifications: Iterable[Specifications]) -> query` +###### `_apply_specifications()` Applies Specifications to the query. **Must not be used directly.** apply specifications gets a list of specifications and applies them to the query returned in _get_initial_query(). The idea is the following: each specification gets a query and @@ -27,40 +27,47 @@ specifications provided by the user. else that specifies what kind of data we want. -###### `get(*specifications: Specification, lazy: bool = False) -> Union[LazyCommand, Any]:` +###### `get()` get is the function used to query the data storage and return one entity. You supply a list of specifications that get you the entity from the storage. - `specifications: Specifications` - specifications that can be used to specify some conditions in the query - `lazy: bool` - whether you want to execute your query straight away or just build it for the future +- `initial_query = None` - if you want to change the initial query for this query only, then you can provide it as an argument -###### `filter(self, *specifications: Specification, lazy: bool = False) -> Union[LazyCommand, Iterable]:` +###### `filter()` filters is the function used to query the data storage and return many entities. You supply a list of specifications that filter entities in the storage. - `specifications: Specifications` - specifications that can be used to specify some conditions in the query - `lazy: bool` - whether you want to execute your query straight away or just build it for the future +- `initial_query = None` - if you want to change the initial query for this query only, then you can provide it as an argument - -###### `save(self, obj) -> None:` +###### `save()` Adds the objects to the session, so you can commit it latter. This method should not change the final state of the storage, we have UnitOfWork for this(*do not commit your changes, just add them*). -###### `delete(self, obj) -> None:` +###### `delete()` Deletes the objects from the session, so you can commit it latter. This method should not change the final state of the storage, we have UnitOfWork for this(*do not commit your changes, just delete them from your session*). -###### `update(self, obj) -> None:` +###### `update()` Updates the objects in the session, so you can commit it latter. This method should not change the final state of the storage, we have UnitOfWork for this(*do not commit your changes, just update them in your session*). -###### `is_modified(self, obj) -> bool:` +###### `is_modified()` Checks whether an obj was modified or not. If any value changes within the object, then it must return True -###### `refresh(self, obj) -> None:` +###### `refresh()` Updates the object values with the values in the data storage. That can be useful if you want to create an object and get its id that was generated in the storage, or if you just want to have the latest saved version of the object. +###### `count()` +Counts the objects while applying specifications to the query. Give no specifications to +count the whole data storage. +- `specifications: Specifications` - specifications that can be used to specify some conditions in the query +- `lazy: bool` - whether you want to execute your query straight away or just build it for the future + ### Creating your own repository: If you want to create your own repository, then you are going to have to override all the functions above. @@ -95,7 +102,7 @@ from users.repository import UserRepository from products.repository import ProductRepository -def get_by_id(id, repository: BaseRepository) +def get_by_id(id, repository: BaseRepository): return repository.get(filter_specification(id=1)) @@ -200,12 +207,16 @@ user = repository.get( ## SpecificationList SpecificationList is a static class that contains basic specifications for our repository. -Specifications: -- `filter()` - filters the data -- `order()` - filters the data -- `paginate()` - paginates the data(limits the results, offsets them) -- `join()` - joins entities together(join a table, get related data) +Specifications: +###### `filter()` +filters the data +###### `order()` +orders the data +###### `paginate()` +paginates the data(limits the results, offsets them). +###### `join()` +joins entities together(join a table, get related data). The reason we use `SpecificationList` is because we want to have an abstraction for our specifications. Take two examples: @@ -278,6 +289,35 @@ Once you have done that, the repository will use your specifications. > Of course, you can still use specifications directly, but if you ever need to change > the repository, then it may be a problem. + +## LazyCommand +Sometimes we don't want to execute the query right away. For example, for optimization purposes or +some other purpose that requires us to delay the execution. In that case, you want to find `lazy` argument +in the function that you are calling and set it to `True`. After that, a `LazyCommand` is going to be returned. That +object allows you to call it as a function or iterate over it to get the results: + +```python +from assimilator.core.database import BaseRepository + + +def print_all_usernames(repository: BaseRepository): + for user in repository.filter(lazy=True): + print(user.username) + # we don't want to receive a list of all the users, but want to iterate + # through it and only get 1 user at a time + + +def count_users_if_argument_true(do_count, repository: BaseRepository): + count_command = repository.count(lazy=True) + # turn on lazy and get LazyCommand + + if do_count: + return count_command() # call for the result + return -1 + +``` + + ## Unit of Work Unit of work allows us to work with transactions and repositories that change the data. The problem with repository is the transaction management. We want to make our transaction management @@ -292,8 +332,9 @@ They allow us to do the following: 5. Unit of work closes the transaction -###### `__init__(repository: BaseRepository)` -The repository is provided in the UnitOfWork when we create it. The session +###### `__init__()` + +- `repository: BaseRepository` - The repository is provided in the UnitOfWork when we create it. The session to the data storage is going to be taken from the repository. ###### `begin()` @@ -308,7 +349,7 @@ Saves the changes to the data storage. While the repository only adds the tempor function is responsible for the final save. _You need to call it yourself, it will not be called automatically like rollback()_ ###### `close()` -closes the transaction. The function is called automatically. +Closes the transaction. The function is called automatically. #### Here is how you can use UnitOfWork in your code: @@ -346,4 +387,3 @@ def create_user(username: str, uow: UnitOfWork): 1 / 0 # ZeroDivisionError. UnitOfWork calls rollback automatically. uow.commit() # nothing is saved, since the rollback was called. ``` -p diff --git a/docs/patterns/events.md b/docs/patterns/events.md new file mode 100644 index 0000000..7295098 --- /dev/null +++ b/docs/patterns/events.md @@ -0,0 +1,258 @@ +# Events patterns + +## Events and how they work +Event shows changes in your system and listeners(consumers) respond to them. Events contain all the possible things that +other parts of the system may need once they respond to them. That is useful in lots of systems, and this page will describe +the basics of assimilator events. Events use [Pydantic](https://docs.pydantic.dev/) module to ease the process of creation, integration +and parsing. + + +## Event based systems with Assimilator + +1. `Event` - representation of a change in your system that carries all the useful data +2. `EventProducer` - something that produces events(executes the changes in the system and shows it with events) +3. `EventConsumer` - something that waits for the producer to emit various events for it to consume them and execute various operations based on the other changes +4. `EventBus` - combines both producers and consumers in one Entity that can produce and consume simultaneously + + +## Event example with user registration: +1. User sends his registration data to our website +2. We create a new user in the database and emit an `UserCreated` event using an `EventProducer` +3. `EventConsumers` listen to our `UserCreated` event and executes all the operations that must be done once the user is registered + + +## Event +###### `id: int` +Unique identification for the event. +###### `event_name: str` +Name of the event. We can have different events in our system. For example, if we +have an event for User creation and an event for User deletion, then we can name them: + +- User creation: event_name = user_created +- User deletion: event_name = user_deleted + +Those names can help us sort and only listen to specific kind of events. All the names +must be in the past, since an event is the change in the past. +###### `event_date: datetime` +Date of the event. You don't need to change this field since it is assigned by default when an event +is created. + +###### `from_json()` +`from_json()` is a function that is used to convert json data to an event. +That method is in the `JSONParsedMixin` class, and it allows us to quickly convert json +to a Python object. + +- `cls: Type['BaseModel']` - Any [Pydantic](https://docs.pydantic.dev/) class, but typically an `Event` +- `data: str` - json data for our event + +## Create a custom event + +`events.py`: +```python +from assimilator.core.events import Event + +class UserCreated(Event): + user_id: int + username: str + email: str # all the data that could be useful is in the event. + # Since Event is a Pydantic model, we can just create new fields like this + +``` + +`logic.py`: +```python +from assimilator.core.database import UnitOfWork + +from events import UserCreated +from models import User + + +def create_user(username: str, email: str, uow: UnitOfWork): + with uow: + user = User(username=username, email=email) + uow.repository.save(user) + uow.commit() + + # Refresh the object and get the user id from the database + uow.repository.refresh(user) + + event = UserCreated( # we create an event + user_id=user.id, + username=user.username, + email=user.email, + ) +``` + +In that example, we only create an event without publishing it anywhere. Find out how to emit your events below. + +## EventConsumer +`EventConsumer` reads all the incoming events and yields them to the functions that use it. + +###### `start()` +Starts the event consumer by connecting to all the required systems + +###### `close()` +Closes the consumer and finishes the work + +###### `consume()` +Yields incoming events + +> `EventConsumer` uses `StartCloseContextMixin` class that allows us to use context managers(with) +> without calling `start()` or `close()` ourselves + +Here is an example of how you would create and use your `EventConsumer`: + +`events_bus.py`: +```python +from assimilator.core.events import EventConsumer, ExternalEvent + + +class MyEventConsumer(EventConsumer): + def __init__(self, api): + # some object that connects to an external system + self.api = api + + def start(self) -> None: + self.api.connect() + + def close(self) -> None: + self.api.disconnect() + + def consume(self): + while True: + message = self.api.listen() # we receive a message from the API + yield ExternalEvent(**message.convert_to_json()) # parse it +``` + +`logic.py`: + +```python +from events_bus import MyEventConsumer + + +def consume_events(consumer: MyEventConsumer): + with consumer: + for event in events_bus.consume(): + if event.event_name == "user_created": + user_created_handler(UserCreated(**event.data)) + elif event.event_name == "user_deleted": + user_deleted_handler(UserDeleted(**event.data)) +``` + +We create a new `EventConsumer` called `MyEventConsumer`. Then, we use an `api` object +to implement all the functions. After that, we use it in `logic.py` file where we consume +all the events and handle them depending on the `event_name`. + +As you have already noticed, we use something called an `ExternalEvent`. We do that +because all the events that are coming from external sources are unidentified and can only +use specific later. `ExternalEvent` contains all the event data in the `data: dict` field which +can be used later. + +## ExternalEvent +When we listen to external systems, it is sometimes hard to make an event class +that represents a specific class. That is why we use an `ExternalEvent`. It contains all the data +in the `data: dict` field, which can be accessed later in order to use an event class that represents +that specific event. + +- `data: dict` - all the data for the event + +## AckEvent +`AckEvent` is an event that has acknowledgement in it. If you want to show that your event +was processed(acknowledged), then use `AckEvent`. + +- `ack: bool` - whether an event was processed. `False` by default + +## EventProducer +`EventProducer` is the class that produces all the events and sends them. + +###### `start()` +Starts the event producer by connecting to all the required systems. + +###### `close()` +Closes the producer and finishes the work. + +###### `produce()` +Sends an event to an external system for it to be consumed. + +- `event: Event` - the event that must be sent. + +> `EventProducer` uses `StartCloseContextMixin` class that allows us to use context managers(with) +> without calling `start()` or `close()` ourselves + +Here is an example of how you would create and use your `EventProducer`: + +`events_bus.py`: +```python +from assimilator.core.events import EventProducer + + +class MyEventProducer(EventProducer): + def __init__(self, api): + # some object that connects to an external system + self.api = api + + def start(self) -> None: + self.api.connect() + + def close(self) -> None: + self.api.disconnect() + + def produce(self, event: Event) -> None: + self.api.send_event(event.json()) # parse event to json and send it + +``` + +`logic.py`: + +```python +from events_bus import MyEventProducer +from events import UserCreated +from models import User + + +def create_user( + username: str, + email: str, + uow: UnitOfWork, + producer: MyEventProducer, +): + with uow: + user = User(username=username, email=email) + uow.repository.save(user) + uow.commit() + + # Refresh the object and get the user id from the database + uow.repository.refresh(user) + + with producer: + producer.produce( + UserCreated( # we create an event + user_id=user.id, + username=user.username, + email=user.email, + ) + ) # send an event to an external system + +``` + +> `ExternalEvent` must not be used in the producer, since when we emit the events we are the ones +> creating them, so we have a separate class for them with all the data inside. + + +## EventBus +`EventBus` combines both `EventProducer` and `EventConsumer` together. You can use those +classes separately, but sometimes you need one object that combines them. + +###### `__init__()` + +- `consumer: EventConsumer` - the consumer that we want to use +- `producer: EventProducer` - the producer that we want to use + + +###### `produce()` +produces the event using `producer` + +- `event: Event` - an event that has to be emitted + +###### `consume()` +consumes the events using `consumer`. Returns an `Iterator[Event]` From de0ceed89222de8639e3ea3f6afab319993aca05 Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Fri, 23 Dec 2022 13:51:16 +0100 Subject: [PATCH 03/12] Outbox Relay now accepts a producer instead of event bus, added Outbox Relay documentation --- assimilator/alchemy/events/outbox_relay.py | 23 ++++++++-------- assimilator/core/events/outbox_relay.py | 10 ++++--- docs/patterns/events.md | 32 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/assimilator/alchemy/events/outbox_relay.py b/assimilator/alchemy/events/outbox_relay.py index 278c9ed..a535151 100644 --- a/assimilator/alchemy/events/outbox_relay.py +++ b/assimilator/alchemy/events/outbox_relay.py @@ -3,7 +3,7 @@ from assimilator.core.events import Event from assimilator.core.database.unit_of_work import UnitOfWork from assimilator.core.events import OutboxRelay -from assimilator.core.events.events_bus import EventBus +from assimilator.core.events.events_bus import EventProducer def create_outbox_event_model(Base): @@ -24,22 +24,23 @@ def __init__(self, event: Event, *args, **kwargs): class AlchemyOutboxRelay(OutboxRelay): - def __init__(self, outbox_event_model, uow: UnitOfWork, event_bus: EventBus): - super(AlchemyOutboxRelay, self).__init__(uow=uow, event_bus=event_bus) + def __init__(self, outbox_event_model, uow: UnitOfWork, producer: EventProducer): + super(AlchemyOutboxRelay, self).__init__(uow=uow, producer=producer) self.outbox_event_model = outbox_event_model def start(self): - while True: - with self.uow: - events = self.uow.repository.filter() + with self.producer: + while True: + with self.uow: + events = self.uow.repository.filter() - for event in events: - self.event_bus.produce(event) + for event in events: + self.producer.produce(event) - self.acknowledge(events) - self.uow.commit() + self.acknowledge(events) + self.uow.commit() - self.delay_function() + self.delay_function() def delay_function(self): raise NotImplementedError("delay_function() is not implemented") diff --git a/assimilator/core/events/outbox_relay.py b/assimilator/core/events/outbox_relay.py index 3f37ff1..bac0a12 100644 --- a/assimilator/core/events/outbox_relay.py +++ b/assimilator/core/events/outbox_relay.py @@ -1,18 +1,20 @@ from abc import ABC +from typing import Iterable from assimilator.core.database.unit_of_work import UnitOfWork -from assimilator.core.events.events_bus import EventBus +from assimilator.core.events import Event +from assimilator.core.events.events_bus import EventProducer class OutboxRelay(ABC): - def __init__(self, uow: UnitOfWork, event_bus: EventBus): + def __init__(self, uow: UnitOfWork, producer: EventProducer): self.uow = uow - self.event_bus = event_bus + self.producer = producer def start(self): raise NotImplementedError("start() is not implemented") - def acknowledge(self, events): + def acknowledge(self, events: Iterable[Event]): raise NotImplementedError("acknowledge() is not implemented") diff --git a/docs/patterns/events.md b/docs/patterns/events.md index 7295098..4f09ed7 100644 --- a/docs/patterns/events.md +++ b/docs/patterns/events.md @@ -256,3 +256,35 @@ produces the event using `producer` ###### `consume()` consumes the events using `consumer`. Returns an `Iterator[Event]` + + +## Event fails and transaction management +Sometimes we want to be sure that our events are emitted. But, if we use normal +event producers and Unit Of Work separately, we may run into a problem: + +1) User is created(added in the database and unit of work committed it) +2) Event producer encounters an error(the event is not published) +3) Inconsistency: User exists, but consumers do not know about that + +Because of that, we may employ Outbox Relay. It is a pattern that allows us +to save all the events in the database in the same transaction as the main entity. Then, +another program(thread, task, function) gets all the events from the database and ensures that +they are published. We basically save the events to the database in one transaction, emit them in a separate +thing and delete them afterwards. + +## OutboxRelay +This class gets all the events using `UnitOfWork` provided, emits all events, and acknowledges them. + +###### `__init__()` +- `uow: UnitOfWork` - unit of work that is used in order to get the events, acknowledge them +- `producer: EventProducer` - event producer that we use to publish the events + +###### `start()` +Start the relay. This function must run forever, must get the events from the repository from unit of work, +and produce the events. After that, it must call `acknowledge()` to show that these events are produced. + +###### acknowledge() +Acknowledges the events in the database. It might change a boolean column for these events, +might delete them, but the idea is that those events will not be produced twice. + +- `events: Iterable[Event]` - events that must be acknowledged From 5d371ac21a225535ac5bf15e7f09ccfe79bb387d Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Sat, 14 Jan 2023 12:30:32 +0100 Subject: [PATCH 04/12] Added ErrorWrapper pattern to change external exceptions to assimilator exceptions, added model to the AlchemyRepository, fixed AlchemyRepository count() bug --- assimilator/alchemy/database/error_wrapper.py | 13 +++++ assimilator/alchemy/database/repository.py | 53 ++++++++++++------- .../alchemy/database/specifications.py | 2 +- assimilator/alchemy/database/unit_of_work.py | 20 ++++--- .../alchemy/events/database/repository.py | 26 ++++++++- assimilator/alchemy/events/outbox_relay.py | 2 +- assimilator/core/database/repository.py | 20 +++++-- assimilator/core/events/outbox_relay.py | 2 +- assimilator/core/patterns/error_wrapper.py | 27 ++++++++++ assimilator/internal/events/events_bus.py | 2 +- setup.py | 2 +- 11 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 assimilator/alchemy/database/error_wrapper.py create mode 100644 assimilator/core/patterns/error_wrapper.py diff --git a/assimilator/alchemy/database/error_wrapper.py b/assimilator/alchemy/database/error_wrapper.py new file mode 100644 index 0000000..c77d152 --- /dev/null +++ b/assimilator/alchemy/database/error_wrapper.py @@ -0,0 +1,13 @@ +from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError + +from assimilator.core.database.exceptions import DataLayerError, NotFoundError, InvalidQueryError +from assimilator.core.patterns.error_wrapper import ErrorWrapper + + +class AlchemyErrorWrapper(ErrorWrapper): + def __init__(self): + super(AlchemyErrorWrapper, self).__init__(error_mappings={ + NoResultFound: NotFoundError, + IntegrityError: InvalidQueryError, + SQLAlchemyError: DataLayerError, + }, default_error=DataLayerError) diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index 943634c..d28ca60 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -1,46 +1,49 @@ from typing import Type, Union -from sqlalchemy import func, select -from sqlalchemy.exc import NoResultFound -from sqlalchemy.orm import Query +from sqlalchemy import func, select, Table +from sqlalchemy.orm import Session, Query +from sqlalchemy.inspection import inspect +from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper +from assimilator.core.database.exceptions import InvalidQueryError from assimilator.alchemy.database.specifications import AlchemySpecificationList -from assimilator.core.database import BaseRepository, Specification, SpecificationList, LazyCommand -from assimilator.core.database.exceptions import NotFoundError +from assimilator.core.database import BaseRepository, Specification, \ + SpecificationList, LazyCommand, SpecificationType +from assimilator.core.patterns.error_wrapper import ErrorWrapper class AlchemyRepository(BaseRepository): def __init__( self, - session, + session: Session, + model: Type['Table'], initial_query: Query = None, specifications: Type[SpecificationList] = AlchemySpecificationList, + error_wrapper: ErrorWrapper = None, ): super(AlchemyRepository, self).__init__( session=session, - initial_query=initial_query, + model=model, + initial_query=initial_query if initial_query is not None else select(model), specifications=specifications, ) + self.error_wrapper = error_wrapper if error_wrapper is not None else AlchemyErrorWrapper() - def _execute_query(self, query): - return self.session.execute(query) - - def get(self, *specifications: Specification, lazy: bool = False, initial_query=None): - try: + def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): + with self.error_wrapper: if lazy: return LazyCommand(self.get, *specifications, initial_query=initial_query, lazy=False) query = self._apply_specifications(specifications, initial_query=initial_query) - return self._execute_query(query).one()[0] - except NoResultFound as exc: - raise NotFoundError(exc) + return self.session.execute(query).one()[0] def filter(self, *specifications: Specification, lazy: bool = False, initial_query=None): - if lazy: - return LazyCommand(self.filter, *specifications, initial_query=initial_query, lazy=False) + with self.error_wrapper: + if lazy: + return LazyCommand(self.filter, *specifications, initial_query=initial_query, lazy=False) - query = self._apply_specifications(specifications, initial_query=initial_query) - return [result[0] for result in self._execute_query(query)] + query = self._apply_specifications(specifications, initial_query=initial_query) + return [result[0] for result in self.session.execute(query)] def update(self, obj): """ We don't do anything, as the object is going to be updated with the obj.key = value """ @@ -58,7 +61,17 @@ def is_modified(self, obj) -> bool: return self.session.is_modified(obj) def count(self, *specifications, lazy: bool = False) -> Union[LazyCommand, int]: - return self.get(*specifications, lazy=lazy, initial_query=select(func.count())) + with self.error_wrapper: + primary_keys = inspect(self.model).primary_key + + if not primary_keys: + raise InvalidQueryError("Your repository model does not have any primary keys. We cannot use count()") + + return self.get( + *specifications, + lazy=lazy, + initial_query=select(func.count(getattr(self.model, primary_keys[0].name))), + ) __all__ = [ diff --git a/assimilator/alchemy/database/specifications.py b/assimilator/alchemy/database/specifications.py index e05eb2d..2f92367 100644 --- a/assimilator/alchemy/database/specifications.py +++ b/assimilator/alchemy/database/specifications.py @@ -1,4 +1,4 @@ -from typing import Iterable, Collection +from typing import Collection from sqlalchemy.orm import Query diff --git a/assimilator/alchemy/database/unit_of_work.py b/assimilator/alchemy/database/unit_of_work.py index 4e3a5bf..2d74bdf 100644 --- a/assimilator/alchemy/database/unit_of_work.py +++ b/assimilator/alchemy/database/unit_of_work.py @@ -1,24 +1,28 @@ -from sqlalchemy.exc import IntegrityError - +from assimilator.alchemy.database.repository import AlchemyRepository +from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper from assimilator.core.database.unit_of_work import UnitOfWork -from assimilator.core.database.exceptions import InvalidQueryError +from assimilator.core.patterns.error_wrapper import ErrorWrapper class AlchemyUnitOfWork(UnitOfWork): + def __init__(self, repository: AlchemyRepository, error_wrapper: ErrorWrapper = None): + super(AlchemyUnitOfWork, self).__init__(repository) + self.error_wrapper = error_wrapper if error_wrapper is not None else AlchemyErrorWrapper() + def begin(self): - self.repository.session.begin() + with self.error_wrapper: + self.repository.session.begin() def rollback(self): - self.repository.session.rollback() + with self.error_wrapper: + self.repository.session.rollback() def close(self): pass def commit(self): - try: + with self.error_wrapper: self.repository.session.commit() - except IntegrityError as exc: - raise InvalidQueryError(exc) __all__ = [ diff --git a/assimilator/alchemy/events/database/repository.py b/assimilator/alchemy/events/database/repository.py index 8ed83b0..67d8a1a 100644 --- a/assimilator/alchemy/events/database/repository.py +++ b/assimilator/alchemy/events/database/repository.py @@ -1,9 +1,31 @@ +from typing import Type, Optional + +from sqlalchemy import Table +from sqlalchemy.orm import Query + +from assimilator.alchemy import AlchemySpecificationList from assimilator.alchemy.database.repository import AlchemyRepository +from assimilator.core.database import SpecificationList +from assimilator.core.patterns.error_wrapper import ErrorWrapper class AlchemyOutboxRepository(AlchemyRepository): - def __init__(self, event_model, session, initial_query=None): - super(AlchemyOutboxRepository, self).__init__(session, initial_query) + def __init__( + self, + session, + event_model: Type[Table], + model: Type[Table], + initial_query: Optional[Query] = None, + specifications: Type[SpecificationList] = AlchemySpecificationList, + error_wrapper: ErrorWrapper = None, + ): + super(AlchemyOutboxRepository, self).__init__( + session=session, + initial_query=initial_query, + model=model, + specifications=specifications, + error_wrapper=error_wrapper, + ) self.event_model = event_model def save(self, obj): diff --git a/assimilator/alchemy/events/outbox_relay.py b/assimilator/alchemy/events/outbox_relay.py index a535151..0c65ef2 100644 --- a/assimilator/alchemy/events/outbox_relay.py +++ b/assimilator/alchemy/events/outbox_relay.py @@ -1,6 +1,6 @@ from sqlalchemy import Column, BigInteger, Text, DateTime -from assimilator.core.events import Event +from assimilator.core.events.events import Event from assimilator.core.database.unit_of_work import UnitOfWork from assimilator.core.events import OutboxRelay from assimilator.core.events.events_bus import EventProducer diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index 0c76176..827c8d6 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -31,14 +31,26 @@ def __bool__(self): class BaseRepository(ABC): - def __init__(self, session: Any, specifications: Type[SpecificationList], initial_query: Optional[Any] = None): + def __init__( + self, + session: Any, + model: Type, + specifications: Type[SpecificationList], + initial_query: Optional[Any] = None, + ): self.session = session - self.initial_query = initial_query + self.model = model + self.__initial_query = initial_query self.specifications = specifications + @property + def specs(self): + """ That property is used to shorten the full name of the self.specifications. You can use any of them """ + return self.specifications + def _get_initial_query(self): - if self.initial_query is not None: - return self.initial_query + if self.__initial_query is not None: + return self.__initial_query else: raise NotImplementedError("You must either pass the initial query or define get_initial_query()") diff --git a/assimilator/core/events/outbox_relay.py b/assimilator/core/events/outbox_relay.py index bac0a12..6752c46 100644 --- a/assimilator/core/events/outbox_relay.py +++ b/assimilator/core/events/outbox_relay.py @@ -2,7 +2,7 @@ from typing import Iterable from assimilator.core.database.unit_of_work import UnitOfWork -from assimilator.core.events import Event +from assimilator.core.events.events import Event from assimilator.core.events.events_bus import EventProducer diff --git a/assimilator/core/patterns/error_wrapper.py b/assimilator/core/patterns/error_wrapper.py new file mode 100644 index 0000000..94404a2 --- /dev/null +++ b/assimilator/core/patterns/error_wrapper.py @@ -0,0 +1,27 @@ +from typing import Dict, Type, Optional + + +class ErrorWrapper: + def __init__( + self, + error_mappings: Dict[Type[Exception], Type[Exception]], + default_error: Optional[Type[Exception]] = None, + ): + self.error_mappings = error_mappings + self.default_error = default_error + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is None: + return + + for initial_error, wrapped_error in self.error_mappings.items(): + if isinstance(exc_type, initial_error): + raise wrapped_error(exc_val) + + if self.default_error is not None: + raise self.default_error(exc_val) + + raise exc_val # No wrapping error was found diff --git a/assimilator/internal/events/events_bus.py b/assimilator/internal/events/events_bus.py index 27d7e66..73b29aa 100644 --- a/assimilator/internal/events/events_bus.py +++ b/assimilator/internal/events/events_bus.py @@ -1,4 +1,4 @@ -from assimilator.core.events import Event +from assimilator.core.events.events import Event from assimilator.core.events.events_bus import EventConsumer, EventProducer diff --git a/setup.py b/setup.py index 02242c7..8b67c5a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='py_assimilator', - version='0.2.5', + version='0.2.6', author='Andrey Ivanov', author_email='python.on.papyrus@gmail.com', url='https://pypi.python.org/pypi/py_assimilator/', From 66583d8c11408859ee8636c54b073bb17234d93b Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Sat, 14 Jan 2023 17:22:15 +0100 Subject: [PATCH 05/12] Added specifications for internal repository, fixed arguments and added error_wrapper for InternalRepository and RedisRepository --- assimilator/alchemy/database/repository.py | 12 ++- assimilator/core/database/repository.py | 21 ++++- assimilator/core/patterns/error_wrapper.py | 4 +- .../internal/database/error_wrapper.py | 10 +++ assimilator/internal/database/models.py | 4 +- assimilator/internal/database/repository.py | 84 +++++++++++-------- .../internal/database/specifications.py | 18 ++-- assimilator/redis/database/models.py | 7 +- assimilator/redis/database/repository.py | 70 ++++++++++------ 9 files changed, 145 insertions(+), 85 deletions(-) create mode 100644 assimilator/internal/database/error_wrapper.py diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index d28ca60..ff6377f 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -9,6 +9,7 @@ from assimilator.alchemy.database.specifications import AlchemySpecificationList from assimilator.core.database import BaseRepository, Specification, \ SpecificationList, LazyCommand, SpecificationType +from assimilator.core.database.repository import make_lazy from assimilator.core.patterns.error_wrapper import ErrorWrapper @@ -29,19 +30,15 @@ def __init__( ) self.error_wrapper = error_wrapper if error_wrapper is not None else AlchemyErrorWrapper() + @make_lazy def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): with self.error_wrapper: - if lazy: - return LazyCommand(self.get, *specifications, initial_query=initial_query, lazy=False) - query = self._apply_specifications(specifications, initial_query=initial_query) return self.session.execute(query).one()[0] + @make_lazy def filter(self, *specifications: Specification, lazy: bool = False, initial_query=None): with self.error_wrapper: - if lazy: - return LazyCommand(self.filter, *specifications, initial_query=initial_query, lazy=False) - query = self._apply_specifications(specifications, initial_query=initial_query) return [result[0] for result in self.session.execute(query)] @@ -60,6 +57,7 @@ def delete(self, obj): def is_modified(self, obj) -> bool: return self.session.is_modified(obj) + @make_lazy def count(self, *specifications, lazy: bool = False) -> Union[LazyCommand, int]: with self.error_wrapper: primary_keys = inspect(self.model).primary_key @@ -69,7 +67,7 @@ def count(self, *specifications, lazy: bool = False) -> Union[LazyCommand, int]: return self.get( *specifications, - lazy=lazy, + lazy=False, initial_query=select(func.count(getattr(self.model, primary_keys[0].name))), ) diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index 827c8d6..b242edf 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -1,5 +1,6 @@ +import functools from abc import ABC, abstractmethod -from typing import Union, Any, Optional, Callable, Iterable, Type, Container +from typing import Union, Any, Optional, Callable, Iterable, Type, Container, Collection from assimilator.core.database.specifications import SpecificationList, SpecificationType @@ -30,11 +31,24 @@ def __bool__(self): return bool(self()) +def make_lazy(func: callable): + + @functools.wraps(func) + def make_lazy_wrapper(self, *args, **kwargs): + if kwargs.get('lazy') is True: + kwargs['lazy'] = False + return LazyCommand(command=func, *args, **kwargs) + + return func(self, *args, **kwargs) + + return make_lazy_wrapper + + class BaseRepository(ABC): def __init__( self, session: Any, - model: Type, + model: Type[Any], specifications: Type[SpecificationList], initial_query: Optional[Any] = None, ): @@ -69,7 +83,7 @@ def get(self, *specifications: SpecificationType, lazy: bool = False, initial_qu @abstractmethod def filter(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None)\ - -> Union[LazyCommand, Container]: + -> Union[LazyCommand, Collection]: raise NotImplementedError("filter() is not implemented()") @abstractmethod @@ -100,4 +114,5 @@ def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union __all__ = [ 'LazyCommand', 'BaseRepository', + 'make_lazy', ] diff --git a/assimilator/core/patterns/error_wrapper.py b/assimilator/core/patterns/error_wrapper.py index 94404a2..4ddb03f 100644 --- a/assimilator/core/patterns/error_wrapper.py +++ b/assimilator/core/patterns/error_wrapper.py @@ -4,10 +4,10 @@ class ErrorWrapper: def __init__( self, - error_mappings: Dict[Type[Exception], Type[Exception]], + error_mappings: Dict[Type[Exception], Type[Exception]] = None, default_error: Optional[Type[Exception]] = None, ): - self.error_mappings = error_mappings + self.error_mappings = error_mappings or {} self.default_error = default_error def __enter__(self): diff --git a/assimilator/internal/database/error_wrapper.py b/assimilator/internal/database/error_wrapper.py new file mode 100644 index 0000000..cf23903 --- /dev/null +++ b/assimilator/internal/database/error_wrapper.py @@ -0,0 +1,10 @@ +from assimilator.core.database.exceptions import DataLayerError, NotFoundError +from assimilator.core.patterns.error_wrapper import ErrorWrapper + + +class InternalErrorWrapper(ErrorWrapper): + def __init__(self): + super(InternalErrorWrapper, self).__init__(error_mappings={ + KeyError: NotFoundError, + TypeError: NotFoundError, + }, default_error=DataLayerError) diff --git a/assimilator/internal/database/models.py b/assimilator/internal/database/models.py index 37ba4e1..ade9a89 100644 --- a/assimilator/internal/database/models.py +++ b/assimilator/internal/database/models.py @@ -2,6 +2,8 @@ from pydantic import BaseModel +from assimilator.core.patterns.mixins import JSONParsedMixin -class InternalModel(BaseModel): + +class InternalModel(JSONParsedMixin, BaseModel): id: Any diff --git a/assimilator/internal/database/repository.py b/assimilator/internal/database/repository.py index d30897f..8883b72 100644 --- a/assimilator/internal/database/repository.py +++ b/assimilator/internal/database/repository.py @@ -1,71 +1,87 @@ import re -from typing import Type, Union +from typing import Type, Union, Optional, Tuple -from assimilator.core.database.exceptions import NotFoundError +from assimilator.core.database.repository import make_lazy +from assimilator.internal.database.models import InternalModel +from assimilator.core.patterns.error_wrapper import ErrorWrapper +from assimilator.internal.database.error_wrapper import InternalErrorWrapper +from assimilator.internal.database.specifications import InternalSpecificationList from assimilator.core.database import BaseRepository, SpecificationList, SpecificationType, LazyCommand -from assimilator.internal.database.specifications import InternalSpecification, InternalSpecificationList class InternalRepository(BaseRepository): def __init__( self, session: dict, + model: Type[InternalModel], + initial_query: str = '', specifications: Type[SpecificationList] = InternalSpecificationList, - initial_keyname: str = '', + error_wrapper: Optional[ErrorWrapper] = None, ): super(InternalRepository, self).__init__( + model=model, session=session, - initial_query=initial_keyname, + initial_query=initial_query, specifications=specifications, ) + self.error_wrapper = error_wrapper if error_wrapper is not None else InternalErrorWrapper() - def get(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None): - try: - if lazy: - return LazyCommand(self.get, *specifications, lazy=False, initial_query=initial_query) + def __parse_specifications(self, specifications: Tuple): + before_specs, after_specs = [], [] + for specification in specifications: + if specification is self.specs.filter: + before_specs.append(specification) + else: + after_specs.append(specification) + + return before_specs, after_specs + + @make_lazy + def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): + with self.error_wrapper: return self.session[self._apply_specifications(specifications, initial_query=initial_query)] - except (KeyError, TypeError) as exc: - raise NotFoundError(exc) - def filter(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None): - if lazy: - return LazyCommand(self.filter, *specifications, lazy=False, initial_query=initial_query) + @make_lazy + def filter(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): + with self.error_wrapper: + before_specs, after_specs = self.__parse_specifications(specifications) - if not specifications: - return list(self.session.values()) + if before_specs: + models = [] + key_mask = self._apply_specifications(specifications=before_specs, initial_query=initial_query) + for key, value in self.session.items(): + if re.match(key_mask, key): + models.append(value) - key_mask = self._apply_specifications(specifications, initial_query=initial_query) - models = [] - for key, value in self.session.items(): - if re.match(key_mask, key): - models.append(value) + else: + models = list(self.session.values()) - return models + for specification in after_specs: + models = specification(models) + return models def save(self, obj): - self.session[str(obj.id)] = obj + self.session[obj.id] = obj def delete(self, obj): - del self.session[str(obj.id)] + del self.session[obj.id] def update(self, obj): - self.session[str(obj.id)] = obj + self.save(obj) def is_modified(self, obj): - return self.get(self.specifications.filter(obj.id)) == obj + return self.get(self.specs.filter(id=obj.id)) == obj def refresh(self, obj): - obj.value = self.get(self.specifications.filter(obj.id)) + fresh_obj = self.get(self.specs.filter(id=obj.id), lazy=False) + obj.__dict__.update(fresh_obj.__dict__) + @make_lazy def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: - if lazy: - return LazyCommand(self.count, *specifications, lazy=False) - elif not specifications: - return len(self.session) - - results: LazyCommand = self.filter(*specifications, lazy=True) - return len(results()) + if specifications: + return len(self.filter(*specifications, lazy=False)) + return len(self.session) __all__ = [ diff --git a/assimilator/internal/database/specifications.py b/assimilator/internal/database/specifications.py index 55e8450..59419f9 100644 --- a/assimilator/internal/database/specifications.py +++ b/assimilator/internal/database/specifications.py @@ -1,4 +1,7 @@ +from typing import List + from assimilator.core.database import Specification, specification, SpecificationList +from assimilator.internal.database.models import InternalModel class InternalSpecification(Specification): @@ -7,18 +10,21 @@ def apply(self, query: str) -> str: # returns the str key @specification -def internal_filter(key: str, query: str) -> str: - return f"{query}{key}" +def internal_filter(*args, query: str, **kwargs) -> str: + return f'{query}{"".join(*args)}' @specification -def internal_order(*args, query: str, **kwargs) -> str: - return query +def internal_order(*args, query: List[InternalModel], **kwargs) -> List[InternalModel]: + return sorted( + query, + key=lambda item: [getattr(item, argument) for argument in (args, *kwargs.keys())], + ) @specification -def internal_paginate(*args, query: str, **kwargs) -> str: - return query +def internal_paginate(limit: int, offset: int, query: List[InternalModel]) -> List[InternalModel]: + return query[limit:offset] @specification diff --git a/assimilator/redis/database/models.py b/assimilator/redis/database/models.py index 2feab4f..a349e68 100644 --- a/assimilator/redis/database/models.py +++ b/assimilator/redis/database/models.py @@ -1,12 +1,9 @@ from typing import Optional -from pydantic import BaseModel +from assimilator.internal.database.models import InternalModel -from assimilator.core.patterns.mixins import JSONParsedMixin - -class RedisModel(JSONParsedMixin, BaseModel): - id: int +class RedisModel(InternalModel): expire_in: Optional[int] = None diff --git a/assimilator/redis/database/repository.py b/assimilator/redis/database/repository.py index 7b71651..035d595 100644 --- a/assimilator/redis/database/repository.py +++ b/assimilator/redis/database/repository.py @@ -1,38 +1,56 @@ -from typing import Type, Union, Iterable +from typing import Type, Union, Iterable, Optional -import redis +from redis import Redis -from assimilator.core.database import SpecificationList, SpecificationType from assimilator.redis.database import RedisModel -from assimilator.core.database.repository import BaseRepository, LazyCommand +from assimilator.core.database.exceptions import DataLayerError +from assimilator.core.patterns.error_wrapper import ErrorWrapper +from assimilator.core.database import SpecificationList, SpecificationType +from assimilator.core.database.repository import BaseRepository, LazyCommand, make_lazy from assimilator.internal.database.specifications import InternalSpecification, InternalSpecificationList class RedisRepository(BaseRepository): + model: Type[RedisModel] + def __init__( self, - session: redis.Redis, + session: Redis, model: Type[RedisModel], + initial_query: str = '', specifications: Type[SpecificationList] = InternalSpecificationList, + error_wrapper: Optional[ErrorWrapper] = None, ): - super(RedisRepository, self).__init__(session, initial_query='', specifications=specifications) - self.model = model - - def get(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None)\ - -> Union[LazyCommand, RedisModel]: - key_name = self._apply_specifications(specifications, initial_query=initial_query) - if lazy: - return LazyCommand(lambda: self.model.from_json(self.session.get(key_name))) - - return self.model.from_json(self.session.get(key_name)) - - def filter(self, *specifications: InternalSpecification, lazy: bool = False, initial_query=None)\ - -> Union[LazyCommand, Iterable['RedisModel']]: - if lazy: - return LazyCommand(self.filter, *specifications, lazy=False, initial_query=initial_query) - + super(RedisRepository, self).__init__( + session=session, + model=model, + initial_query=initial_query, + specifications=specifications, + ) + self.error_wrapper = error_wrapper if not error_wrapper else ErrorWrapper(default_error=DataLayerError) + + @make_lazy + def get( + self, + *specifications: InternalSpecification, + lazy: bool = False, + initial_query: str = None, + ) -> Union[LazyCommand, RedisModel]: + query = self._apply_specifications(specifications, initial_query=initial_query) + return self.model.from_json(self.session.get(query)) + + @make_lazy + def filter( + self, + *specifications: InternalSpecification, + lazy: bool = False, + initial_query: str = None, + ) -> Union[LazyCommand, Iterable[RedisModel]]: key_name = self._apply_specifications(specifications, initial_query=initial_query) - return [self.model.from_json(value) for value in self.session.mget(self.session.keys(key_name))] + return [ + self.model.from_json(value) + for value in self.session.mget(self.session.keys(key_name)) + ] def save(self, obj: RedisModel): self.session.set(str(obj.id), obj.json(), ex=obj.expire_in) @@ -52,13 +70,11 @@ def refresh(self, obj: RedisModel): for key, value in fresh_obj.dict().items(): setattr(obj, key, value) + @make_lazy def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: - if lazy: - return LazyCommand(self.count, *specifications, lazy=False) - elif not specifications: + if specifications: return self.session.dbsize() - else: - return len(self.session.keys(self._apply_specifications(specifications))) + return len(self.session.keys(self._apply_specifications(specifications))) __all__ = [ From 5b088d638b77cb63ecc0a6304b75797ef68ba16a Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Sun, 15 Jan 2023 18:09:35 +0100 Subject: [PATCH 06/12] Added new logos to the project --- README.md | 2 ++ docs/images/logo.png | Bin 0 -> 127604 bytes docs/images/logo.svg | 14 ++++++++++++++ docs/images/logo_white.svg | 4 ++++ docs/index.md | 2 ++ mkdocs.yml | 25 +++++++++++++++++++++++-- 6 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 docs/images/logo.png create mode 100644 docs/images/logo.svg create mode 100644 docs/images/logo_white.svg diff --git a/README.md b/README.md index 856466d..0091d6e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Assimilator - the best Python patterns for the best projects +![](/images/logo.png) + ## Install now * `pip install py_assimilator` diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f75fc0cc851ddca11b1a46cf499e5825e96685a8 GIT binary patch literal 127604 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 PP2jTC)v0g`De->+= + + + + + + + diff --git a/docs/images/logo_white.svg b/docs/images/logo_white.svg new file mode 100644 index 0000000..d8fddfa --- /dev/null +++ b/docs/images/logo_white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/index.md b/docs/index.md index 856466d..0091d6e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,7 @@ # Assimilator - the best Python patterns for the best projects +![](/images/logo.png) + ## Install now * `pip install py_assimilator` diff --git a/mkdocs.yml b/mkdocs.yml index 0bf67f1..d38048d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,27 @@ site_name: Assimilator - the best Python patterns +site_description: Assimilator Python framework, Domain-Driven Design, DDD, high performance, easy to learn, fast to code theme: name: material + logo: images/logo.png + features: + - search.suggest + - search.highlight + - content.tabs.link + palette: + - media: '(prefers-color-scheme: light)' + scheme: default + primary: deep purple + accent: purple + toggle: + icon: material/lightbulb + name: Switch to light mode + - media: '(prefers-color-scheme: dark)' + scheme: slate + primary: deep purple + accent: purple + toggle: + icon: material/lightbulb-outline + name: Switch to dark mode nav: - Introduction: index.md - Concepts: concepts.md @@ -21,5 +42,5 @@ nav: - Advanced topics: - Core: core/core.md repo_url: https://github.com/knucklesuganda/py_assimilator -site_description: PyAssimilator allows you to write the best patterns in your projects. -site_author: Andrey Ivanov +repo_name: knucklesuganda/py_assimilator +site_author: Andrey Ivanov | Python From c4b4490f59c02794b32eb7bc8855ca8941202cf6 Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Tue, 17 Jan 2023 20:35:30 +0100 Subject: [PATCH 07/12] Added UnitOfWork as a parameter in crud service, moved lazy_command to a separate file, refactored get_initial_query(), added more annotations for BaseRepository, --- assimilator/alchemy/database/error_wrapper.py | 2 +- assimilator/alchemy/database/repository.py | 17 ++- .../alchemy/events/database/repository.py | 2 +- assimilator/core/database/__init__.py | 1 + assimilator/core/database/exceptions.py | 6 +- assimilator/core/database/repository.py | 111 +++++++++--------- assimilator/core/patterns/__init__.py | 2 + assimilator/core/patterns/error_wrapper.py | 3 + assimilator/core/patterns/lazy_command.py | 35 ++++++ assimilator/core/services/crud.py | 40 +++---- assimilator/internal/database/repository.py | 33 ++++-- assimilator/redis/database/repository.py | 36 ++++-- 12 files changed, 174 insertions(+), 114 deletions(-) create mode 100644 assimilator/core/patterns/lazy_command.py diff --git a/assimilator/alchemy/database/error_wrapper.py b/assimilator/alchemy/database/error_wrapper.py index c77d152..2e15725 100644 --- a/assimilator/alchemy/database/error_wrapper.py +++ b/assimilator/alchemy/database/error_wrapper.py @@ -1,4 +1,4 @@ -from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError +from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError, MultipleResultsFound from assimilator.core.database.exceptions import DataLayerError, NotFoundError, InvalidQueryError from assimilator.core.patterns.error_wrapper import ErrorWrapper diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index ff6377f..12287bc 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -8,8 +8,7 @@ from assimilator.core.database.exceptions import InvalidQueryError from assimilator.alchemy.database.specifications import AlchemySpecificationList from assimilator.core.database import BaseRepository, Specification, \ - SpecificationList, LazyCommand, SpecificationType -from assimilator.core.database.repository import make_lazy + SpecificationList, LazyCommand, SpecificationType, make_lazy from assimilator.core.patterns.error_wrapper import ErrorWrapper @@ -33,16 +32,22 @@ def __init__( @make_lazy def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): with self.error_wrapper: - query = self._apply_specifications(specifications, initial_query=initial_query) + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=specifications, + ) return self.session.execute(query).one()[0] @make_lazy def filter(self, *specifications: Specification, lazy: bool = False, initial_query=None): with self.error_wrapper: - query = self._apply_specifications(specifications, initial_query=initial_query) + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=specifications, + ) return [result[0] for result in self.session.execute(query)] - def update(self, obj): + def update(self, obj) -> None: """ We don't do anything, as the object is going to be updated with the obj.key = value """ def save(self, obj): @@ -68,7 +73,7 @@ def count(self, *specifications, lazy: bool = False) -> Union[LazyCommand, int]: return self.get( *specifications, lazy=False, - initial_query=select(func.count(getattr(self.model, primary_keys[0].name))), + query=select(func.count(getattr(self.model, primary_keys[0].name))), ) diff --git a/assimilator/alchemy/events/database/repository.py b/assimilator/alchemy/events/database/repository.py index 67d8a1a..f9ab337 100644 --- a/assimilator/alchemy/events/database/repository.py +++ b/assimilator/alchemy/events/database/repository.py @@ -3,7 +3,7 @@ from sqlalchemy import Table from sqlalchemy.orm import Query -from assimilator.alchemy import AlchemySpecificationList +from assimilator.alchemy.database.specifications import AlchemySpecificationList from assimilator.alchemy.database.repository import AlchemyRepository from assimilator.core.database import SpecificationList from assimilator.core.patterns.error_wrapper import ErrorWrapper diff --git a/assimilator/core/database/__init__.py b/assimilator/core/database/__init__.py index c7ad9ac..8bd88e3 100644 --- a/assimilator/core/database/__init__.py +++ b/assimilator/core/database/__init__.py @@ -1,3 +1,4 @@ from assimilator.core.database.repository import * from assimilator.core.database.specifications import * from assimilator.core.database.unit_of_work import * +from assimilator.core.database.exceptions import * diff --git a/assimilator/core/database/exceptions.py b/assimilator/core/database/exceptions.py index c055049..8da175f 100644 --- a/assimilator/core/database/exceptions.py +++ b/assimilator/core/database/exceptions.py @@ -1,12 +1,12 @@ class DataLayerError(Exception): - pass + """ Any error related to Repository, UnitOfWork, Model """ class NotFoundError(DataLayerError): - pass + """ Results are not found """ class InvalidQueryError(DataLayerError): - pass + """ The query to the data storage supplied was invalid """ diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index b242edf..35c22d4 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -1,113 +1,110 @@ -import functools +from functools import wraps +from typing import TypeVar, Callable, Generic, final from abc import ABC, abstractmethod -from typing import Union, Any, Optional, Callable, Iterable, Type, Container, Collection +from typing import Union, Optional, Iterable, Type, Collection +from assimilator.core.patterns.lazy_command import LazyCommand from assimilator.core.database.specifications import SpecificationList, SpecificationType -class LazyCommand: - def __init__(self, command: Callable, *args, **kwargs): - self.command = command - self.args = args - self.kwargs = kwargs - self._results = None +def make_lazy(func: Callable): - def __call__(self) -> Union[Container, Any]: - if self._results is not None: - return self._results - - self._results = self.command(*self.args, **self.kwargs) - return self._results - - def __iter__(self): - results = self() - - if not isinstance(results, Iterable): # get() command - raise StopIteration("Results are not iterable") - - return iter(results) # filter() command - - def __bool__(self): - return bool(self()) - - -def make_lazy(func: callable): + @wraps(func) + def make_lazy_wrapper( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: QueryT = None, + ): + if lazy: + return LazyCommand(func, self, *specifications, lazy=False, initial_query=initial_query) + return func(*specifications, lazy=False, initial_query=initial_query) - @functools.wraps(func) - def make_lazy_wrapper(self, *args, **kwargs): - if kwargs.get('lazy') is True: - kwargs['lazy'] = False - return LazyCommand(command=func, *args, **kwargs) + return make_lazy_wrapper - return func(self, *args, **kwargs) - return make_lazy_wrapper +QueryT = TypeVar("QueryT") +ModelT = TypeVar("ModelT") +SessionT = TypeVar("SessionT") -class BaseRepository(ABC): +class BaseRepository(Generic[SessionT, ModelT, QueryT], ABC): def __init__( self, - session: Any, - model: Type[Any], + session: SessionT, + model: Type[ModelT], specifications: Type[SpecificationList], - initial_query: Optional[Any] = None, + initial_query: Optional[SessionT] = None, ): self.session = session self.model = model - self.__initial_query = initial_query + self.__initial_query: QueryT = initial_query self.specifications = specifications @property - def specs(self): - """ That property is used to shorten the full name of the self.specifications. You can use any of them """ + def specs(self) -> Type[SpecificationList]: + """ That property is used to shorten the full name of the self.specifications. """ return self.specifications - def _get_initial_query(self): - if self.__initial_query is not None: + def get_initial_query(self, override_query: Optional[QueryT] = None) -> QueryT: + if override_query is not None: + return override_query + elif self.__initial_query is not None: return self.__initial_query else: raise NotImplementedError("You must either pass the initial query or define get_initial_query()") - def _apply_specifications(self, specifications: Iterable[SpecificationType], initial_query=None) -> Any: - query = self._get_initial_query() if initial_query is None else initial_query - + @final + def _apply_specifications(self, query: QueryT, specifications: Iterable[SpecificationType]) -> QueryT: for specification in specifications: query = specification(query) return query @abstractmethod - def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None)\ - -> Union[LazyCommand, Any]: + def get( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: QueryT = None, + ) -> Union[ModelT, LazyCommand[ModelT]]: raise NotImplementedError("get() is not implemented()") @abstractmethod - def filter(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None)\ - -> Union[LazyCommand, Collection]: + def filter( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: QueryT = None, + ) -> Union[Collection[ModelT], LazyCommand[Collection[ModelT]]]: raise NotImplementedError("filter() is not implemented()") @abstractmethod - def save(self, obj) -> None: + def save(self, obj: ModelT) -> None: raise NotImplementedError("save() is not implemented in the repository") @abstractmethod - def delete(self, obj) -> None: + def delete(self, obj: ModelT) -> None: raise NotImplementedError("delete() is not implemented in the repository") @abstractmethod - def update(self, obj) -> None: + def update(self, obj: ModelT) -> None: raise NotImplementedError("update() is not implemented in the repository") @abstractmethod - def is_modified(self, obj) -> bool: + def is_modified(self, obj: ModelT) -> bool: raise NotImplementedError("is_modified() is not implemented in the repository") @abstractmethod - def refresh(self, obj) -> None: + def refresh(self, obj: ModelT) -> None: raise NotImplementedError("refresh() is not implemented in the repository") @abstractmethod - def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: + def count( + self, + *specifications: SpecificationType, + lazy: bool = False, + ) -> Union[LazyCommand[int], int]: raise NotImplementedError("count() is not implemented in the repository") diff --git a/assimilator/core/patterns/__init__.py b/assimilator/core/patterns/__init__.py index e7bfb8e..9a7ee47 100644 --- a/assimilator/core/patterns/__init__.py +++ b/assimilator/core/patterns/__init__.py @@ -1,2 +1,4 @@ import assimilator.core.patterns.context_managers import assimilator.core.patterns.mixins +import assimilator.core.patterns.error_wrapper +import assimilator.core.patterns.lazy_command diff --git a/assimilator/core/patterns/error_wrapper.py b/assimilator/core/patterns/error_wrapper.py index 4ddb03f..6ad0450 100644 --- a/assimilator/core/patterns/error_wrapper.py +++ b/assimilator/core/patterns/error_wrapper.py @@ -25,3 +25,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise self.default_error(exc_val) raise exc_val # No wrapping error was found + + +__all__ = ['ErrorWrapper'] diff --git a/assimilator/core/patterns/lazy_command.py b/assimilator/core/patterns/lazy_command.py new file mode 100644 index 0000000..02452b4 --- /dev/null +++ b/assimilator/core/patterns/lazy_command.py @@ -0,0 +1,35 @@ +from typing import Union, Callable, Iterable, Container, TypeVar, Generic, Iterator + +T = TypeVar("T") + + +class LazyCommand(Generic[T]): + def __init__(self, command: Callable, *args, **kwargs): + self.command = command + self.args = args + self.kwargs = kwargs + self._results: T = None + + def __call__(self) -> Union[Container[T], T]: + if self._results is not None: + return self._results + + self._results = self.command(*self.args, **self.kwargs) + return self._results + + def __iter__(self) -> Iterator[T]: + results = self() + + if not isinstance(results, Iterable): # get() command + raise StopIteration("Results are not iterable") + + return iter(results) # filter() command + + def __bool__(self): + return bool(self()) + + def __str__(self): + return f"Lazy<{self.command}(*{self.args}, **{self.kwargs})>" + + def __repr__(self): + return str(self) diff --git a/assimilator/core/services/crud.py b/assimilator/core/services/crud.py index b8b19ac..8984c06 100644 --- a/assimilator/core/services/crud.py +++ b/assimilator/core/services/crud.py @@ -5,44 +5,42 @@ class CRUDService(Service): - def __init__(self, uow: UnitOfWork, model: Type): - self.uow = uow - self.specifications = self.uow.repository.specifications + def __init__(self, model: Type): self.model = model - def create(self, obj_data: dict): - with self.uow: + def create(self, obj_data: dict, uow: UnitOfWork): + with uow: obj = self.model(**obj_data) - self.uow.repository.save(obj) - self.uow.commit() + uow.repository.save(obj) + uow.commit() - self.uow.repository.refresh(obj) + uow.repository.refresh(obj) return obj - def update(self, update_data: dict, *filters, **kwargs_filters): - with self.uow: + def update(self, update_data: dict, uow: UnitOfWork, *filters, **kwargs_filters): + with uow: obj = self.get(*filters, **kwargs_filters) for key, value in update_data.items(): setattr(obj, key, value) - self.uow.repository.update(obj) - self.uow.commit() + uow.repository.update(obj) + uow.commit() - self.uow.repository.refresh(obj) + uow.repository.refresh(obj) return obj - def list(self, *filters, lazy: bool = False, **kwargs_filters): - return self.uow.repository.get(self.specifications.filter(*filters, **kwargs_filters), lazy=lazy) + def list(self, *filters, uow: UnitOfWork, lazy: bool = False, **kwargs_filters): + return uow.repository.get(uow.repository.specs.filter(*filters, **kwargs_filters), lazy=lazy) - def get(self, *filters, lazy: bool = False, **kwargs_filters): - return self.uow.repository.filter(self.specifications.filter(*filters, **kwargs_filters), lazy=lazy) + def get(self, *filters, uow: UnitOfWork, lazy: bool = False, **kwargs_filters): + return uow.repository.filter(uow.repository.specs.filter(*filters, **kwargs_filters), lazy=lazy) - def delete(self, *filters, **kwargs_filters): - with self.uow: + def delete(self, uow: UnitOfWork, *filters, **kwargs_filters): + with uow: obj = self.get(*filters, **kwargs_filters) - self.uow.repository.delete(obj) - self.uow.commit() + uow.repository.delete(obj) + uow.commit() __all__ = [ diff --git a/assimilator/internal/database/repository.py b/assimilator/internal/database/repository.py index 8883b72..08fd215 100644 --- a/assimilator/internal/database/repository.py +++ b/assimilator/internal/database/repository.py @@ -5,7 +5,7 @@ from assimilator.internal.database.models import InternalModel from assimilator.core.patterns.error_wrapper import ErrorWrapper from assimilator.internal.database.error_wrapper import InternalErrorWrapper -from assimilator.internal.database.specifications import InternalSpecificationList +from assimilator.internal.database.specifications import InternalSpecificationList, internal_filter from assimilator.core.database import BaseRepository, SpecificationList, SpecificationType, LazyCommand @@ -14,7 +14,7 @@ def __init__( self, session: dict, model: Type[InternalModel], - initial_query: str = '', + initial_query: Optional[str] = '', specifications: Type[SpecificationList] = InternalSpecificationList, error_wrapper: Optional[ErrorWrapper] = None, ): @@ -26,11 +26,15 @@ def __init__( ) self.error_wrapper = error_wrapper if error_wrapper is not None else InternalErrorWrapper() + def check_before_specification(self, specification: SpecificationType): + """ Checks that the specification provided is a specification that must be run before the query """ + return specification is internal_filter + def __parse_specifications(self, specifications: Tuple): before_specs, after_specs = [], [] for specification in specifications: - if specification is self.specs.filter: + if self.check_before_specification(specification): before_specs.append(specification) else: after_specs.append(specification) @@ -40,26 +44,31 @@ def __parse_specifications(self, specifications: Tuple): @make_lazy def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): with self.error_wrapper: - return self.session[self._apply_specifications(specifications, initial_query=initial_query)] + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=specifications, + ) + return self.session[query] @make_lazy def filter(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): with self.error_wrapper: before_specs, after_specs = self.__parse_specifications(specifications) - if before_specs: + if not before_specs: + models = list(self.session.values()) + else: models = [] - key_mask = self._apply_specifications(specifications=before_specs, initial_query=initial_query) + key_mask = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=before_specs + ) + for key, value in self.session.items(): if re.match(key_mask, key): models.append(value) - else: - models = list(self.session.values()) - - for specification in after_specs: - models = specification(models) - return models + return self._apply_specifications(query=models, specifications=after_specs) def save(self, obj): self.session[obj.id] = obj diff --git a/assimilator/redis/database/repository.py b/assimilator/redis/database/repository.py index 035d595..74d42c2 100644 --- a/assimilator/redis/database/repository.py +++ b/assimilator/redis/database/repository.py @@ -3,21 +3,21 @@ from redis import Redis from assimilator.redis.database import RedisModel -from assimilator.core.database.exceptions import DataLayerError +from assimilator.core.database.exceptions import DataLayerError, NotFoundError from assimilator.core.patterns.error_wrapper import ErrorWrapper from assimilator.core.database import SpecificationList, SpecificationType from assimilator.core.database.repository import BaseRepository, LazyCommand, make_lazy -from assimilator.internal.database.specifications import InternalSpecification, InternalSpecificationList +from assimilator.internal.database.specifications import InternalSpecificationList class RedisRepository(BaseRepository): - model: Type[RedisModel] + session: Redis def __init__( self, session: Redis, model: Type[RedisModel], - initial_query: str = '', + initial_query: Optional[str] = '', specifications: Type[SpecificationList] = InternalSpecificationList, error_wrapper: Optional[ErrorWrapper] = None, ): @@ -27,33 +27,43 @@ def __init__( initial_query=initial_query, specifications=specifications, ) - self.error_wrapper = error_wrapper if not error_wrapper else ErrorWrapper(default_error=DataLayerError) + self.error_wrapper = error_wrapper if not \ + error_wrapper else ErrorWrapper(default_error=DataLayerError) @make_lazy def get( self, - *specifications: InternalSpecification, + *specifications: SpecificationType, lazy: bool = False, initial_query: str = None, - ) -> Union[LazyCommand, RedisModel]: - query = self._apply_specifications(specifications, initial_query=initial_query) - return self.model.from_json(self.session.get(query)) + ) -> Union[LazyCommand[RedisModel], RedisModel]: + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=specifications, + ) + + found_obj = self.session.get(query) + if found_obj is None: + raise NotFoundError() + + return self.model.from_json(found_obj) @make_lazy def filter( self, - *specifications: InternalSpecification, + *specifications: SpecificationType, lazy: bool = False, initial_query: str = None, ) -> Union[LazyCommand, Iterable[RedisModel]]: - key_name = self._apply_specifications(specifications, initial_query=initial_query) - return [ + + + models = [ self.model.from_json(value) for value in self.session.mget(self.session.keys(key_name)) ] def save(self, obj: RedisModel): - self.session.set(str(obj.id), obj.json(), ex=obj.expire_in) + self.session.rpush(str(obj.id), obj.json()) def delete(self, obj: RedisModel): self.session.delete(str(obj.id)) From 9f53223dd5c4b18730df9e7a3a3908efe398ba7e Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Wed, 18 Jan 2023 09:43:00 +0100 Subject: [PATCH 08/12] Added typings, fixed transactional bugs with RedisRepository and RedisUnitOfWork, added SpecificationSplitMixin, started working on a global system for specifications, added decorator for ErrorWrapper, added ErrorWrapper to BaseRepository and UnitOfWork --- assimilator/__init__.py | 2 +- assimilator/alchemy/database/error_wrapper.py | 5 +- assimilator/alchemy/database/repository.py | 92 +++++++----- .../alchemy/database/specifications.py | 8 +- assimilator/alchemy/database/unit_of_work.py | 15 +- assimilator/core/database/__init__.py | 1 + assimilator/core/database/repository.py | 14 +- assimilator/core/database/specifications.py | 12 +- .../core/database/specifications_split.py | 58 ++++++++ assimilator/core/database/unit_of_work.py | 13 +- assimilator/core/patterns/__init__.py | 8 +- assimilator/core/patterns/error_wrapper.py | 14 +- assimilator/core/patterns/mixins.py | 7 +- .../internal/database/error_wrapper.py | 3 + assimilator/internal/database/models.py | 26 +++- assimilator/internal/database/repository.py | 103 ++++++------- .../internal/database/specifications.py | 55 +++++-- assimilator/internal/database/unit_of_work.py | 6 +- assimilator/redis/__init__.py | 2 - assimilator/redis/database/__init__.py | 3 - assimilator/redis/database/models.py | 12 -- assimilator/redis/database/repository.py | 92 ------------ assimilator/redis/database/unit_of_work.py | 25 ---- assimilator/redis/events/__init__.py | 1 - assimilator/redis_/__init__.py | 2 + assimilator/redis_/database/__init__.py | 3 + assimilator/redis_/database/models.py | 16 ++ assimilator/redis_/database/repository.py | 137 ++++++++++++++++++ assimilator/redis_/database/unit_of_work.py | 26 ++++ assimilator/redis_/events/__init__.py | 1 + .../{redis => redis_}/events/events_bus.py | 6 +- examples/__init__.py | 0 examples/redis/__init__.py | 0 examples/redis/database/__init__.py | 0 examples/redis/database/dependencies.py | 17 +++ examples/redis/database/main.py | 77 ++++++++++ examples/redis/database/models.py | 10 ++ tests/__init__.py | 0 tests/core/__init__.py | 0 tests/core/patterns/__init__.py | 0 40 files changed, 598 insertions(+), 274 deletions(-) create mode 100644 assimilator/core/database/specifications_split.py delete mode 100644 assimilator/redis/__init__.py delete mode 100644 assimilator/redis/database/__init__.py delete mode 100644 assimilator/redis/database/models.py delete mode 100644 assimilator/redis/database/repository.py delete mode 100644 assimilator/redis/database/unit_of_work.py delete mode 100644 assimilator/redis/events/__init__.py create mode 100644 assimilator/redis_/__init__.py create mode 100644 assimilator/redis_/database/__init__.py create mode 100644 assimilator/redis_/database/models.py create mode 100644 assimilator/redis_/database/repository.py create mode 100644 assimilator/redis_/database/unit_of_work.py create mode 100644 assimilator/redis_/events/__init__.py rename assimilator/{redis => redis_}/events/events_bus.py (89%) create mode 100644 examples/__init__.py create mode 100644 examples/redis/__init__.py create mode 100644 examples/redis/database/__init__.py create mode 100644 examples/redis/database/dependencies.py create mode 100644 examples/redis/database/main.py create mode 100644 examples/redis/database/models.py create mode 100644 tests/__init__.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/patterns/__init__.py diff --git a/assimilator/__init__.py b/assimilator/__init__.py index 63846f2..1d9c335 100644 --- a/assimilator/__init__.py +++ b/assimilator/__init__.py @@ -24,4 +24,4 @@ def optional_dependencies(error: str = "ignore"): import assimilator.kafka with optional_dependencies(): - import assimilator.redis + import assimilator.redis_ as redis diff --git a/assimilator/alchemy/database/error_wrapper.py b/assimilator/alchemy/database/error_wrapper.py index 2e15725..07a73c0 100644 --- a/assimilator/alchemy/database/error_wrapper.py +++ b/assimilator/alchemy/database/error_wrapper.py @@ -1,4 +1,4 @@ -from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError, MultipleResultsFound +from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError from assimilator.core.database.exceptions import DataLayerError, NotFoundError, InvalidQueryError from assimilator.core.patterns.error_wrapper import ErrorWrapper @@ -11,3 +11,6 @@ def __init__(self): IntegrityError: InvalidQueryError, SQLAlchemyError: DataLayerError, }, default_error=DataLayerError) + + +__all__ = ['AlchemyErrorWrapper'] diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index 12287bc..05ebc9f 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -1,80 +1,94 @@ -from typing import Type, Union +from typing import Type, Union, Optional, TypeVar, Collection -from sqlalchemy import func, select, Table +from sqlalchemy import func, select from sqlalchemy.orm import Session, Query from sqlalchemy.inspection import inspect from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper from assimilator.core.database.exceptions import InvalidQueryError from assimilator.alchemy.database.specifications import AlchemySpecificationList -from assimilator.core.database import BaseRepository, Specification, \ - SpecificationList, LazyCommand, SpecificationType, make_lazy +from assimilator.core.database import BaseRepository, SpecificationList, \ + LazyCommand, SpecificationType, make_lazy from assimilator.core.patterns.error_wrapper import ErrorWrapper +AlchemyModelT = TypeVar("AlchemyModelT") + + class AlchemyRepository(BaseRepository): + session: Session + model: Type[AlchemyModelT] + def __init__( self, session: Session, - model: Type['Table'], + model: Type[AlchemyModelT], initial_query: Query = None, specifications: Type[SpecificationList] = AlchemySpecificationList, - error_wrapper: ErrorWrapper = None, + error_wrapper: Optional[ErrorWrapper] = None, ): super(AlchemyRepository, self).__init__( session=session, model=model, initial_query=initial_query if initial_query is not None else select(model), specifications=specifications, + error_wrapper=error_wrapper or AlchemyErrorWrapper(), ) - self.error_wrapper = error_wrapper if error_wrapper is not None else AlchemyErrorWrapper() @make_lazy - def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): - with self.error_wrapper: - query = self._apply_specifications( - query=self.get_initial_query(initial_query), - specifications=specifications, - ) - return self.session.execute(query).one()[0] + def get( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: Query = None, + ) -> Union[AlchemyModelT, LazyCommand[AlchemyModelT]]: + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=specifications, + ) + return self.session.execute(query).one()[0] @make_lazy - def filter(self, *specifications: Specification, lazy: bool = False, initial_query=None): - with self.error_wrapper: - query = self._apply_specifications( - query=self.get_initial_query(initial_query), - specifications=specifications, - ) - return [result[0] for result in self.session.execute(query)] - - def update(self, obj) -> None: + def filter( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: Query = None, + ) -> Union[Collection[AlchemyModelT], LazyCommand[Collection[AlchemyModelT]]]: + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=specifications, + ) + return [result[0] for result in self.session.execute(query)] + + def update(self, obj: AlchemyModelT) -> None: """ We don't do anything, as the object is going to be updated with the obj.key = value """ - def save(self, obj): + def save(self, obj: AlchemyModelT) -> None: self.session.add(obj) - def refresh(self, obj): + def refresh(self, obj: AlchemyModelT) -> None: self.session.refresh(obj) - def delete(self, obj): + def delete(self, obj: AlchemyModelT) -> None: self.session.delete(obj) - def is_modified(self, obj) -> bool: + def is_modified(self, obj: AlchemyModelT) -> bool: return self.session.is_modified(obj) @make_lazy - def count(self, *specifications, lazy: bool = False) -> Union[LazyCommand, int]: - with self.error_wrapper: - primary_keys = inspect(self.model).primary_key - - if not primary_keys: - raise InvalidQueryError("Your repository model does not have any primary keys. We cannot use count()") - - return self.get( - *specifications, - lazy=False, - query=select(func.count(getattr(self.model, primary_keys[0].name))), - ) + def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand[int], int]: + primary_keys = inspect(self.model).primary_key + + if not primary_keys: + raise InvalidQueryError("Your repository model does not have" + " any primary keys. We cannot use count()") + + return self.get( + *specifications, + lazy=False, + query=select(func.count(getattr(self.model, primary_keys[0].name))), + ) __all__ = [ diff --git a/assimilator/alchemy/database/specifications.py b/assimilator/alchemy/database/specifications.py index 2f92367..94feced 100644 --- a/assimilator/alchemy/database/specifications.py +++ b/assimilator/alchemy/database/specifications.py @@ -2,12 +2,7 @@ from sqlalchemy.orm import Query -from assimilator.core.database.specifications import Specification, specification, SpecificationList - - -class AlchemySpecification(Specification): - def apply(self, query: Query) -> Query: - return super(AlchemySpecification, self).apply(query) +from assimilator.core.database.specifications import specification, SpecificationList @specification @@ -45,7 +40,6 @@ class AlchemySpecificationList(SpecificationList): __all__ = [ 'AlchemySpecificationList', - 'AlchemySpecification', 'alchemy_filter', 'alchemy_order', 'alchemy_paginate', diff --git a/assimilator/alchemy/database/unit_of_work.py b/assimilator/alchemy/database/unit_of_work.py index 2d74bdf..8ec10b5 100644 --- a/assimilator/alchemy/database/unit_of_work.py +++ b/assimilator/alchemy/database/unit_of_work.py @@ -6,23 +6,22 @@ class AlchemyUnitOfWork(UnitOfWork): def __init__(self, repository: AlchemyRepository, error_wrapper: ErrorWrapper = None): - super(AlchemyUnitOfWork, self).__init__(repository) - self.error_wrapper = error_wrapper if error_wrapper is not None else AlchemyErrorWrapper() + super(AlchemyUnitOfWork, self).__init__( + repository=repository, + error_wrapper=error_wrapper or AlchemyErrorWrapper(), + ) def begin(self): - with self.error_wrapper: - self.repository.session.begin() + self.repository.session.begin() def rollback(self): - with self.error_wrapper: - self.repository.session.rollback() + self.repository.session.rollback() def close(self): pass def commit(self): - with self.error_wrapper: - self.repository.session.commit() + self.repository.session.commit() __all__ = [ diff --git a/assimilator/core/database/__init__.py b/assimilator/core/database/__init__.py index 8bd88e3..693dbd8 100644 --- a/assimilator/core/database/__init__.py +++ b/assimilator/core/database/__init__.py @@ -2,3 +2,4 @@ from assimilator.core.database.specifications import * from assimilator.core.database.unit_of_work import * from assimilator.core.database.exceptions import * +from assimilator.core.database.specifications_split import * diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index 35c22d4..576cca0 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from typing import Union, Optional, Iterable, Type, Collection +from assimilator.core.patterns import ErrorWrapper from assimilator.core.patterns.lazy_command import LazyCommand from assimilator.core.database.specifications import SpecificationList, SpecificationType @@ -18,7 +19,7 @@ def make_lazy_wrapper( ): if lazy: return LazyCommand(func, self, *specifications, lazy=False, initial_query=initial_query) - return func(*specifications, lazy=False, initial_query=initial_query) + return func(self, *specifications, lazy=False, initial_query=initial_query) return make_lazy_wrapper @@ -35,12 +36,23 @@ def __init__( model: Type[ModelT], specifications: Type[SpecificationList], initial_query: Optional[SessionT] = None, + error_wrapper: Optional[ErrorWrapper] = None, ): self.session = session self.model = model self.__initial_query: QueryT = initial_query self.specifications = specifications + self.error_wrapper = error_wrapper or ErrorWrapper() + self.get = self.error_wrapper.decorate(self.get) + self.filter = self.error_wrapper.decorate(self.filter) + self.save = self.error_wrapper.decorate(self.save) + self.delete = self.error_wrapper.decorate(self.delete) + self.update = self.error_wrapper.decorate(self.update) + self.is_modified = self.error_wrapper.decorate(self.is_modified) + self.refresh = self.error_wrapper.decorate(self.refresh) + self.count = self.error_wrapper.decorate(self.count) + @property def specs(self) -> Type[SpecificationList]: """ That property is used to shorten the full name of the self.specifications. """ diff --git a/assimilator/core/database/specifications.py b/assimilator/core/database/specifications.py index 5d38ab2..846ae77 100644 --- a/assimilator/core/database/specifications.py +++ b/assimilator/core/database/specifications.py @@ -1,14 +1,17 @@ from abc import ABC, abstractmethod -from typing import Callable, Union +from typing import Callable, Union, TypeVar from functools import wraps +QueryT = TypeVar("QueryT") + + class Specification(ABC): @abstractmethod - def apply(self, query): + def apply(self, query: QueryT) -> QueryT: raise NotImplementedError("Specification must specify apply()") - def __call__(self, query): + def __call__(self, query: QueryT) -> QueryT: return self.apply(query) @@ -16,10 +19,11 @@ def specification(func: Callable): def create_specification(*args, **kwargs): @wraps(func) - def created_specification(query): + def created_specification(query: QueryT) -> QueryT: return func(*args, **kwargs, query=query) return created_specification + return create_specification diff --git a/assimilator/core/database/specifications_split.py b/assimilator/core/database/specifications_split.py new file mode 100644 index 0000000..0a2a2cb --- /dev/null +++ b/assimilator/core/database/specifications_split.py @@ -0,0 +1,58 @@ +from abc import abstractmethod +from typing import Tuple, List, Iterable + +from assimilator.core.database.specifications import SpecificationType + + +class SpecificationSplitMixin: + """ + I found out that some data storages will not support specification + that can only build queries and then execute them to get the results. + + Example: redis does not support simultaneous sorting and filtering with pagination, but SQLAlchemy can. + We still want to sort results that come from Redis, so we split specifications into two categories: + 1) Specifications that run before the query is sent to the storage + 1) Specifications that run after the query is sent to the storage and work with a list of results + + This mixin allows you to use them in your repositories + """ + + @abstractmethod + def _is_before_specification(self, specification: SpecificationType) -> bool: + """ Checks that the specification provided is a specification that must be run before the query """ + raise NotImplementedError("is_before_specification() is not implemented") + + def _split_specifications( + self, specifications: Iterable[SpecificationType], no_after_specs: bool = False + ) -> Tuple[List[SpecificationType], List[SpecificationType]]: + """ + Splits our specifications into two categories, so we can use them later. + + Sometimes you only want to use 'before_specs' and show your + users that they cannot use 'after_specs' as they are useless. + Example: using repository.get() with pagination. You only get one result, so pagination is useless. + + If that is the case, then turn on 'no_after_specs' and the function will raise a Warning() is there are + any 'after_specs' present. + """ + before_specs, after_specs = [], [] + + for specification in specifications: + if self._is_before_specification(specification): + before_specs.append(specification) + else: + after_specs.append(specification) + + if no_after_specs and after_specs: + raise Warning( + "You are using after specifications in a function that bans them " + "We cannot apply them, as the developers decided that after" + " specifications do not fit in the function. " + "Please, remove all the specifications that run after the results are obtained." + "Examples: sorting, ordering, pagination, etc." + ) + + return before_specs, after_specs + + +__all__ = ['SpecificationSplitMixin'] diff --git a/assimilator/core/database/unit_of_work.py b/assimilator/core/database/unit_of_work.py index 12dd059..51bde1a 100644 --- a/assimilator/core/database/unit_of_work.py +++ b/assimilator/core/database/unit_of_work.py @@ -1,11 +1,22 @@ from abc import ABC, abstractmethod +from typing import Optional from assimilator.core.database.repository import BaseRepository +from assimilator.core.patterns import ErrorWrapper class UnitOfWork(ABC): - def __init__(self, repository: BaseRepository): + error_wrapper: ErrorWrapper = ErrorWrapper() + + def __init__(self, repository: BaseRepository, error_wrapper: Optional[ErrorWrapper] = None): self.repository = repository + if error_wrapper is not None: + self.error_wrapper = error_wrapper + + self.begin = self.error_wrapper.decorate(self.begin) + self.rollback = self.error_wrapper.decorate(self.rollback) + self.commit = self.error_wrapper.decorate(self.commit) + self.close = self.error_wrapper.decorate(self.close) @abstractmethod def begin(self): diff --git a/assimilator/core/patterns/__init__.py b/assimilator/core/patterns/__init__.py index 9a7ee47..aac852b 100644 --- a/assimilator/core/patterns/__init__.py +++ b/assimilator/core/patterns/__init__.py @@ -1,4 +1,4 @@ -import assimilator.core.patterns.context_managers -import assimilator.core.patterns.mixins -import assimilator.core.patterns.error_wrapper -import assimilator.core.patterns.lazy_command +from assimilator.core.patterns.context_managers import * +from assimilator.core.patterns.mixins import * +from assimilator.core.patterns.error_wrapper import * +from assimilator.core.patterns.lazy_command import * diff --git a/assimilator/core/patterns/error_wrapper.py b/assimilator/core/patterns/error_wrapper.py index 6ad0450..7e4dfb2 100644 --- a/assimilator/core/patterns/error_wrapper.py +++ b/assimilator/core/patterns/error_wrapper.py @@ -1,4 +1,5 @@ -from typing import Dict, Type, Optional +from functools import wraps +from typing import Dict, Type, Optional, Callable class ErrorWrapper: @@ -18,7 +19,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): return for initial_error, wrapped_error in self.error_mappings.items(): - if isinstance(exc_type, initial_error): + if isinstance(exc_val, initial_error): raise wrapped_error(exc_val) if self.default_error is not None: @@ -26,5 +27,14 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise exc_val # No wrapping error was found + def decorate(self, func: Callable) -> Callable: + + @wraps(func) + def wrapper(*args, **kwargs): + with self: + return func(*args, **kwargs) + + return wrapper + __all__ = ['ErrorWrapper'] diff --git a/assimilator/core/patterns/mixins.py b/assimilator/core/patterns/mixins.py index 5cb34b5..29f9739 100644 --- a/assimilator/core/patterns/mixins.py +++ b/assimilator/core/patterns/mixins.py @@ -1,14 +1,17 @@ import json -from typing import Type +from typing import Type, TypeVar from pydantic import ValidationError, BaseModel from assimilator.core.exceptions import ParsingError +T = TypeVar("T", bound=BaseModel) + + class JSONParsedMixin: @classmethod - def from_json(cls: Type['BaseModel'], data: str): + def from_json(cls: Type[T], data: str) -> T: try: return cls(**json.loads(data)) except ValidationError as exc: diff --git a/assimilator/internal/database/error_wrapper.py b/assimilator/internal/database/error_wrapper.py index cf23903..d6589c6 100644 --- a/assimilator/internal/database/error_wrapper.py +++ b/assimilator/internal/database/error_wrapper.py @@ -8,3 +8,6 @@ def __init__(self): KeyError: NotFoundError, TypeError: NotFoundError, }, default_error=DataLayerError) + + +__all__ = ['InternalErrorWrapper'] diff --git a/assimilator/internal/database/models.py b/assimilator/internal/database/models.py index ade9a89..01e1478 100644 --- a/assimilator/internal/database/models.py +++ b/assimilator/internal/database/models.py @@ -1,9 +1,29 @@ -from typing import Any +from typing import TypeVar, Callable +from uuid import uuid4 -from pydantic import BaseModel +from pydantic import BaseModel, Field from assimilator.core.patterns.mixins import JSONParsedMixin +T = TypeVar("T") +ComparableT = TypeVar("ComparableT") + + class InternalModel(JSONParsedMixin, BaseModel): - id: Any + id: T + + class Meta: + autogenerate_id = True + + def generate_id(self, *args, **kwargs) -> T: + return str(uuid4()) + + def __init__(self, *args, **kwargs): + if self.Meta.autogenerate_id and (kwargs.get('id') is None): + kwargs['id'] = self.generate_id(*args, **kwargs) + + super(InternalModel, self).__init__(*args, **kwargs) + + +__all__ = ['InternalModel'] diff --git a/assimilator/internal/database/repository.py b/assimilator/internal/database/repository.py index 08fd215..bcffe4d 100644 --- a/assimilator/internal/database/repository.py +++ b/assimilator/internal/database/repository.py @@ -1,21 +1,26 @@ import re -from typing import Type, Union, Optional, Tuple +from typing import Type, Union, Optional, Iterable, TypeVar, List, Tuple from assimilator.core.database.repository import make_lazy from assimilator.internal.database.models import InternalModel from assimilator.core.patterns.error_wrapper import ErrorWrapper from assimilator.internal.database.error_wrapper import InternalErrorWrapper from assimilator.internal.database.specifications import InternalSpecificationList, internal_filter -from assimilator.core.database import BaseRepository, SpecificationList, SpecificationType, LazyCommand +from assimilator.core.database import BaseRepository, SpecificationType, LazyCommand, SpecificationSplitMixin +ModelT = TypeVar("ModelT", bound=InternalModel) + + +class InternalRepository(SpecificationSplitMixin, BaseRepository): + session: dict + model: Type[ModelT] -class InternalRepository(BaseRepository): def __init__( self, session: dict, - model: Type[InternalModel], + model: Type[ModelT], initial_query: Optional[str] = '', - specifications: Type[SpecificationList] = InternalSpecificationList, + specifications: Type[InternalSpecificationList] = InternalSpecificationList, error_wrapper: Optional[ErrorWrapper] = None, ): super(InternalRepository, self).__init__( @@ -23,71 +28,69 @@ def __init__( session=session, initial_query=initial_query, specifications=specifications, + error_wrapper=error_wrapper or InternalErrorWrapper(), ) - self.error_wrapper = error_wrapper if error_wrapper is not None else InternalErrorWrapper() - - def check_before_specification(self, specification: SpecificationType): - """ Checks that the specification provided is a specification that must be run before the query """ - return specification is internal_filter - def __parse_specifications(self, specifications: Tuple): - before_specs, after_specs = [], [] + def _is_before_specification(self, specification: SpecificationType) -> bool: + return specification is internal_filter or specification is self.specs.filter - for specification in specifications: - if self.check_before_specification(specification): - before_specs.append(specification) - else: - after_specs.append(specification) - - return before_specs, after_specs + @make_lazy + def get( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: Optional[str] = None, + ) -> Union[LazyCommand[ModelT], ModelT]: + before_specs, _ = self._split_specifications(specifications, no_after_specs=True) + + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=before_specs, + ) + return self.session[query] @make_lazy - def get(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): - with self.error_wrapper: - query = self._apply_specifications( + def filter( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: Optional[str] = None, + ) -> Union[LazyCommand[List[ModelT]], List[ModelT]]: + before_specs, after_specs = self._split_specifications(specifications) + + if not before_specs: # No filters, get all the data from the session + models = list(self.session.values()) + else: + models = [] + key_mask = self._apply_specifications( query=self.get_initial_query(initial_query), - specifications=specifications, + specifications=before_specs, ) - return self.session[query] - @make_lazy - def filter(self, *specifications: SpecificationType, lazy: bool = False, initial_query=None): - with self.error_wrapper: - before_specs, after_specs = self.__parse_specifications(specifications) - - if not before_specs: - models = list(self.session.values()) - else: - models = [] - key_mask = self._apply_specifications( - query=self.get_initial_query(initial_query), - specifications=before_specs - ) - - for key, value in self.session.items(): - if re.match(key_mask, key): - models.append(value) - - return self._apply_specifications(query=models, specifications=after_specs) - - def save(self, obj): + for key, value in self.session.items(): + if re.match(key_mask, key): + models.append(value) + + return self._apply_specifications(query=models, specifications=after_specs) + + def save(self, obj: ModelT) -> None: self.session[obj.id] = obj - def delete(self, obj): + def delete(self, obj: ModelT) -> None: del self.session[obj.id] - def update(self, obj): + def update(self, obj: ModelT) -> None: self.save(obj) - def is_modified(self, obj): + def is_modified(self, obj: ModelT) -> bool: return self.get(self.specs.filter(id=obj.id)) == obj - def refresh(self, obj): + def refresh(self, obj: ModelT) -> None: fresh_obj = self.get(self.specs.filter(id=obj.id), lazy=False) obj.__dict__.update(fresh_obj.__dict__) @make_lazy - def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: + def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand[int], int]: if specifications: return len(self.filter(*specifications, lazy=False)) return len(self.session) diff --git a/assimilator/internal/database/specifications.py b/assimilator/internal/database/specifications.py index 59419f9..e816bb6 100644 --- a/assimilator/internal/database/specifications.py +++ b/assimilator/internal/database/specifications.py @@ -1,21 +1,53 @@ -from typing import List +import operator +from typing import List, Iterable, Any, Union, Callable -from assimilator.core.database import Specification, specification, SpecificationList +from assimilator.core.database import specification, SpecificationList from assimilator.internal.database.models import InternalModel -class InternalSpecification(Specification): - def apply(self, query: str) -> str: # returns the str key - return super(InternalSpecification, self).apply(query) +@specification +def internal_filter(*args, query: Union[List[InternalModel], str], **kwargs) -> Iterable[InternalModel]: + if isinstance(query, str): + return f"{query}{''.join(args)}" + if not (kwargs or args): # no filters present + return query -@specification -def internal_filter(*args, query: str, **kwargs) -> str: - return f'{query}{"".join(*args)}' + parsed_arguments: List[(str, Callable, Any)] = [] + + for field, value in kwargs.items(): + operation = operator.eq + + if field.endswith("__gt"): + operation = operator.gt + field = field.replace("__gt", "") + elif field.endswith("__gte"): + operation = operator.ge + field = field.replace("__gte", "") + elif field.endswith("__lt"): + operation = operator.lt + field = field.replace("__lt", "") + elif field.endswith("__lte"): + operation = operator.le + field = field.replace("__lte", "") + elif field.endswith("__not"): + operation = operator.not_ + field = field.replace("__not", "") + elif field.endswith("__is"): + operation = operator.is_ + field = field.replace("__is", "") + + parsed_arguments.append((field, operation, value)) + + return filter( + lambda model: all( + operation_(getattr(model, field_), val) for field_, operation_, val in parsed_arguments + ), query, + ) @specification -def internal_order(*args, query: List[InternalModel], **kwargs) -> List[InternalModel]: +def internal_order(*args, query: List[InternalModel], **kwargs) -> Iterable[InternalModel]: return sorted( query, key=lambda item: [getattr(item, argument) for argument in (args, *kwargs.keys())], @@ -23,12 +55,12 @@ def internal_order(*args, query: List[InternalModel], **kwargs) -> List[Internal @specification -def internal_paginate(limit: int, offset: int, query: List[InternalModel]) -> List[InternalModel]: +def internal_paginate(limit: int, offset: int, query: List[InternalModel]) -> Iterable[InternalModel]: return query[limit:offset] @specification -def internal_join(*args, query: str, **kwargs) -> str: +def internal_join(*args, query: Any, **kwargs) -> Any: return query @@ -40,7 +72,6 @@ class InternalSpecificationList(SpecificationList): __all__ = [ - 'InternalSpecification', 'internal_filter', 'internal_order', 'internal_paginate', diff --git a/assimilator/internal/database/unit_of_work.py b/assimilator/internal/database/unit_of_work.py index c4ffacb..67237a7 100644 --- a/assimilator/internal/database/unit_of_work.py +++ b/assimilator/internal/database/unit_of_work.py @@ -3,11 +3,15 @@ from assimilator.core.database import UnitOfWork from assimilator.core.database.repository import BaseRepository +from assimilator.internal.database.error_wrapper import InternalErrorWrapper class InternalUnitOfWork(UnitOfWork): def __init__(self, repository: BaseRepository): - super(InternalUnitOfWork, self).__init__(repository) + super(InternalUnitOfWork, self).__init__( + repository=repository, + error_wrapper=InternalErrorWrapper(), + ) self._saved_data: Optional[dict] = None def begin(self): diff --git a/assimilator/redis/__init__.py b/assimilator/redis/__init__.py deleted file mode 100644 index 0ef6354..0000000 --- a/assimilator/redis/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from assimilator.redis.database import * -from assimilator.redis.events import * diff --git a/assimilator/redis/database/__init__.py b/assimilator/redis/database/__init__.py deleted file mode 100644 index 761266a..0000000 --- a/assimilator/redis/database/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from assimilator.redis.database.models import * -from assimilator.redis.database.repository import * -from assimilator.redis.database.unit_of_work import * diff --git a/assimilator/redis/database/models.py b/assimilator/redis/database/models.py deleted file mode 100644 index a349e68..0000000 --- a/assimilator/redis/database/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Optional - -from assimilator.internal.database.models import InternalModel - - -class RedisModel(InternalModel): - expire_in: Optional[int] = None - - -__all__ = [ - 'RedisModel', -] diff --git a/assimilator/redis/database/repository.py b/assimilator/redis/database/repository.py deleted file mode 100644 index 74d42c2..0000000 --- a/assimilator/redis/database/repository.py +++ /dev/null @@ -1,92 +0,0 @@ -from typing import Type, Union, Iterable, Optional - -from redis import Redis - -from assimilator.redis.database import RedisModel -from assimilator.core.database.exceptions import DataLayerError, NotFoundError -from assimilator.core.patterns.error_wrapper import ErrorWrapper -from assimilator.core.database import SpecificationList, SpecificationType -from assimilator.core.database.repository import BaseRepository, LazyCommand, make_lazy -from assimilator.internal.database.specifications import InternalSpecificationList - - -class RedisRepository(BaseRepository): - session: Redis - - def __init__( - self, - session: Redis, - model: Type[RedisModel], - initial_query: Optional[str] = '', - specifications: Type[SpecificationList] = InternalSpecificationList, - error_wrapper: Optional[ErrorWrapper] = None, - ): - super(RedisRepository, self).__init__( - session=session, - model=model, - initial_query=initial_query, - specifications=specifications, - ) - self.error_wrapper = error_wrapper if not \ - error_wrapper else ErrorWrapper(default_error=DataLayerError) - - @make_lazy - def get( - self, - *specifications: SpecificationType, - lazy: bool = False, - initial_query: str = None, - ) -> Union[LazyCommand[RedisModel], RedisModel]: - query = self._apply_specifications( - query=self.get_initial_query(initial_query), - specifications=specifications, - ) - - found_obj = self.session.get(query) - if found_obj is None: - raise NotFoundError() - - return self.model.from_json(found_obj) - - @make_lazy - def filter( - self, - *specifications: SpecificationType, - lazy: bool = False, - initial_query: str = None, - ) -> Union[LazyCommand, Iterable[RedisModel]]: - - - models = [ - self.model.from_json(value) - for value in self.session.mget(self.session.keys(key_name)) - ] - - def save(self, obj: RedisModel): - self.session.rpush(str(obj.id), obj.json()) - - def delete(self, obj: RedisModel): - self.session.delete(str(obj.id)) - - def update(self, obj: RedisModel): - self.save(obj) - - def is_modified(self, obj: RedisModel): - return self.get(self.specifications.filter(obj.id), lazy=False) == obj - - def refresh(self, obj: RedisModel): - fresh_obj = self.get(self.specifications.filter(obj.id), lazy=False) - - for key, value in fresh_obj.dict().items(): - setattr(obj, key, value) - - @make_lazy - def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand, int]: - if specifications: - return self.session.dbsize() - return len(self.session.keys(self._apply_specifications(specifications))) - - -__all__ = [ - 'RedisRepository', -] diff --git a/assimilator/redis/database/unit_of_work.py b/assimilator/redis/database/unit_of_work.py deleted file mode 100644 index 21c0a45..0000000 --- a/assimilator/redis/database/unit_of_work.py +++ /dev/null @@ -1,25 +0,0 @@ -from assimilator.core.database.unit_of_work import UnitOfWork - - -class RedisUnitOfWork(UnitOfWork): - _saved_session = None - - def begin(self): - self._saved_session = self.repository.session - self.repository.session = self.repository.session.pipeline() - - def rollback(self): - self.repository.session.discard() - - def commit(self): - self.repository.session.execute() - - def close(self): - self.repository.session.reset() - self.repository.session = self._saved_session - self._saved_session = None - - -__all__ = [ - 'RedisUnitOfWork', -] diff --git a/assimilator/redis/events/__init__.py b/assimilator/redis/events/__init__.py deleted file mode 100644 index 9a112fd..0000000 --- a/assimilator/redis/events/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from assimilator.redis.events.events_bus import * diff --git a/assimilator/redis_/__init__.py b/assimilator/redis_/__init__.py new file mode 100644 index 0000000..66696f9 --- /dev/null +++ b/assimilator/redis_/__init__.py @@ -0,0 +1,2 @@ +from assimilator.redis_.database import * +from assimilator.redis_.events import * diff --git a/assimilator/redis_/database/__init__.py b/assimilator/redis_/database/__init__.py new file mode 100644 index 0000000..e8c3980 --- /dev/null +++ b/assimilator/redis_/database/__init__.py @@ -0,0 +1,3 @@ +from assimilator.redis_.database.models import * +from assimilator.redis_.database.repository import * +from assimilator.redis_.database.unit_of_work import * diff --git a/assimilator/redis_/database/models.py b/assimilator/redis_/database/models.py new file mode 100644 index 0000000..4d578da --- /dev/null +++ b/assimilator/redis_/database/models.py @@ -0,0 +1,16 @@ +from typing import Optional + +from assimilator.internal.database.models import InternalModel + + +class RedisModel(InternalModel): + expire_in: Optional[int] = None + expire_in_px: Optional[int] = None + only_update: Optional[bool] = False # Same as xx in redis set. Only set if key exists + only_create: Optional[bool] = False # Same as nx in redis set. Only set if key does not exist + keep_ttl: Optional[bool] = False + + +__all__ = [ + 'RedisModel', +] diff --git a/assimilator/redis_/database/repository.py b/assimilator/redis_/database/repository.py new file mode 100644 index 0000000..4f1ee7e --- /dev/null +++ b/assimilator/redis_/database/repository.py @@ -0,0 +1,137 @@ +from typing import Type, Union, Optional, TypeVar, Collection + +from redis import Redis +from redis.client import Pipeline + +from assimilator.redis_.database import RedisModel +from assimilator.core.database.exceptions import DataLayerError, NotFoundError +from assimilator.core.patterns.error_wrapper import ErrorWrapper +from assimilator.core.database import ( + SpecificationList, + SpecificationType, + SpecificationSplitMixin, + BaseRepository, + LazyCommand, + make_lazy, +) +from assimilator.internal.database.specifications import InternalSpecificationList, internal_filter + +RedisModelT = TypeVar("RedisModelT", bound=RedisModel) + + +class RedisRepository(SpecificationSplitMixin, BaseRepository): + session: Redis + transaction: Union[Pipeline, Redis] + model: Type[RedisModelT] + + def __init__( + self, + session: Redis, + model: Type[RedisModelT], + initial_query: Optional[str] = '', + specifications: Type[SpecificationList] = InternalSpecificationList, + error_wrapper: Optional[ErrorWrapper] = None, + ): + super(RedisRepository, self).__init__( + session=session, + model=model, + initial_query=initial_query, + specifications=specifications, + error_wrapper=error_wrapper or ErrorWrapper(default_error=DataLayerError) + ) + self.transaction = session + + def _is_before_specification(self, specification: SpecificationType) -> bool: + return True + + @make_lazy + def get( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: Optional[str] = None, + ) -> Union[LazyCommand[RedisModelT], RedisModelT]: + before_specs, _ = self._split_specifications(specifications, no_after_specs=True) + + query = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=before_specs, + ) + + found_obj = self.session.get(query) + if found_obj is None: + raise NotFoundError(f"Redis model was not found") + + return self.model.from_json(found_obj) + + @make_lazy + def filter( + self, + *specifications: SpecificationType, + lazy: bool = False, + initial_query: Optional[str] = None, + ) -> Union[LazyCommand[Collection[RedisModelT]], Collection[RedisModelT]]: + if not specifications: + return [ + self.model.from_json(value) + for value in self.session.mget(self.session.keys("*")) + ] + + before_specs, after_specs = self._split_specifications(specifications) + + if before_specs: + key_name = self._apply_specifications( + query=self.get_initial_query(initial_query), + specifications=before_specs, + ) + else: + key_name = "*" + + models = [ + self.model.from_json(value) + for value in self.session.mget(self.session.keys(key_name)) + ] + return self._apply_specifications(query=models, specifications=after_specs) + + def save(self, obj: RedisModelT) -> None: + self.transaction.set( + name=obj.id, + value=obj.json(), + ex=obj.expire_in, + px=obj.expire_in_px, + nx=obj.only_create, + xx=obj.only_update, + keepttl=obj.keep_ttl, + ) + + def delete(self, obj: RedisModelT) -> None: + self.transaction.delete(obj.id) + + def update(self, obj: RedisModelT) -> None: + self.save(obj) + + def is_modified(self, obj: RedisModelT) -> None: + return self.get(self.specifications.filter(obj.id), lazy=False) == obj + + def refresh(self, obj: RedisModelT) -> None: + fresh_obj = self.get(self.specifications.filter(obj.id), lazy=False) + + for key, value in fresh_obj.dict().items(): + setattr(obj, key, value) + + @make_lazy + def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand[int], int]: + if specifications: + return self.session.dbsize() + + before_specs, _ = self._split_specifications(specifications, no_after_specs=True) + filter_query = self._apply_specifications( + query=self.get_initial_query(), + specifications=before_specs, + ) + return len(self.session.keys(filter_query)) + + +__all__ = [ + 'RedisRepository', +] diff --git a/assimilator/redis_/database/unit_of_work.py b/assimilator/redis_/database/unit_of_work.py new file mode 100644 index 0000000..d65c0db --- /dev/null +++ b/assimilator/redis_/database/unit_of_work.py @@ -0,0 +1,26 @@ +from redis.client import Pipeline + +from assimilator.core.database.unit_of_work import UnitOfWork +from redis_.database.repository import RedisRepository + + +class RedisUnitOfWork(UnitOfWork): + repository: RedisRepository + + def begin(self): + self.repository.transaction = self.repository.session.pipeline() + + def rollback(self): + self.repository.transaction.discard() + + def commit(self): + self.repository.transaction.execute() + + def close(self): + self.repository.transaction.reset() + self.repository.transaction = self.repository.session + + +__all__ = [ + 'RedisUnitOfWork', +] diff --git a/assimilator/redis_/events/__init__.py b/assimilator/redis_/events/__init__.py new file mode 100644 index 0000000..e4d2cef --- /dev/null +++ b/assimilator/redis_/events/__init__.py @@ -0,0 +1 @@ +from assimilator.redis_.events.events_bus import * diff --git a/assimilator/redis/events/events_bus.py b/assimilator/redis_/events/events_bus.py similarity index 89% rename from assimilator/redis/events/events_bus.py rename to assimilator/redis_/events/events_bus.py index b42568d..45bb9a6 100644 --- a/assimilator/redis/events/events_bus.py +++ b/assimilator/redis_/events/events_bus.py @@ -1,13 +1,13 @@ from typing import Iterable -import redis +from redis import Redis from assimilator.core.events import Event, ExternalEvent from assimilator.core.events.events_bus import EventConsumer, EventProducer class RedisEventConsumer(EventConsumer): - def __init__(self, channels: Iterable[str], session: redis.Redis): + def __init__(self, channels: Iterable[str], session: Redis): self.session = session self.channels = channels @@ -36,7 +36,7 @@ def delay_function(self): class RedisEventProducer(EventProducer): - def __init__(self, channel: str, session: redis.Redis): + def __init__(self, channel: str, session: Redis): self.session = session self.channel = channel diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/redis/__init__.py b/examples/redis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/redis/database/__init__.py b/examples/redis/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/redis/database/dependencies.py b/examples/redis/database/dependencies.py new file mode 100644 index 0000000..485bc49 --- /dev/null +++ b/examples/redis/database/dependencies.py @@ -0,0 +1,17 @@ +from redis.client import Redis + +from assimilator.redis_ import RedisRepository, RedisUnitOfWork +from examples.redis.database.models import RedisUser + +session = Redis() + + +def create_repository(): # factory for RedisRepository + return RedisRepository(session=session, model=RedisUser) + + +def create_unit_of_work(): # factory for RedisUnitOfWork + return RedisUnitOfWork(repository=create_repository()) + + +__all__ = ['create_repository', 'create_unit_of_work'] diff --git a/examples/redis/database/main.py b/examples/redis/database/main.py new file mode 100644 index 0000000..bac7d5f --- /dev/null +++ b/examples/redis/database/main.py @@ -0,0 +1,77 @@ +import random +import string +from uuid import UUID + +from assimilator.core.database import UnitOfWork, BaseRepository + +from dependencies import create_unit_of_work +from examples.redis.database.models import RedisUser + + +def create_user(username: str, email: str, balance: float, uow: UnitOfWork) -> UUID: + with uow: + new_user = RedisUser(username=username, email=email, balance=balance) + uow.repository.save(new_user) + uow.commit() + return new_user.id # id generated by default. But, we can change that behaviour if we need + + +def read_user(id: UUID, repository: BaseRepository) -> RedisUser: + return repository.get(repository.specs.filter(id)) + + +def buy_product(user_id: UUID, product_price: int, uow: UnitOfWork): + with uow: + found_user = read_user(id=user_id, repository=uow.repository) + + found_user.balance -= product_price + uow.repository.update(found_user) + uow.commit() + + +def refresh_user(old_user: RedisUser, repository: BaseRepository) -> RedisUser: + repository.refresh(old_user) + return old_user + + +def create_many_users(uow): + with uow: + for i in range(100): + new_user = RedisUser( + username="".join(random.sample(string.ascii_letters, 10)), + email=f"{''.join(random.sample(string.ascii_letters, 10))}@gmail.com", + balance=random.randint(0, 100), + ) + uow.repository.save(new_user) + + uow.commit() # Commit is only called once! + + +def filter_users(repository: BaseRepository, **filters): + return repository.filter(repository.specs.filter(**filters)) + + +if __name__ == '__main__': + new_user_id = create_user( + username="Andrey", + email="python.on.papyrus@gmail.com", + balance=1000, + uow=create_unit_of_work(), + ) + + print(f"User with '{new_user_id}' was created") + + user = read_user( + id=new_user_id, + repository=create_unit_of_work().repository, + ) + print("User returned from Redis:", user) + + buy_product(user_id=new_user_id, product_price=100, uow=create_unit_of_work()) + + updated_user = refresh_user(user, repository=create_unit_of_work().repository) + print("User balance after product purchase:", updated_user.balance) + + create_many_users(create_unit_of_work()) + + print(filter_users(repository=create_unit_of_work().repository, balance__gt=90)) diff --git a/examples/redis/database/models.py b/examples/redis/database/models.py new file mode 100644 index 0000000..bde8322 --- /dev/null +++ b/examples/redis/database/models.py @@ -0,0 +1,10 @@ +from assimilator.redis_.database import RedisModel + + +class RedisUser(RedisModel): + username: str + email: str + balance: float = 0 + + +__all__ = ['RedisUser'] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/patterns/__init__.py b/tests/core/patterns/__init__.py new file mode 100644 index 0000000..e69de29 From 2b65f35d8a94ef7a3a7d8dd2b95d8db103317549 Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Wed, 18 Jan 2023 18:10:05 +0100 Subject: [PATCH 09/12] Optimized ErrorWrapper with dict.get() function --- assimilator/core/patterns/error_wrapper.py | 8 ++++---- assimilator/core/patterns/lazy_command.py | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/assimilator/core/patterns/error_wrapper.py b/assimilator/core/patterns/error_wrapper.py index 7e4dfb2..5137814 100644 --- a/assimilator/core/patterns/error_wrapper.py +++ b/assimilator/core/patterns/error_wrapper.py @@ -18,11 +18,11 @@ def __exit__(self, exc_type, exc_val, exc_tb): if exc_val is None: return - for initial_error, wrapped_error in self.error_mappings.items(): - if isinstance(exc_val, initial_error): - raise wrapped_error(exc_val) + wrapped_error = self.error_mappings.get(exc_type) - if self.default_error is not None: + if wrapped_error is not None: + raise wrapped_error(exc_val) + elif self.default_error is not None: raise self.default_error(exc_val) raise exc_val # No wrapping error was found diff --git a/assimilator/core/patterns/lazy_command.py b/assimilator/core/patterns/lazy_command.py index 02452b4..9c03e02 100644 --- a/assimilator/core/patterns/lazy_command.py +++ b/assimilator/core/patterns/lazy_command.py @@ -33,3 +33,6 @@ def __str__(self): def __repr__(self): return str(self) + + +__all__ = ['LazyCommand'] From 77389fe20b4ca202a46268bbc44a092c1a72882c Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Wed, 18 Jan 2023 20:34:45 +0100 Subject: [PATCH 10/12] Removed SpecificationSplitMixin, changed internal specifications to work with both strings and Models. Added examples for Redis, fixed RedisRepository bugs. Added SQLAlchemy examples, fixed sqlaclhemy pagination and sorting --- assimilator/alchemy/database/repository.py | 11 +- .../alchemy/database/specifications.py | 54 ++++++++- assimilator/alchemy/database/unit_of_work.py | 2 + assimilator/core/database/__init__.py | 1 - assimilator/core/database/repository.py | 8 +- .../core/database/specifications_split.py | 58 --------- assimilator/core/database/unit_of_work.py | 4 +- assimilator/core/patterns/error_wrapper.py | 11 +- assimilator/internal/database/models.py | 4 +- assimilator/internal/database/repository.py | 35 ++---- .../internal/database/specifications.py | 56 ++++++--- assimilator/internal/database/unit_of_work.py | 7 +- assimilator/redis_/database/repository.py | 49 ++++---- docs/patterns/database.md | 25 ++-- examples/alchemy/__init__.py | 0 examples/alchemy/database/__init__.py | 0 examples/alchemy/database/dependencies.py | 13 ++ examples/alchemy/database/main.py | 112 ++++++++++++++++++ examples/alchemy/database/models.py | 24 ++++ examples/redis/database/dependencies.py | 4 +- examples/redis/database/main.py | 61 +++++++--- 21 files changed, 362 insertions(+), 177 deletions(-) delete mode 100644 assimilator/core/database/specifications_split.py create mode 100644 examples/alchemy/__init__.py create mode 100644 examples/alchemy/database/__init__.py create mode 100644 examples/alchemy/database/dependencies.py create mode 100644 examples/alchemy/database/main.py create mode 100644 examples/alchemy/database/models.py diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index 05ebc9f..c1c9763 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -7,7 +7,7 @@ from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper from assimilator.core.database.exceptions import InvalidQueryError from assimilator.alchemy.database.specifications import AlchemySpecificationList -from assimilator.core.database import BaseRepository, SpecificationList, \ +from assimilator.core.database import Repository, SpecificationList, \ LazyCommand, SpecificationType, make_lazy from assimilator.core.patterns.error_wrapper import ErrorWrapper @@ -15,7 +15,7 @@ AlchemyModelT = TypeVar("AlchemyModelT") -class AlchemyRepository(BaseRepository): +class AlchemyRepository(Repository): session: Session model: Type[AlchemyModelT] @@ -68,6 +68,13 @@ def save(self, obj: AlchemyModelT) -> None: self.session.add(obj) def refresh(self, obj: AlchemyModelT) -> None: + inspection = inspect(obj) + + if inspection.transient or inspection.pending: + return + elif inspection.detached: + self.session.add(obj) + self.session.refresh(obj) def delete(self, obj: AlchemyModelT) -> None: diff --git a/assimilator/alchemy/database/specifications.py b/assimilator/alchemy/database/specifications.py index 94feced..b0a77a7 100644 --- a/assimilator/alchemy/database/specifications.py +++ b/assimilator/alchemy/database/specifications.py @@ -1,23 +1,69 @@ -from typing import Collection +from typing import Collection, Optional from sqlalchemy.orm import Query +from sqlalchemy.sql.operators import is_ +from sqlalchemy import not_, column, desc from assimilator.core.database.specifications import specification, SpecificationList @specification def alchemy_filter(*filters, query: Query, **filters_by) -> Query: + filters = list(filters) + removed_filters_by = [] + + for field, value in filters_by.items(): + if field.endswith("__gt"): + filter_ = column(field.replace("__gt", "")) > value + elif field.endswith("__gte"): + filter_ = column(field.replace("__gte", "")) >= value + elif field.endswith("__lt"): + filter_ = column(field.replace("__lt", "")) < value + elif field.endswith("__lte"): + filter_ = column(field.replace("__lte", "")) <= value + elif field.endswith("__not"): + filter_ = not_(column(field.replace("__not", ""))) + elif field.endswith("__is"): + filter_ = is_(column(field.replace("__is", ""))) + else: + continue + + filters.append(filter_) + removed_filters_by.append(field) + + for removed_filter in removed_filters_by: + del filters_by[removed_filter] + return query.filter(*filters).filter_by(**filters_by) @specification def alchemy_order(*clauses: str, query: Query) -> Query: - return query.order_by(*clauses) + parsed_clauses = [] + + for clause in clauses: + if clause.startswith("-"): + parsed_clauses.append(desc(column(clause[1:]))) + else: + parsed_clauses.append(clause) + + return query.order_by(*parsed_clauses) @specification -def alchemy_paginate(limit: int, offset: int, query: Query) -> Query: - return query.limit(limit).offset(offset) +def alchemy_paginate( + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: Query, +) -> Query: + + if limit is not None: + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + + return query @specification diff --git a/assimilator/alchemy/database/unit_of_work.py b/assimilator/alchemy/database/unit_of_work.py index 8ec10b5..b93fd2c 100644 --- a/assimilator/alchemy/database/unit_of_work.py +++ b/assimilator/alchemy/database/unit_of_work.py @@ -5,6 +5,8 @@ class AlchemyUnitOfWork(UnitOfWork): + repository: AlchemyRepository + def __init__(self, repository: AlchemyRepository, error_wrapper: ErrorWrapper = None): super(AlchemyUnitOfWork, self).__init__( repository=repository, diff --git a/assimilator/core/database/__init__.py b/assimilator/core/database/__init__.py index 693dbd8..8bd88e3 100644 --- a/assimilator/core/database/__init__.py +++ b/assimilator/core/database/__init__.py @@ -2,4 +2,3 @@ from assimilator.core.database.specifications import * from assimilator.core.database.unit_of_work import * from assimilator.core.database.exceptions import * -from assimilator.core.database.specifications_split import * diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index 576cca0..de79e54 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -1,7 +1,7 @@ from functools import wraps -from typing import TypeVar, Callable, Generic, final from abc import ABC, abstractmethod -from typing import Union, Optional, Iterable, Type, Collection +from typing import TypeVar, Callable, Generic, final, \ + Union, Optional, Iterable, Type, Collection from assimilator.core.patterns import ErrorWrapper from assimilator.core.patterns.lazy_command import LazyCommand @@ -29,7 +29,7 @@ def make_lazy_wrapper( SessionT = TypeVar("SessionT") -class BaseRepository(Generic[SessionT, ModelT, QueryT], ABC): +class Repository(Generic[SessionT, ModelT, QueryT], ABC): def __init__( self, session: SessionT, @@ -122,6 +122,6 @@ def count( __all__ = [ 'LazyCommand', - 'BaseRepository', + 'Repository', 'make_lazy', ] diff --git a/assimilator/core/database/specifications_split.py b/assimilator/core/database/specifications_split.py deleted file mode 100644 index 0a2a2cb..0000000 --- a/assimilator/core/database/specifications_split.py +++ /dev/null @@ -1,58 +0,0 @@ -from abc import abstractmethod -from typing import Tuple, List, Iterable - -from assimilator.core.database.specifications import SpecificationType - - -class SpecificationSplitMixin: - """ - I found out that some data storages will not support specification - that can only build queries and then execute them to get the results. - - Example: redis does not support simultaneous sorting and filtering with pagination, but SQLAlchemy can. - We still want to sort results that come from Redis, so we split specifications into two categories: - 1) Specifications that run before the query is sent to the storage - 1) Specifications that run after the query is sent to the storage and work with a list of results - - This mixin allows you to use them in your repositories - """ - - @abstractmethod - def _is_before_specification(self, specification: SpecificationType) -> bool: - """ Checks that the specification provided is a specification that must be run before the query """ - raise NotImplementedError("is_before_specification() is not implemented") - - def _split_specifications( - self, specifications: Iterable[SpecificationType], no_after_specs: bool = False - ) -> Tuple[List[SpecificationType], List[SpecificationType]]: - """ - Splits our specifications into two categories, so we can use them later. - - Sometimes you only want to use 'before_specs' and show your - users that they cannot use 'after_specs' as they are useless. - Example: using repository.get() with pagination. You only get one result, so pagination is useless. - - If that is the case, then turn on 'no_after_specs' and the function will raise a Warning() is there are - any 'after_specs' present. - """ - before_specs, after_specs = [], [] - - for specification in specifications: - if self._is_before_specification(specification): - before_specs.append(specification) - else: - after_specs.append(specification) - - if no_after_specs and after_specs: - raise Warning( - "You are using after specifications in a function that bans them " - "We cannot apply them, as the developers decided that after" - " specifications do not fit in the function. " - "Please, remove all the specifications that run after the results are obtained." - "Examples: sorting, ordering, pagination, etc." - ) - - return before_specs, after_specs - - -__all__ = ['SpecificationSplitMixin'] diff --git a/assimilator/core/database/unit_of_work.py b/assimilator/core/database/unit_of_work.py index 51bde1a..637b353 100644 --- a/assimilator/core/database/unit_of_work.py +++ b/assimilator/core/database/unit_of_work.py @@ -1,14 +1,14 @@ from abc import ABC, abstractmethod from typing import Optional -from assimilator.core.database.repository import BaseRepository +from assimilator.core.database.repository import Repository from assimilator.core.patterns import ErrorWrapper class UnitOfWork(ABC): error_wrapper: ErrorWrapper = ErrorWrapper() - def __init__(self, repository: BaseRepository, error_wrapper: Optional[ErrorWrapper] = None): + def __init__(self, repository: Repository, error_wrapper: Optional[ErrorWrapper] = None): self.repository = repository if error_wrapper is not None: self.error_wrapper = error_wrapper diff --git a/assimilator/core/patterns/error_wrapper.py b/assimilator/core/patterns/error_wrapper.py index 5137814..f475ef6 100644 --- a/assimilator/core/patterns/error_wrapper.py +++ b/assimilator/core/patterns/error_wrapper.py @@ -1,22 +1,29 @@ from functools import wraps -from typing import Dict, Type, Optional, Callable +from typing import Dict, Type, Optional, Callable, Container class ErrorWrapper: def __init__( self, - error_mappings: Dict[Type[Exception], Type[Exception]] = None, + error_mappings: Optional[Dict[Type[Exception], Type[Exception]]] = None, default_error: Optional[Type[Exception]] = None, + skipped_errors: Optional[Container[Type[Exception]]] = None, ): self.error_mappings = error_mappings or {} self.default_error = default_error + self.skipped_errors = skipped_errors or set() + self.skipped_errors = set(self.skipped_errors) + self.skipped_errors.add(BaseException) + def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): if exc_val is None: return + elif exc_type in self.skipped_errors: + raise exc_val wrapped_error = self.error_mappings.get(exc_type) diff --git a/assimilator/internal/database/models.py b/assimilator/internal/database/models.py index 01e1478..5029040 100644 --- a/assimilator/internal/database/models.py +++ b/assimilator/internal/database/models.py @@ -1,7 +1,7 @@ -from typing import TypeVar, Callable +from typing import TypeVar from uuid import uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel from assimilator.core.patterns.mixins import JSONParsedMixin diff --git a/assimilator/internal/database/repository.py b/assimilator/internal/database/repository.py index bcffe4d..53a7c28 100644 --- a/assimilator/internal/database/repository.py +++ b/assimilator/internal/database/repository.py @@ -1,17 +1,15 @@ -import re -from typing import Type, Union, Optional, Iterable, TypeVar, List, Tuple +from typing import Type, Union, Optional, TypeVar, List -from assimilator.core.database.repository import make_lazy from assimilator.internal.database.models import InternalModel from assimilator.core.patterns.error_wrapper import ErrorWrapper from assimilator.internal.database.error_wrapper import InternalErrorWrapper +from assimilator.core.database import Repository, SpecificationType, LazyCommand, make_lazy from assimilator.internal.database.specifications import InternalSpecificationList, internal_filter -from assimilator.core.database import BaseRepository, SpecificationType, LazyCommand, SpecificationSplitMixin ModelT = TypeVar("ModelT", bound=InternalModel) -class InternalRepository(SpecificationSplitMixin, BaseRepository): +class InternalRepository(Repository): session: dict model: Type[ModelT] @@ -31,9 +29,6 @@ def __init__( error_wrapper=error_wrapper or InternalErrorWrapper(), ) - def _is_before_specification(self, specification: SpecificationType) -> bool: - return specification is internal_filter or specification is self.specs.filter - @make_lazy def get( self, @@ -41,11 +36,9 @@ def get( lazy: bool = False, initial_query: Optional[str] = None, ) -> Union[LazyCommand[ModelT], ModelT]: - before_specs, _ = self._split_specifications(specifications, no_after_specs=True) - query = self._apply_specifications( query=self.get_initial_query(initial_query), - specifications=before_specs, + specifications=specifications, ) return self.session[query] @@ -56,22 +49,10 @@ def filter( lazy: bool = False, initial_query: Optional[str] = None, ) -> Union[LazyCommand[List[ModelT]], List[ModelT]]: - before_specs, after_specs = self._split_specifications(specifications) - - if not before_specs: # No filters, get all the data from the session - models = list(self.session.values()) - else: - models = [] - key_mask = self._apply_specifications( - query=self.get_initial_query(initial_query), - specifications=before_specs, - ) - - for key, value in self.session.items(): - if re.match(key_mask, key): - models.append(value) - - return self._apply_specifications(query=models, specifications=after_specs) + return self._apply_specifications( + query=list(self.session.values()), + specifications=specifications, + ) def save(self, obj: ModelT) -> None: self.session[obj.id] = obj diff --git a/assimilator/internal/database/specifications.py b/assimilator/internal/database/specifications.py index e816bb6..bd5e9a6 100644 --- a/assimilator/internal/database/specifications.py +++ b/assimilator/internal/database/specifications.py @@ -1,16 +1,18 @@ import operator -from typing import List, Iterable, Any, Union, Callable +from typing import List, Iterable, Any, Union, Callable, Optional from assimilator.core.database import specification, SpecificationList from assimilator.internal.database.models import InternalModel +QueryT = Union[str, List[InternalModel]] + + @specification -def internal_filter(*args, query: Union[List[InternalModel], str], **kwargs) -> Iterable[InternalModel]: +def internal_filter(*args, query: QueryT, **kwargs) -> Iterable[InternalModel]: if isinstance(query, str): return f"{query}{''.join(args)}" - - if not (kwargs or args): # no filters present + elif not (kwargs or args): # no filters present return query parsed_arguments: List[(str, Callable, Any)] = [] @@ -39,28 +41,50 @@ def internal_filter(*args, query: Union[List[InternalModel], str], **kwargs) -> parsed_arguments.append((field, operation, value)) - return filter( + return list(filter( lambda model: all( - operation_(getattr(model, field_), val) for field_, operation_, val in parsed_arguments - ), query, - ) + operation_(getattr(model, field_), val) + for field_, operation_, val in parsed_arguments + ), + query, + )) @specification -def internal_order(*args, query: List[InternalModel], **kwargs) -> Iterable[InternalModel]: - return sorted( - query, - key=lambda item: [getattr(item, argument) for argument in (args, *kwargs.keys())], - ) +def internal_order(*args, query: QueryT, **kwargs) -> Iterable[InternalModel]: + if isinstance(query, str): + return query + + fields = (*args, *kwargs.keys()) + + if not any(field.startswith("-") for field in fields): + query.sort(key=lambda item: [getattr(item, argument) for argument in fields]) + return query + + for field in fields: + reverse = field.startswith("-") + query.sort(key=lambda item: getattr(item, field.strip("-")), reverse=reverse) + + return query @specification -def internal_paginate(limit: int, offset: int, query: List[InternalModel]) -> Iterable[InternalModel]: - return query[limit:offset] +def internal_paginate( + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: QueryT = None, +) -> Iterable[InternalModel]: + if query is None: + raise ValueError("Query must not be None in the specification!") + if isinstance(query, str): + return query + + return query[offset:limit] @specification -def internal_join(*args, query: Any, **kwargs) -> Any: +def internal_join(*args, query: QueryT, **kwargs) -> Any: return query diff --git a/assimilator/internal/database/unit_of_work.py b/assimilator/internal/database/unit_of_work.py index 67237a7..26ec9fd 100644 --- a/assimilator/internal/database/unit_of_work.py +++ b/assimilator/internal/database/unit_of_work.py @@ -2,12 +2,15 @@ from typing import Optional from assimilator.core.database import UnitOfWork -from assimilator.core.database.repository import BaseRepository +from assimilator.core.database.repository import Repository +from assimilator.internal.database.repository import InternalRepository from assimilator.internal.database.error_wrapper import InternalErrorWrapper class InternalUnitOfWork(UnitOfWork): - def __init__(self, repository: BaseRepository): + repository: InternalRepository + + def __init__(self, repository: Repository): super(InternalUnitOfWork, self).__init__( repository=repository, error_wrapper=InternalErrorWrapper(), diff --git a/assimilator/redis_/database/repository.py b/assimilator/redis_/database/repository.py index 4f1ee7e..5768ef3 100644 --- a/assimilator/redis_/database/repository.py +++ b/assimilator/redis_/database/repository.py @@ -9,17 +9,16 @@ from assimilator.core.database import ( SpecificationList, SpecificationType, - SpecificationSplitMixin, - BaseRepository, + Repository, LazyCommand, make_lazy, ) -from assimilator.internal.database.specifications import InternalSpecificationList, internal_filter +from assimilator.internal.database.specifications import InternalSpecificationList RedisModelT = TypeVar("RedisModelT", bound=RedisModel) -class RedisRepository(SpecificationSplitMixin, BaseRepository): +class RedisRepository(Repository): session: Redis transaction: Union[Pipeline, Redis] model: Type[RedisModelT] @@ -31,18 +30,20 @@ def __init__( initial_query: Optional[str] = '', specifications: Type[SpecificationList] = InternalSpecificationList, error_wrapper: Optional[ErrorWrapper] = None, + use_double_filter: bool = True, ): super(RedisRepository, self).__init__( session=session, model=model, initial_query=initial_query, specifications=specifications, - error_wrapper=error_wrapper or ErrorWrapper(default_error=DataLayerError) + error_wrapper=error_wrapper or ErrorWrapper( + default_error=DataLayerError, + skipped_errors=(NotFoundError,) + ) ) self.transaction = session - - def _is_before_specification(self, specification: SpecificationType) -> bool: - return True + self.use_double_specifications = use_double_filter @make_lazy def get( @@ -51,11 +52,9 @@ def get( lazy: bool = False, initial_query: Optional[str] = None, ) -> Union[LazyCommand[RedisModelT], RedisModelT]: - before_specs, _ = self._split_specifications(specifications, no_after_specs=True) - query = self._apply_specifications( query=self.get_initial_query(initial_query), - specifications=before_specs, + specifications=specifications, ) found_obj = self.session.get(query) @@ -71,27 +70,20 @@ def filter( lazy: bool = False, initial_query: Optional[str] = None, ) -> Union[LazyCommand[Collection[RedisModelT]], Collection[RedisModelT]]: - if not specifications: - return [ - self.model.from_json(value) - for value in self.session.mget(self.session.keys("*")) - ] - - before_specs, after_specs = self._split_specifications(specifications) - - if before_specs: + if self.use_double_specifications and specifications: key_name = self._apply_specifications( query=self.get_initial_query(initial_query), - specifications=before_specs, - ) + specifications=specifications, + ) or "*" else: key_name = "*" - models = [ - self.model.from_json(value) - for value in self.session.mget(self.session.keys(key_name)) - ] - return self._apply_specifications(query=models, specifications=after_specs) + key_name = key_name or "*" + + models = self.session.mget(self.session.keys(key_name)) + return list(self._apply_specifications(specifications=specifications, query=[ + self.model.from_json(value) for value in models + ])) def save(self, obj: RedisModelT) -> None: self.transaction.set( @@ -124,10 +116,9 @@ def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union if specifications: return self.session.dbsize() - before_specs, _ = self._split_specifications(specifications, no_after_specs=True) filter_query = self._apply_specifications( query=self.get_initial_query(), - specifications=before_specs, + specifications=specifications, ) return len(self.session.keys(filter_query)) diff --git a/docs/patterns/database.md b/docs/patterns/database.md index 7faceef..ba8cda1 100644 --- a/docs/patterns/database.md +++ b/docs/patterns/database.md @@ -74,20 +74,24 @@ If you want to create your own repository, then you are going to have to overrid But, please, do not make new functions available to the outer world. You can do this: + ```python -from assimilator.core.database import BaseRepository +from assimilator.core.database import Repository + -class UserRepository(BaseRepository): +class UserRepository(Repository): def _users_private_func(self): # Cannot be called outside return 'Do something' ``` And call that function inside of your repository. But, never do this: + ```python -from assimilator.core.database import BaseRepository +from assimilator.core.database import Repository -class UserRepository(BaseRepository): + +class UserRepository(Repository): def get_ser_by_id(self): # Cannot be called outside return self.get(filter_specification(id=1)) @@ -95,19 +99,18 @@ class UserRepository(BaseRepository): ``` Since it is going to be really hard for you to replace one repository to another. Example: - ```python -from assimilator.core.database import BaseRepository +from assimilator.core.database import Repository from users.repository import UserRepository from products.repository import ProductRepository -def get_by_id(id, repository: BaseRepository): +def get_by_id(id, repository: Repository): return repository.get(filter_specification(id=1)) get_by_id(UserRepository()) -get_by_id(ProductRepository()) +get_by_id(ProductRepository()) # You can call the function with both repositories, and it will probably work fine ``` @@ -297,17 +300,17 @@ in the function that you are calling and set it to `True`. After that, a `LazyCo object allows you to call it as a function or iterate over it to get the results: ```python -from assimilator.core.database import BaseRepository +from assimilator.core.database import Repository -def print_all_usernames(repository: BaseRepository): +def print_all_usernames(repository: Repository): for user in repository.filter(lazy=True): print(user.username) # we don't want to receive a list of all the users, but want to iterate # through it and only get 1 user at a time -def count_users_if_argument_true(do_count, repository: BaseRepository): +def count_users_if_argument_true(do_count, repository: Repository): count_command = repository.count(lazy=True) # turn on lazy and get LazyCommand diff --git a/examples/alchemy/__init__.py b/examples/alchemy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/alchemy/database/__init__.py b/examples/alchemy/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/alchemy/database/dependencies.py b/examples/alchemy/database/dependencies.py new file mode 100644 index 0000000..b117b78 --- /dev/null +++ b/examples/alchemy/database/dependencies.py @@ -0,0 +1,13 @@ +from assimilator.alchemy.database import AlchemyRepository, AlchemyUnitOfWork +from examples.alchemy.database.models import User, DatabaseSession + + +def create_repository(): # factory for RedisRepository + return AlchemyRepository(session=DatabaseSession(), model=User) + + +def create_uow(): # factory for RedisUnitOfWork + return AlchemyUnitOfWork(repository=create_repository()) + + +__all__ = ['create_repository', 'create_uow'] diff --git a/examples/alchemy/database/main.py b/examples/alchemy/database/main.py new file mode 100644 index 0000000..97c169a --- /dev/null +++ b/examples/alchemy/database/main.py @@ -0,0 +1,112 @@ +import random +import string + +from assimilator.core.database import UnitOfWork, Repository, NotFoundError +from assimilator.core.patterns import LazyCommand +from core.database import DataLayerError + +from dependencies import create_uow +from examples.alchemy.database.models import User + + +def create_user(username: str, email: str, balance: float, uow: UnitOfWork) -> int: + with uow: + new_user = User(username=username, email=email, balance=balance) + uow.repository.save(new_user) + uow.commit() + return new_user.id # id generated by default. But, we can change that behaviour if we need + + +def read_user(id: int, repository: Repository) -> User: + return repository.get(repository.specs.filter(id=id)) + + +def buy_product(user_id: int, product_price: int, uow: UnitOfWork): + with uow: + found_user = read_user(id=user_id, repository=uow.repository) + + found_user.balance -= product_price + uow.repository.update(found_user) + uow.commit() + + +def refresh_user(old_user: User, repository: Repository) -> User: + try: + repository.refresh(old_user) + except DataLayerError: + pass + + return old_user + + +def create_many_users(uow): + with uow: + for i in range(100): + new_user = User( + username="".join(random.sample(string.ascii_letters, 10)), + email=f"{''.join(random.sample(string.ascii_letters, 10))}@gmail.com", + balance=random.randint(0, 100), + ) + uow.repository.save(new_user) + + uow.commit() # Commit is only called once! + + +def show_rich_users(balance: int, repository: Repository): + users: LazyCommand[User] = repository.filter( + repository.specs.filter(balance__gt=balance), + repository.specs.paginate(limit=10), + lazy=True, + ) + + for rich_user in users: + print("The user", rich_user.username, "is rich!", "Balance:", rich_user.balance) + + +def delete_user(id: int, uow: UnitOfWork): + with uow: + user_to_delete = uow.repository.get( + uow.repository.specs.filter(id=id) + ) + uow.repository.delete(user_to_delete) + uow.commit() + + +def order_users(repository: Repository): + for i, ordered_user in enumerate(repository.filter( + repository.specs.order('id', '-balance'), + repository.specs.paginate(offset=20, limit=40), + )): + print(f"User {i} ordered by id and balance:", ordered_user.username, ordered_user.balance) + + +if __name__ == '__main__': + new_user_id = create_user( + username="Andrey", + email="python.on.papyrus@gmail.com", + balance=1000, + uow=create_uow(), + ) + + print(f"User with '{new_user_id}' was created") + + user = read_user(id=new_user_id, repository=create_uow().repository) + print("User returned from Redis:", user) + + buy_product(user_id=new_user_id, product_price=100, uow=create_uow()) + + updated_user = refresh_user(user, repository=create_uow().repository) + print("User balance after product purchase:", updated_user.balance) + + create_many_users(create_uow()) + show_rich_users(balance=90, repository=create_uow().repository) + + delete_user(id=new_user_id, uow=create_uow()) + print("User is deleted from the storage!") + + try: + read_user(id=new_user_id, repository=create_uow().repository) + except NotFoundError as error: + print("User was not found due to his deletion! Error:", error) + + order_users(repository=create_uow().repository) diff --git a/examples/alchemy/database/models.py b/examples/alchemy/database/models.py new file mode 100644 index 0000000..0fac7b1 --- /dev/null +++ b/examples/alchemy/database/models.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine, Column, Integer, String, Float +from sqlalchemy.orm import declarative_base, sessionmaker + + +engine = create_engine(url="sqlite:///:memory:") +DatabaseSession = sessionmaker(bind=engine) +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer(), primary_key=True) + username = Column(String()) + email = Column(String()) + balance = Column(Float()) + + def __str__(self): + return f"{self.id} {self.username} {self.email}" + + +Base.metadata.create_all(engine) + +__all__ = ['User', 'DatabaseSession'] diff --git a/examples/redis/database/dependencies.py b/examples/redis/database/dependencies.py index 485bc49..de8b399 100644 --- a/examples/redis/database/dependencies.py +++ b/examples/redis/database/dependencies.py @@ -10,8 +10,8 @@ def create_repository(): # factory for RedisRepository return RedisRepository(session=session, model=RedisUser) -def create_unit_of_work(): # factory for RedisUnitOfWork +def create_uow(): # factory for RedisUnitOfWork return RedisUnitOfWork(repository=create_repository()) -__all__ = ['create_repository', 'create_unit_of_work'] +__all__ = ['create_repository', 'create_uow'] diff --git a/examples/redis/database/main.py b/examples/redis/database/main.py index bac7d5f..9c5cd4a 100644 --- a/examples/redis/database/main.py +++ b/examples/redis/database/main.py @@ -2,9 +2,10 @@ import string from uuid import UUID -from assimilator.core.database import UnitOfWork, BaseRepository +from assimilator.core.database import UnitOfWork, Repository, NotFoundError +from assimilator.core.patterns import LazyCommand -from dependencies import create_unit_of_work +from dependencies import create_uow from examples.redis.database.models import RedisUser @@ -16,7 +17,7 @@ def create_user(username: str, email: str, balance: float, uow: UnitOfWork) -> U return new_user.id # id generated by default. But, we can change that behaviour if we need -def read_user(id: UUID, repository: BaseRepository) -> RedisUser: +def read_user(id: UUID, repository: Repository) -> RedisUser: return repository.get(repository.specs.filter(id)) @@ -29,7 +30,7 @@ def buy_product(user_id: UUID, product_price: int, uow: UnitOfWork): uow.commit() -def refresh_user(old_user: RedisUser, repository: BaseRepository) -> RedisUser: +def refresh_user(old_user: RedisUser, repository: Repository) -> RedisUser: repository.refresh(old_user) return old_user @@ -47,8 +48,32 @@ def create_many_users(uow): uow.commit() # Commit is only called once! -def filter_users(repository: BaseRepository, **filters): - return repository.filter(repository.specs.filter(**filters)) +def show_rich_users(balance: int, repository: Repository): + users: LazyCommand[RedisUser] = repository.filter( + repository.specs.filter(balance__gt=balance), + repository.specs.paginate(limit=10), + lazy=True, + ) + + for rich_user in users: + print("The user", rich_user.username, "is rich!", "Balance:", rich_user.balance) + + +def delete_user(id: UUID, uow: UnitOfWork): + with uow: + user_to_delete = uow.repository.get( + uow.repository.specs.filter(id) + ) + uow.repository.delete(user_to_delete) + uow.commit() + + +def order_users(repository: Repository): + for i, ordered_user in enumerate(repository.filter( + repository.specs.order('id', '-balance'), + repository.specs.paginate(offset=20, limit=40), + )): + print(f"User {i} ordered by id and balance:", ordered_user.username, ordered_user.balance) if __name__ == '__main__': @@ -56,22 +81,28 @@ def filter_users(repository: BaseRepository, **filters): username="Andrey", email="python.on.papyrus@gmail.com", balance=1000, - uow=create_unit_of_work(), + uow=create_uow(), ) print(f"User with '{new_user_id}' was created") - user = read_user( - id=new_user_id, - repository=create_unit_of_work().repository, - ) + user = read_user(id=new_user_id, repository=create_uow().repository) print("User returned from Redis:", user) - buy_product(user_id=new_user_id, product_price=100, uow=create_unit_of_work()) + buy_product(user_id=new_user_id, product_price=100, uow=create_uow()) - updated_user = refresh_user(user, repository=create_unit_of_work().repository) + updated_user = refresh_user(user, repository=create_uow().repository) print("User balance after product purchase:", updated_user.balance) - create_many_users(create_unit_of_work()) + create_many_users(create_uow()) + show_rich_users(balance=90, repository=create_uow().repository) + + delete_user(id=new_user_id, uow=create_uow()) + print("User is deleted from the storage!") + + try: + read_user(id=new_user_id, repository=create_uow().repository) + except NotFoundError as error: + print("User was not found due to his deletion! Error:", error) - print(filter_users(repository=create_unit_of_work().repository, balance__gt=90)) + order_users(repository=create_uow().repository) From 7616fdef925925a63b82a71ab8a281341d870b8e Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Thu, 19 Jan 2023 11:05:03 +0100 Subject: [PATCH 11/12] Added a function for filter specification mappings, added a Repository save() using data only, added multiple delete() and update() clauses --- assimilator/alchemy/database/repository.py | 38 ++++++++++--- .../alchemy/database/specifications.py | 45 ++++++++-------- assimilator/core/database/repository.py | 6 +-- assimilator/core/database/specifications.py | 15 +++++- assimilator/internal/database/repository.py | 41 +++++++++++--- .../internal/database/specifications.py | 54 +++++++++---------- assimilator/redis_/database/repository.py | 43 ++++++++++++--- examples/alchemy/database/main.py | 2 +- examples/redis/database/dependencies.py | 6 +++ examples/redis/database/main.py | 12 ++--- 10 files changed, 178 insertions(+), 84 deletions(-) diff --git a/assimilator/alchemy/database/repository.py b/assimilator/alchemy/database/repository.py index c1c9763..d75028b 100644 --- a/assimilator/alchemy/database/repository.py +++ b/assimilator/alchemy/database/repository.py @@ -1,6 +1,6 @@ -from typing import Type, Union, Optional, TypeVar, Collection +from typing import Type, Union, Optional, TypeVar, Collection, Dict -from sqlalchemy import func, select +from sqlalchemy import func, select, update, delete from sqlalchemy.orm import Session, Query from sqlalchemy.inspection import inspect @@ -61,11 +61,28 @@ def filter( ) return [result[0] for result in self.session.execute(query)] - def update(self, obj: AlchemyModelT) -> None: - """ We don't do anything, as the object is going to be updated with the obj.key = value """ + def update(self, obj: Optional[AlchemyModelT] = None, *specifications, **update_values) -> None: + if specifications: + if not update_values: + raise InvalidQueryError( + "You did not provide any update_values " + "to the update() yet provided specifications" + ) + + query: Query = self._apply_specifications( + query=self.get_initial_query(update(self.model)), + specifications=specifications, + ) + self.session.execute(query.values(update_values)) + elif obj is not None: + self.session.add(obj) + + def save(self, obj: Optional[AlchemyModelT] = None, **data) -> AlchemyModelT: + if obj is None: + obj = self.model(**data) - def save(self, obj: AlchemyModelT) -> None: self.session.add(obj) + return obj def refresh(self, obj: AlchemyModelT) -> None: inspection = inspect(obj) @@ -77,8 +94,15 @@ def refresh(self, obj: AlchemyModelT) -> None: self.session.refresh(obj) - def delete(self, obj: AlchemyModelT) -> None: - self.session.delete(obj) + def delete(self, obj: Optional[AlchemyModelT] = None, *specifications: SpecificationType) -> None: + if specifications: + query: Query = self._apply_specifications( + query=self.get_initial_query(delete(self.model)), + specifications=specifications, + ) + self.session.execute(query) + elif obj is not None: + self.session.delete(obj) def is_modified(self, obj: AlchemyModelT) -> bool: return self.session.is_modified(obj) diff --git a/assimilator/alchemy/database/specifications.py b/assimilator/alchemy/database/specifications.py index b0a77a7..a79fbfa 100644 --- a/assimilator/alchemy/database/specifications.py +++ b/assimilator/alchemy/database/specifications.py @@ -2,37 +2,36 @@ from sqlalchemy.orm import Query from sqlalchemy.sql.operators import is_ -from sqlalchemy import not_, column, desc +from sqlalchemy import column, desc -from assimilator.core.database.specifications import specification, SpecificationList +from assimilator.core.database.specifications import specification, SpecificationList,\ + filter_parameter_parser + + +alchemy_filter_mappings = { + "__gt": lambda field_, val: column(field_) > val, + "__gte": lambda field_, val: column(field_) >= val, + "__lt": lambda field_, val: column(field_) < val, + "__lte": lambda field_, val: column(field_) <= val, + "__not": lambda field_, val: column(field_) != val, + "__is": lambda field_, val: is_(column(field_, val)), +} @specification def alchemy_filter(*filters, query: Query, **filters_by) -> Query: filters = list(filters) - removed_filters_by = [] - - for field, value in filters_by.items(): - if field.endswith("__gt"): - filter_ = column(field.replace("__gt", "")) > value - elif field.endswith("__gte"): - filter_ = column(field.replace("__gte", "")) >= value - elif field.endswith("__lt"): - filter_ = column(field.replace("__lt", "")) < value - elif field.endswith("__lte"): - filter_ = column(field.replace("__lte", "")) <= value - elif field.endswith("__not"): - filter_ = not_(column(field.replace("__not", ""))) - elif field.endswith("__is"): - filter_ = is_(column(field.replace("__is", ""))) - else: - continue - filters.append(filter_) - removed_filters_by.append(field) + for field, value in dict(filters_by).items(): + _, parsed_filter = filter_parameter_parser( + field=field, + value=value, + filter_mappings=alchemy_filter_mappings, + ) - for removed_filter in removed_filters_by: - del filters_by[removed_filter] + if parsed_filter is not None: + filters.append(parsed_filter) + del filters_by[field] return query.filter(*filters).filter_by(**filters_by) diff --git a/assimilator/core/database/repository.py b/assimilator/core/database/repository.py index de79e54..c2722d5 100644 --- a/assimilator/core/database/repository.py +++ b/assimilator/core/database/repository.py @@ -92,15 +92,15 @@ def filter( raise NotImplementedError("filter() is not implemented()") @abstractmethod - def save(self, obj: ModelT) -> None: + def save(self, obj: Optional[ModelT] = None, **obj_data) -> ModelT: raise NotImplementedError("save() is not implemented in the repository") @abstractmethod - def delete(self, obj: ModelT) -> None: + def delete(self, obj: Optional[ModelT] = None, *specifications: SpecificationType) -> None: raise NotImplementedError("delete() is not implemented in the repository") @abstractmethod - def update(self, obj: ModelT) -> None: + def update(self, obj: Optional[ModelT] = None, *specifications: SpecificationType, **update_values) -> None: raise NotImplementedError("update() is not implemented in the repository") @abstractmethod diff --git a/assimilator/core/database/specifications.py b/assimilator/core/database/specifications.py index 846ae77..2657690 100644 --- a/assimilator/core/database/specifications.py +++ b/assimilator/core/database/specifications.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Callable, Union, TypeVar +from typing import Callable, Union, TypeVar, Dict, Any, Tuple, Optional from functools import wraps @@ -15,6 +15,18 @@ def __call__(self, query: QueryT) -> QueryT: return self.apply(query) +def filter_parameter_parser( + field: str, + value: Any, + filter_mappings: Dict[str, Callable], +) -> Tuple[Optional[str], Optional[Any]]: + for filter_ending, filter_func in filter_mappings.items(): + if field.endswith(filter_ending): + return filter_ending, filter_func(field.replace(filter_ending, ""), value) + + return None, None + + def specification(func: Callable): def create_specification(*args, **kwargs): @@ -42,4 +54,5 @@ class SpecificationList: 'Specification', 'specification', 'SpecificationType', + 'filter_parameter_parser', ] diff --git a/assimilator/internal/database/repository.py b/assimilator/internal/database/repository.py index 53a7c28..196d656 100644 --- a/assimilator/internal/database/repository.py +++ b/assimilator/internal/database/repository.py @@ -3,8 +3,9 @@ from assimilator.internal.database.models import InternalModel from assimilator.core.patterns.error_wrapper import ErrorWrapper from assimilator.internal.database.error_wrapper import InternalErrorWrapper -from assimilator.core.database import Repository, SpecificationType, LazyCommand, make_lazy -from assimilator.internal.database.specifications import InternalSpecificationList, internal_filter +from assimilator.core.database import Repository, SpecificationType, LazyCommand, \ + make_lazy, InvalidQueryError +from assimilator.internal.database.specifications import InternalSpecificationList ModelT = TypeVar("ModelT", bound=InternalModel) @@ -54,14 +55,36 @@ def filter( specifications=specifications, ) - def save(self, obj: ModelT) -> None: + def save(self, obj: Optional[ModelT] = None, **obj_data) -> ModelT: + if obj is None: + obj = self.model(**obj_data) + self.session[obj.id] = obj + return obj + + def delete(self, obj: Optional[ModelT] = None, *specifications: SpecificationType) -> None: + if specifications: + for model in self.filter(*specifications, lazy=True): + del self.session[model.id] + elif obj is not None: + del self.session[obj.id] + + def update( + self, obj: Optional[ModelT] = None, *specifications: SpecificationType, **update_values, + ) -> None: + if specifications: + if not update_values: + raise InvalidQueryError( + "You did not provide any update_values " + "to the update() yet provided specifications" + ) - def delete(self, obj: ModelT) -> None: - del self.session[obj.id] + for model in self.filter(*specifications, lazy=True): + model.__dict__.update(update_values) + self.save(model) - def update(self, obj: ModelT) -> None: - self.save(obj) + elif obj is not None: + self.save(obj) def is_modified(self, obj: ModelT) -> bool: return self.get(self.specs.filter(id=obj.id)) == obj @@ -71,7 +94,9 @@ def refresh(self, obj: ModelT) -> None: obj.__dict__.update(fresh_obj.__dict__) @make_lazy - def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand[int], int]: + def count( + self, *specifications: SpecificationType, lazy: bool = False, + ) -> Union[LazyCommand[int], int]: if specifications: return len(self.filter(*specifications, lazy=False)) return len(self.session) diff --git a/assimilator/internal/database/specifications.py b/assimilator/internal/database/specifications.py index bd5e9a6..2cb97d9 100644 --- a/assimilator/internal/database/specifications.py +++ b/assimilator/internal/database/specifications.py @@ -1,45 +1,43 @@ import operator from typing import List, Iterable, Any, Union, Callable, Optional -from assimilator.core.database import specification, SpecificationList +from assimilator.core.database import specification, SpecificationList, filter_parameter_parser from assimilator.internal.database.models import InternalModel QueryT = Union[str, List[InternalModel]] +internal_filter_mappings = { + "__gt": lambda field, value: operator.gt, + "__gte": lambda field, value: operator.ge, + "__lt": lambda field, value: operator.lt, + "__lte": lambda field, value: operator.le, + "__not": lambda field, value: operator.not_, + "__is": lambda field, value: operator.is_, +} @specification -def internal_filter(*args, query: QueryT, **kwargs) -> Iterable[InternalModel]: - if isinstance(query, str): - return f"{query}{''.join(args)}" - elif not (kwargs or args): # no filters present +def internal_filter(*filters, query: QueryT, **filters_by) -> Iterable[InternalModel]: + if not (filters_by or filters): # no filters present return query + elif isinstance(query, str): + id_ = filters_by.get('id') + return f'{query}{"".join(filters)}{id_ if id_ else ""}' parsed_arguments: List[(str, Callable, Any)] = [] - for field, value in kwargs.items(): - operation = operator.eq - - if field.endswith("__gt"): - operation = operator.gt - field = field.replace("__gt", "") - elif field.endswith("__gte"): - operation = operator.ge - field = field.replace("__gte", "") - elif field.endswith("__lt"): - operation = operator.lt - field = field.replace("__lt", "") - elif field.endswith("__lte"): - operation = operator.le - field = field.replace("__lte", "") - elif field.endswith("__not"): - operation = operator.not_ - field = field.replace("__not", "") - elif field.endswith("__is"): - operation = operator.is_ - field = field.replace("__is", "") - - parsed_arguments.append((field, operation, value)) + for field, value in dict(filters_by).items(): + ending, parsed_filter = filter_parameter_parser( + field=field, + value=value, + filter_mappings=internal_filter_mappings, + ) + + parsed_arguments.append(( + field.replace(ending, "") if (ending is not None) else field, + parsed_filter or operator.eq, + value, + )) return list(filter( lambda model: all( diff --git a/assimilator/redis_/database/repository.py b/assimilator/redis_/database/repository.py index 5768ef3..15606b9 100644 --- a/assimilator/redis_/database/repository.py +++ b/assimilator/redis_/database/repository.py @@ -4,7 +4,7 @@ from redis.client import Pipeline from assimilator.redis_.database import RedisModel -from assimilator.core.database.exceptions import DataLayerError, NotFoundError +from assimilator.core.database.exceptions import DataLayerError, NotFoundError, InvalidQueryError from assimilator.core.patterns.error_wrapper import ErrorWrapper from assimilator.core.database import ( SpecificationList, @@ -85,7 +85,10 @@ def filter( self.model.from_json(value) for value in models ])) - def save(self, obj: RedisModelT) -> None: + def save(self, obj: Optional[RedisModelT] = None, **obj_data) -> RedisModelT: + if obj is None: + obj = self.model(**obj_data) + self.transaction.set( name=obj.id, value=obj.json(), @@ -95,12 +98,40 @@ def save(self, obj: RedisModelT) -> None: xx=obj.only_update, keepttl=obj.keep_ttl, ) + return obj + + def delete(self, obj: Optional[RedisModelT] = None, *specifications: SpecificationType) -> None: + if specifications: + models = self.filter(*specifications) # TODO: ADD ONLY SPECIFICATIONS + self.transaction.delete(*[str(model.id) for model in models]) + elif obj is not None: + self.transaction.delete(obj.id) + + def update( + self, + obj: Optional[RedisModelT] = None, + *specifications: SpecificationType, + **update_values, + ) -> None: + if specifications: + if not update_values: + raise InvalidQueryError( + "You did not provide any update_values " + "to the update() yet provided specifications" + ) + + models = self.filter(*specifications, lazy=False) + updated_models = {} + + for model in models: + model.__dict__.update(update_values) + updated_models = {model.id: model} - def delete(self, obj: RedisModelT) -> None: - self.transaction.delete(obj.id) + self.transaction.mset(updated_models) - def update(self, obj: RedisModelT) -> None: - self.save(obj) + elif obj is not None: + obj.only_update = True + self.save(obj) def is_modified(self, obj: RedisModelT) -> None: return self.get(self.specifications.filter(obj.id), lazy=False) == obj diff --git a/examples/alchemy/database/main.py b/examples/alchemy/database/main.py index 97c169a..1829043 100644 --- a/examples/alchemy/database/main.py +++ b/examples/alchemy/database/main.py @@ -3,7 +3,7 @@ from assimilator.core.database import UnitOfWork, Repository, NotFoundError from assimilator.core.patterns import LazyCommand -from core.database import DataLayerError +from assimilator.core.database import DataLayerError from dependencies import create_uow from examples.alchemy.database.models import User diff --git a/examples/redis/database/dependencies.py b/examples/redis/database/dependencies.py index de8b399..d9172d3 100644 --- a/examples/redis/database/dependencies.py +++ b/examples/redis/database/dependencies.py @@ -2,15 +2,21 @@ from assimilator.redis_ import RedisRepository, RedisUnitOfWork from examples.redis.database.models import RedisUser +from internal import InternalRepository, InternalUnitOfWork + +# from examples.alchemy.database.dependencies import create_uow, create_repository session = Redis() +# session = {} def create_repository(): # factory for RedisRepository + # return InternalRepository(session, model=RedisUser) return RedisRepository(session=session, model=RedisUser) def create_uow(): # factory for RedisUnitOfWork + # return InternalUnitOfWork(repository=create_repository()) return RedisUnitOfWork(repository=create_repository()) diff --git a/examples/redis/database/main.py b/examples/redis/database/main.py index 9c5cd4a..7db098b 100644 --- a/examples/redis/database/main.py +++ b/examples/redis/database/main.py @@ -11,14 +11,13 @@ def create_user(username: str, email: str, balance: float, uow: UnitOfWork) -> UUID: with uow: - new_user = RedisUser(username=username, email=email, balance=balance) - uow.repository.save(new_user) + new_user = uow.repository.save(username=username, email=email, balance=balance) uow.commit() return new_user.id # id generated by default. But, we can change that behaviour if we need def read_user(id: UUID, repository: Repository) -> RedisUser: - return repository.get(repository.specs.filter(id)) + return repository.get(repository.specs.filter(id=id)) def buy_product(user_id: UUID, product_price: int, uow: UnitOfWork): @@ -31,19 +30,18 @@ def buy_product(user_id: UUID, product_price: int, uow: UnitOfWork): def refresh_user(old_user: RedisUser, repository: Repository) -> RedisUser: - repository.refresh(old_user) + #repository.refresh(old_user) return old_user def create_many_users(uow): with uow: for i in range(100): - new_user = RedisUser( + uow.repository.save( username="".join(random.sample(string.ascii_letters, 10)), email=f"{''.join(random.sample(string.ascii_letters, 10))}@gmail.com", balance=random.randint(0, 100), ) - uow.repository.save(new_user) uow.commit() # Commit is only called once! @@ -62,7 +60,7 @@ def show_rich_users(balance: int, repository: Repository): def delete_user(id: UUID, uow: UnitOfWork): with uow: user_to_delete = uow.repository.get( - uow.repository.specs.filter(id) + uow.repository.specs.filter(id=id) ) uow.repository.delete(user_to_delete) uow.commit() From b27d4e76ec8805c5f75071f274fd76bd9dc26574 Mon Sep 17 00:00:00 2001 From: knucklesuganda Date: Thu, 19 Jan 2023 11:08:46 +0100 Subject: [PATCH 12/12] Updated the version of the project --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8b67c5a..2c35109 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='py_assimilator', - version='0.2.6', + version='0.3.0', author='Andrey Ivanov', author_email='python.on.papyrus@gmail.com', url='https://pypi.python.org/pypi/py_assimilator/',