Skip to content

Commit 66f8e1e

Browse files
Merge pull request #11 from knucklesuganda/feature/filter_specifications
Version 1.0.0 of PyAssimilator
2 parents e97de49 + bd94d78 commit 66f8e1e

102 files changed

Lines changed: 4623 additions & 942 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,72 @@
11
# Assimilator - the best Python patterns for the best projects
22

3-
![](/images/logo.png)
3+
<p align="center">
4+
<a href="https://knucklesuganda.github.io/py_assimilator/"><img src="https://knucklesuganda.github.io/py_assimilator/images/logo.png" alt="PyAssimilator"></a>
5+
</p>
6+
<p align="center">
7+
<a href="https://pypi.org/project/py-assimilator/" target="_blank">
8+
<img src="https://img.shields.io/github/license/knucklesuganda/py_assimilator?color=%237e56c2&style=for-the-badge" alt="License">
9+
</a>
10+
11+
<a href="https://pypi.org/project/py-assimilator/" target="_blank">
12+
<img src="https://img.shields.io/github/stars/knucklesuganda/py_assimilator?color=%237e56c2&style=for-the-badge" alt="Stars">
13+
</a>
14+
<a href="https://pypi.org/project/py-assimilator/" target="_blank">
15+
<img src="https://img.shields.io/github/last-commit/knucklesuganda/py_assimilator?color=%237e56c2&style=for-the-badge" alt="Last commit">
16+
</a>
17+
</p>
18+
419

520
## Install now
6-
* `pip install py_assimilator`
21+
* `pip install py-assimilator`
22+
* `pip install py-assimilator[alchemy]` - Optional SQLAlchemy support
23+
* `pip install py-assimilator[kafka]` - Optional Kafka support
24+
* `pip install py-assimilator[redis]` - Optional Redis support
25+
* `pip install py-assimilator[mongo]` - Optional MongoDB support
26+
27+
28+
## Simple example
29+
30+
Example usage of the code to create a user using all the DDD patterns:
31+
```Python
32+
from assimilator.alchemy.database import AlchemyUnitOfWork, AlchemyRepository
33+
from assimilator.core.database import UnitOfWork
34+
35+
def create_user(username: str, email: str, uow: UnitOfWork):
36+
with uow:
37+
repository = uow.repository # Get Repository pattern
38+
new_user = repository.save(username=username, email=email, balance=0)
39+
uow.commit() # Securely save the data
40+
41+
return new_user
42+
43+
44+
user_repository = AlchemyRepository(
45+
session=alchemy_session, # alchemy db session
46+
model=User, # alchemy user model
47+
)
48+
user_uow = AlchemyUnitOfWork(repository=user_repository)
49+
50+
create_user(
51+
username="Andrey",
52+
53+
uow=user_uow,
54+
)
55+
56+
```
57+
58+
## Why do I need it?
59+
![](images/why_assimilator_no_usage.png)
60+
61+
Patterns are very useful for good code, but only to some extent. Most of them are not suitable for
62+
real life applications. DDD(Domain-driven design) is one of the most popular ways of development
63+
today, but nobody explains how to write most of DDD patterns in Python. Even if they do, life gives you another
64+
issue that cannot be solved with a simple algorithm. That is why [Andrey](https://www.youtube.com/channel/UCSNpJHMOU7FqjD4Ttux0uuw) created
65+
a library for the patterns that he uses in his projects daily.
66+
67+
![](images/why_assimilator_usage.png)
68+
69+
Watch our [Demo]() to find out more about pyAssimilator capabilities.
770

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

16-
## About patterns in coding
17-
They are useful, but only to some extent. Most of them are not suitable for
18-
real life applications. DDD(Domain-driven design) is one of the most popular ways of development
19-
today, but nobody explains how to write most of DDD patterns in Python. Even if they do, life gives you another
20-
issue that cannot be solved with a simple algorithm. That is why [Andrey](https://www.youtube.com/channel/UCSNpJHMOU7FqjD4Ttux0uuw) created
21-
a library for the patterns that he uses in his projects daily.
79+
80+
## Stars history
81+
[![Star History Chart](https://api.star-history.com/svg?repos=knucklesuganda/py_assimilator&type=Date)](https://star-history.com/#knucklesuganda/py_assimilator&Date)
82+
2283

2384
## Types of patterns
24-
These are different use cases for the patterns implemented.
85+
These are different use cases for the patterns implemented:
2586

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

2991
## Available providers
30-
Providers are different patterns for external modules like SQLAlchemy or
31-
FastAPI.
92+
Providers are different patterns for external modules like SQLAlchemy or FastAPI.
3293

33-
- Alchemy(Database, Events) - patterns for [SQLAlchemy](https://docs.sqlalchemy.org/en/20/) for both database and events
34-
- Kafka(Events) - patterns in [Kafka](https://kafka.apache.org/) related to events
35-
- Internal(Database, Events) - internal is the type of provider that saves everything in memory(dict, list and all the tools within your app)
36-
- Redis(Database, Events) - redis allows us to work with [Redis](https://redis.io/) memory database
94+
- Alchemy(Database, Events) - patterns for [SQLAlchemy](https://docs.sqlalchemy.org/en/20/) for both database and events.
95+
- Kafka(Events) - patterns in [Kafka](https://kafka.apache.org/) related to events.
96+
- Internal(Database, Events) - internal is the type of provider that saves everything in memory(dict, list and all the tools within your app).
97+
- Redis(Database, Events) - redis_ allows us to work with [Redis](https://redis.io/) memory database.
98+
- MongoDB(Database) - mongo allows us to work with [MongoDB](https://www.mongodb.com/) database.

assimilator/__init__.py

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1 @@
1-
from contextlib import contextmanager
2-
3-
import assimilator.core
4-
import assimilator.internal
5-
6-
7-
@contextmanager
8-
def optional_dependencies(error: str = "ignore"):
9-
assert error in {"raise", "warn", "ignore"}
10-
try:
11-
yield None
12-
except ImportError as e:
13-
if error == "raise":
14-
raise e
15-
if error == "warn":
16-
msg = f'Missing optional dependency "{e.name}". Use pip or conda to install.'
17-
print(f'Warning: {msg}')
18-
19-
20-
with optional_dependencies():
21-
import assimilator.alchemy
22-
23-
with optional_dependencies():
24-
import assimilator.kafka_
25-
26-
with optional_dependencies():
27-
import assimilator.redis_ as redis
1+
# TODO: change demo link in docs

assimilator/alchemy/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +0,0 @@
1-
from assimilator.alchemy.events import *
2-
from assimilator.alchemy.database import *
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from assimilator.alchemy.database.repository import *
2-
from assimilator.alchemy.database.specifications import *
2+
from assimilator.alchemy.database.specifications.specifications import *
3+
from assimilator.alchemy.database.specifications.filtering_options import *
34
from assimilator.alchemy.database.unit_of_work import *

assimilator/alchemy/database/error_wrapper.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError
1+
from sqlalchemy.exc import NoResultFound, IntegrityError, SQLAlchemyError, MultipleResultsFound
22

3-
from assimilator.core.database.exceptions import DataLayerError, NotFoundError, InvalidQueryError
3+
from assimilator.core.database.exceptions import (
4+
DataLayerError,
5+
NotFoundError,
6+
InvalidQueryError,
7+
MultipleResultsError,
8+
)
49
from assimilator.core.patterns.error_wrapper import ErrorWrapper
510

611

@@ -10,6 +15,7 @@ def __init__(self):
1015
NoResultFound: NotFoundError,
1116
IntegrityError: InvalidQueryError,
1217
SQLAlchemyError: DataLayerError,
18+
MultipleResultsFound: MultipleResultsError,
1319
}, default_error=DataLayerError)
1420

1521

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from typing import TypeVar, Type
2+
3+
from sqlalchemy import inspect
4+
5+
6+
T = TypeVar("T")
7+
8+
9+
def get_model_from_relationship(model: T, relationship_name: str):
10+
foreign_prop = getattr(model, relationship_name).property
11+
return foreign_prop.mapper.class_, foreign_prop.uselist
12+
13+
14+
def dict_to_models(data: dict, model: Type[T]) -> T:
15+
for relationship in inspect(model).relationships.keys():
16+
foreign_data = data.get(relationship)
17+
if foreign_data is None:
18+
continue
19+
20+
foreign_model, is_list = get_model_from_relationship(
21+
model=model,
22+
relationship_name=relationship,
23+
)
24+
25+
if not is_list and isinstance(foreign_data, dict):
26+
foreign_data = dict_to_models(data=foreign_data, model=foreign_model)
27+
foreign_data = foreign_model(**foreign_data)
28+
elif is_list:
29+
foreign_models = (
30+
foreign_data for foreign_data in foreign_data
31+
if isinstance(foreign_data, dict)
32+
)
33+
34+
for i, foreign_part in enumerate(foreign_models):
35+
foreign_part = dict_to_models(data=foreign_part, model=foreign_model)
36+
foreign_data[i] = foreign_model(**foreign_part)
37+
38+
data[relationship] = foreign_data
39+
40+
return data

assimilator/alchemy/database/repository.py

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
from typing import Type, Union, Optional, TypeVar, Collection
22

3-
from sqlalchemy import func, select, update, delete
4-
from sqlalchemy.orm import Session, Query
3+
from sqlalchemy import func, select, update, delete, Delete
4+
from sqlalchemy.orm import Session, Query # TODO: change query for alchemy 2
55
from sqlalchemy.inspection import inspect
66

7-
from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper
7+
from assimilator.alchemy.database.model_utils import dict_to_models
8+
from assimilator.core.patterns.error_wrapper import ErrorWrapper
89
from assimilator.core.database.exceptions import InvalidQueryError
10+
from assimilator.alchemy.database.error_wrapper import AlchemyErrorWrapper
911
from assimilator.alchemy.database.specifications import AlchemySpecificationList
10-
from assimilator.core.database import Repository, SpecificationList, \
11-
LazyCommand, SpecificationType, make_lazy
12-
from assimilator.core.patterns.error_wrapper import ErrorWrapper
12+
from assimilator.core.database import Repository, LazyCommand, SpecificationType
1313

1414

1515
AlchemyModelT = TypeVar("AlchemyModelT")
@@ -24,7 +24,7 @@ def __init__(
2424
session: Session,
2525
model: Type[AlchemyModelT],
2626
initial_query: Query = None,
27-
specifications: Type[SpecificationList] = AlchemySpecificationList,
27+
specifications: Type[AlchemySpecificationList] = AlchemySpecificationList,
2828
error_wrapper: Optional[ErrorWrapper] = None,
2929
):
3030
super(AlchemyRepository, self).__init__(
@@ -35,33 +35,36 @@ def __init__(
3535
error_wrapper=error_wrapper or AlchemyErrorWrapper(),
3636
)
3737

38-
@make_lazy
3938
def get(
4039
self,
4140
*specifications: SpecificationType,
4241
lazy: bool = False,
4342
initial_query: Query = None,
4443
) -> Union[AlchemyModelT, LazyCommand[AlchemyModelT]]:
4544
query = self._apply_specifications(
46-
query=self.get_initial_query(initial_query),
45+
query=initial_query,
4746
specifications=specifications,
4847
)
4948
return self.session.execute(query).one()[0]
5049

51-
@make_lazy
5250
def filter(
5351
self,
5452
*specifications: SpecificationType,
5553
lazy: bool = False,
5654
initial_query: Query = None,
5755
) -> Union[Collection[AlchemyModelT], LazyCommand[Collection[AlchemyModelT]]]:
5856
query = self._apply_specifications(
59-
query=self.get_initial_query(initial_query),
57+
query=initial_query,
6058
specifications=specifications,
6159
)
6260
return [result[0] for result in self.session.execute(query)]
6361

64-
def update(self, obj: Optional[AlchemyModelT] = None, *specifications, **update_values) -> None:
62+
def update(
63+
self,
64+
obj: Optional[AlchemyModelT] = None,
65+
*specifications: SpecificationType,
66+
**update_values,
67+
) -> None:
6568
obj, specifications = self._check_obj_is_specification(obj, specifications)
6669

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

74-
query: Query = self._apply_specifications(
75-
query=self.get_initial_query(update(self.model)),
77+
query = self._apply_specifications(
78+
query=update(self.model),
7679
specifications=specifications,
7780
)
78-
self.session.execute(query.values(update_values))
81+
82+
self.session.execute(
83+
query.values(update_values).execution_options(synchronize_session=False)
84+
)
85+
7986
elif obj is not None:
87+
if obj not in self.session:
88+
obj = self.session.merge(obj)
89+
8090
self.session.add(obj)
8191

8292
def save(self, obj: Optional[AlchemyModelT] = None, **data) -> AlchemyModelT:
8393
if obj is None:
84-
obj = self.model(**data)
94+
obj = self.model(**dict_to_models(data=data, model=self.model))
8595

8696
self.session.add(obj)
8797
return obj
8898

8999
def refresh(self, obj: AlchemyModelT) -> None:
90-
inspection = inspect(obj)
91-
92-
if inspection.transient or inspection.pending:
93-
return
94-
elif inspection.detached:
95-
self.session.add(obj)
100+
if obj not in self.session:
101+
obj = self.session.merge(obj)
96102

97103
self.session.refresh(obj)
98104

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

102108
if specifications:
103-
query: Query = self._apply_specifications(
104-
query=self.get_initial_query(delete(self.model)),
109+
query: Delete = self._apply_specifications(
110+
query=delete(self.model),
105111
specifications=specifications,
106112
)
107-
self.session.execute(query)
113+
self.session.execute(query.execution_options(synchronize_session=False))
108114
elif obj is not None:
109115
self.session.delete(obj)
110116

111117
def is_modified(self, obj: AlchemyModelT) -> bool:
112-
return self.session.is_modified(obj)
118+
return obj in self.session and self.session.is_modified(obj)
113119

114-
@make_lazy
115-
def count(self, *specifications: SpecificationType, lazy: bool = False) -> Union[LazyCommand[int], int]:
120+
def count(
121+
self,
122+
*specifications: SpecificationType,
123+
lazy: bool = False,
124+
initial_query: Query = None
125+
) -> Union[LazyCommand[int], int]:
116126
primary_keys = inspect(self.model).primary_key
117127

118128
if not primary_keys:
119-
raise InvalidQueryError("Your repository model does not have"
120-
" any primary keys. We cannot use count()")
129+
raise InvalidQueryError(
130+
"Your repository model does not have any primary keys. We cannot use count()"
131+
)
121132

122133
return self.get(
123134
*specifications,
124135
lazy=False,
125-
query=select(func.count(getattr(self.model, primary_keys[0].name))),
136+
initial_query=initial_query or select(func.count(getattr(self.model, primary_keys[0].name))),
126137
)
127138

128139

0 commit comments

Comments
 (0)