Skip to content

Commit

Permalink
Merge pull request #11 from knucklesuganda/feature/filter_specifications
Browse files Browse the repository at this point in the history
Version 1.0.0 of PyAssimilator
  • Loading branch information
knucklesuganda authored Feb 27, 2023
2 parents e97de49 + bd94d78 commit 66f8e1e
Show file tree
Hide file tree
Showing 102 changed files with 4,623 additions and 942 deletions.
96 changes: 79 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,72 @@
# Assimilator - the best Python patterns for the best projects

![](/images/logo.png)
<p align="center">
<a href="https://knucklesuganda.github.io/py_assimilator/"><img src="https://knucklesuganda.github.io/py_assimilator/images/logo.png" alt="PyAssimilator"></a>
</p>
<p align="center">
<a href="https://pypi.org/project/py-assimilator/" target="_blank">
<img src="https://img.shields.io/github/license/knucklesuganda/py_assimilator?color=%237e56c2&style=for-the-badge" alt="License">
</a>

<a href="https://pypi.org/project/py-assimilator/" target="_blank">
<img src="https://img.shields.io/github/stars/knucklesuganda/py_assimilator?color=%237e56c2&style=for-the-badge" alt="Stars">
</a>
<a href="https://pypi.org/project/py-assimilator/" target="_blank">
<img src="https://img.shields.io/github/last-commit/knucklesuganda/py_assimilator?color=%237e56c2&style=for-the-badge" alt="Last commit">
</a>
</p>


## Install now
* `pip install py_assimilator`
* `pip install py-assimilator`
* `pip install py-assimilator[alchemy]` - Optional SQLAlchemy support
* `pip install py-assimilator[kafka]` - Optional Kafka support
* `pip install py-assimilator[redis]` - Optional Redis support
* `pip install py-assimilator[mongo]` - Optional MongoDB support


## Simple example

Example usage of the code to create a user using all the DDD patterns:
```Python
from assimilator.alchemy.database import AlchemyUnitOfWork, AlchemyRepository
from assimilator.core.database import UnitOfWork

def create_user(username: str, email: str, uow: UnitOfWork):
with uow:
repository = uow.repository # Get Repository pattern
new_user = repository.save(username=username, email=email, balance=0)
uow.commit() # Securely save the data

return new_user


user_repository = AlchemyRepository(
session=alchemy_session, # alchemy db session
model=User, # alchemy user model
)
user_uow = AlchemyUnitOfWork(repository=user_repository)

create_user(
username="Andrey",
email="[email protected]",
uow=user_uow,
)

```

## Why do I need it?
![](images/why_assimilator_no_usage.png)

Patterns are very useful for good code, but only to some extent. Most of them are not suitable for
real life applications. DDD(Domain-driven design) is one of the most popular ways of development
today, but nobody explains how to write most of DDD patterns in Python. Even if they do, life gives you another
issue that cannot be solved with a simple algorithm. That is why [Andrey](https://www.youtube.com/channel/UCSNpJHMOU7FqjD4Ttux0uuw) created
a library for the patterns that he uses in his projects daily.

![](images/why_assimilator_usage.png)

Watch our [Demo]() to find out more about pyAssimilator capabilities.

## Source
* [Github](https://github.com/knucklesuganda/py_assimilator)
Expand All @@ -13,24 +76,23 @@
* [Author's YouTube RU](https://www.youtube.com/channel/UCSNpJHMOU7FqjD4Ttux0uuw)
* [Author's YouTube ENG](https://www.youtube.com/channel/UCeC9LNDwRP9OfjyOFHaSikA)

## About patterns in coding
They are useful, but only to some extent. Most of them are not suitable for
real life applications. DDD(Domain-driven design) is one of the most popular ways of development
today, but nobody explains how to write most of DDD patterns in Python. Even if they do, life gives you another
issue that cannot be solved with a simple algorithm. That is why [Andrey](https://www.youtube.com/channel/UCSNpJHMOU7FqjD4Ttux0uuw) created
a library for the patterns that he uses in his projects daily.

## Stars history
[![Star History Chart](https://api.star-history.com/svg?repos=knucklesuganda/py_assimilator&type=Date)](https://star-history.com/#knucklesuganda/py_assimilator&Date)


## Types of patterns
These are different use cases for the patterns implemented.
These are different use cases for the patterns implemented:

- Database - patterns for database/data layer interactions
- Events - projects with events or event-driven architecture
- Database - patterns for database/data layer interactions.
- Events(in development) - projects with events or event-driven architecture.
- Unidentified - patterns that are useful for different purposes.

## Available providers
Providers are different patterns for external modules like SQLAlchemy or
FastAPI.
Providers are different patterns for external modules like SQLAlchemy or FastAPI.

- Alchemy(Database, Events) - patterns for [SQLAlchemy](https://docs.sqlalchemy.org/en/20/) for both database and events
- Kafka(Events) - patterns in [Kafka](https://kafka.apache.org/) related to events
- Internal(Database, Events) - internal is the type of provider that saves everything in memory(dict, list and all the tools within your app)
- Redis(Database, Events) - redis allows us to work with [Redis](https://redis.io/) memory database
- Alchemy(Database, Events) - patterns for [SQLAlchemy](https://docs.sqlalchemy.org/en/20/) for both database and events.
- Kafka(Events) - patterns in [Kafka](https://kafka.apache.org/) related to events.
- Internal(Database, Events) - internal is the type of provider that saves everything in memory(dict, list and all the tools within your app).
- Redis(Database, Events) - redis_ allows us to work with [Redis](https://redis.io/) memory database.
- MongoDB(Database) - mongo allows us to work with [MongoDB](https://www.mongodb.com/) database.
28 changes: 1 addition & 27 deletions assimilator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,27 +1 @@
from contextlib import contextmanager

import assimilator.core
import assimilator.internal


@contextmanager
def optional_dependencies(error: str = "ignore"):
assert error in {"raise", "warn", "ignore"}
try:
yield None
except ImportError as e:
if error == "raise":
raise e
if error == "warn":
msg = f'Missing optional dependency "{e.name}". Use pip or conda to install.'
print(f'Warning: {msg}')


with optional_dependencies():
import assimilator.alchemy

with optional_dependencies():
import assimilator.kafka_

with optional_dependencies():
import assimilator.redis_ as redis
# TODO: change demo link in docs
2 changes: 0 additions & 2 deletions assimilator/alchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +0,0 @@
from assimilator.alchemy.events import *
from assimilator.alchemy.database import *
3 changes: 2 additions & 1 deletion assimilator/alchemy/database/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from assimilator.alchemy.database.repository import *
from assimilator.alchemy.database.specifications import *
from assimilator.alchemy.database.specifications.specifications import *
from assimilator.alchemy.database.specifications.filtering_options import *
from assimilator.alchemy.database.unit_of_work import *
10 changes: 8 additions & 2 deletions assimilator/alchemy/database/error_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
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.database.exceptions import (
DataLayerError,
NotFoundError,
InvalidQueryError,
MultipleResultsError,
)
from assimilator.core.patterns.error_wrapper import ErrorWrapper


Expand All @@ -10,6 +15,7 @@ def __init__(self):
NoResultFound: NotFoundError,
IntegrityError: InvalidQueryError,
SQLAlchemyError: DataLayerError,
MultipleResultsFound: MultipleResultsError,
}, default_error=DataLayerError)


Expand Down
40 changes: 40 additions & 0 deletions assimilator/alchemy/database/model_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import TypeVar, Type

from sqlalchemy import inspect


T = TypeVar("T")


def get_model_from_relationship(model: T, relationship_name: str):
foreign_prop = getattr(model, relationship_name).property
return foreign_prop.mapper.class_, foreign_prop.uselist


def dict_to_models(data: dict, model: Type[T]) -> T:
for relationship in inspect(model).relationships.keys():
foreign_data = data.get(relationship)
if foreign_data is None:
continue

foreign_model, is_list = get_model_from_relationship(
model=model,
relationship_name=relationship,
)

if not is_list and isinstance(foreign_data, dict):
foreign_data = dict_to_models(data=foreign_data, model=foreign_model)
foreign_data = foreign_model(**foreign_data)
elif is_list:
foreign_models = (
foreign_data for foreign_data in foreign_data
if isinstance(foreign_data, dict)
)

for i, foreign_part in enumerate(foreign_models):
foreign_part = dict_to_models(data=foreign_part, model=foreign_model)
foreign_data[i] = foreign_model(**foreign_part)

data[relationship] = foreign_data

return data
73 changes: 42 additions & 31 deletions assimilator/alchemy/database/repository.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from typing import Type, Union, Optional, TypeVar, Collection

from sqlalchemy import func, select, update, delete
from sqlalchemy.orm import Session, Query
from sqlalchemy import func, select, update, delete, Delete
from sqlalchemy.orm import Session, Query # TODO: change query for alchemy 2
from sqlalchemy.inspection import inspect

from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper
from assimilator.alchemy.database.model_utils import dict_to_models
from assimilator.core.patterns.error_wrapper import ErrorWrapper
from assimilator.core.database.exceptions import InvalidQueryError
from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper
from assimilator.alchemy.database.specifications import AlchemySpecificationList
from assimilator.core.database import Repository, SpecificationList, \
LazyCommand, SpecificationType, make_lazy
from assimilator.core.patterns.error_wrapper import ErrorWrapper
from assimilator.core.database import Repository, LazyCommand, SpecificationType


AlchemyModelT = TypeVar("AlchemyModelT")
Expand All @@ -24,7 +24,7 @@ def __init__(
session: Session,
model: Type[AlchemyModelT],
initial_query: Query = None,
specifications: Type[SpecificationList] = AlchemySpecificationList,
specifications: Type[AlchemySpecificationList] = AlchemySpecificationList,
error_wrapper: Optional[ErrorWrapper] = None,
):
super(AlchemyRepository, self).__init__(
Expand All @@ -35,33 +35,36 @@ def __init__(
error_wrapper=error_wrapper or AlchemyErrorWrapper(),
)

@make_lazy
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),
query=initial_query,
specifications=specifications,
)
return self.session.execute(query).one()[0]

@make_lazy
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),
query=initial_query,
specifications=specifications,
)
return [result[0] for result in self.session.execute(query)]

def update(self, obj: Optional[AlchemyModelT] = None, *specifications, **update_values) -> None:
def update(
self,
obj: Optional[AlchemyModelT] = None,
*specifications: SpecificationType,
**update_values,
) -> None:
obj, specifications = self._check_obj_is_specification(obj, specifications)

if specifications:
Expand All @@ -71,58 +74,66 @@ def update(self, obj: Optional[AlchemyModelT] = None, *specifications, **update_
"to the update() yet provided specifications"
)

query: Query = self._apply_specifications(
query=self.get_initial_query(update(self.model)),
query = self._apply_specifications(
query=update(self.model),
specifications=specifications,
)
self.session.execute(query.values(update_values))

self.session.execute(
query.values(update_values).execution_options(synchronize_session=False)
)

elif obj is not None:
if obj not in self.session:
obj = self.session.merge(obj)

self.session.add(obj)

def save(self, obj: Optional[AlchemyModelT] = None, **data) -> AlchemyModelT:
if obj is None:
obj = self.model(**data)
obj = self.model(**dict_to_models(data=data, model=self.model))

self.session.add(obj)
return obj

def refresh(self, obj: AlchemyModelT) -> None:
inspection = inspect(obj)

if inspection.transient or inspection.pending:
return
elif inspection.detached:
self.session.add(obj)
if obj not in self.session:
obj = self.session.merge(obj)

self.session.refresh(obj)

def delete(self, obj: Optional[AlchemyModelT] = None, *specifications: SpecificationType) -> None:
obj, specifications = self._check_obj_is_specification(obj, specifications)

if specifications:
query: Query = self._apply_specifications(
query=self.get_initial_query(delete(self.model)),
query: Delete = self._apply_specifications(
query=delete(self.model),
specifications=specifications,
)
self.session.execute(query)
self.session.execute(query.execution_options(synchronize_session=False))
elif obj is not None:
self.session.delete(obj)

def is_modified(self, obj: AlchemyModelT) -> bool:
return self.session.is_modified(obj)
return obj in self.session and self.session.is_modified(obj)

@make_lazy
def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand[int], int]:
def count(
self,
*specifications: SpecificationType,
lazy: bool = False,
initial_query: Query = None
) -> 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()")
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))),
initial_query=initial_query or select(func.count(getattr(self.model, primary_keys[0].name))),
)


Expand Down
Loading

0 comments on commit 66f8e1e

Please sign in to comment.