Skip to content

Commit 7a68451

Browse files
authored
Merge pull request #21 from febus982/unit_of_work_rewrite
Unit of work rewrite
2 parents b843799 + 64897c6 commit 7a68451

27 files changed

+976
-558
lines changed

README.md

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SQLAlchemy bind manager
22
[![Stable Version](https://img.shields.io/pypi/v/sqlalchemy-bind-manager?color=blue)](https://pypi.org/project/sqlalchemy-bind-manager/)
3-
[![stability-alpha](https://img.shields.io/badge/stability-alpha-f4d03f.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#alpha)
3+
[![stability-beta](https://img.shields.io/badge/stability-beta-33bbff.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#beta)
44

55
[![Python 3.8](https://github.com/febus982/sqlalchemy-bind-manager/actions/workflows/python-3.8.yml/badge.svg?event=push)](https://github.com/febus982/sqlalchemy-bind-manager/actions/workflows/python-3.8.yml)
66
[![Python 3.9](https://github.com/febus982/sqlalchemy-bind-manager/actions/workflows/python-3.9.yml/badge.svg?event=push)](https://github.com/febus982/sqlalchemy-bind-manager/actions/workflows/python-3.9.yml)
@@ -30,7 +30,7 @@ pip install sqlalchemy-bind-manager
3030

3131
[//]: # (https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md)
3232
* [![stability-beta](https://img.shields.io/badge/stability-beta-33bbff.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#beta) **SQLAlchemy manager:** Implementation is mostly finalised, needs testing in production.
33-
* [![stability-wip](https://img.shields.io/badge/stability-wip-lightgrey.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#work-in-progress) **Repository / Unit of work:** Major work is stil necessary to finalise the interface, to hide the session management implementation details from the application.
33+
* [![stability-beta](https://img.shields.io/badge/stability-beta-33bbff.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#beta) **Repository / Unit of work:** Implementation is mostly finalised, needs testing in production.
3434

3535

3636
## SQLAlchemy manager
@@ -75,12 +75,15 @@ Example:
7575
```python
7676
bind = sa_manager.get_bind()
7777

78+
7879
class DeclarativeModel(bind.model_declarative_base):
7980
pass
80-
81+
82+
8183
class ImperativeModel:
8284
id: int
8385

86+
8487
imperative_table = Table(
8588
"imperative",
8689
bind.registry_mapper.metadata,
@@ -94,7 +97,7 @@ bind.registry_mapper.map_imperatively(ImperativeModel, imperative_table)
9497
sa_manager.get_mapper().map_imperatively(ImperativeModel, imperative_table)
9598

9699
# Persist an object
97-
o = ImperativeModel() # also o = DeclarativeModel()
100+
o = ImperativeModel() # also o = DeclarativeModel()
98101
o.name = "John"
99102
with sa_manager.get_bind().session_class()() as session:
100103
session.add(o)
@@ -209,47 +212,52 @@ A `Repository` represents a generic interface to persist data object to a storag
209212
using SQLAlchemy. It makes sense that the lifecycle of a `Session` follows the one of the Repository
210213
(If we create a Repository, we're going to do a DB operation, otherwise we don't need a `Session`)
211214

212-
This ensures we the `Session` we use is isolated, and the same for all the operations we do with the
213-
repository.
215+
This ensures the `Session` we use is isolated, and the same for all the operations we do with the
216+
same repository.
214217

215-
The consequence of this choice is we can't use SQLAlchemy lazy loading, so we need to make sure
216-
relationship are loaded eagerly. You can do this by:
218+
The session is automatically closed and reopen with each Repository operation, this make sure these
219+
operation are independent from each other.
217220

218-
* Setup your model/table relationships to always use always eager loading
219-
* Implement ad-hoc methods to deal with relationships as necessary
221+
These choices cause some consequences:
222+
* The operations that modify the database will reload the models from the DB, causing an additional
223+
SELECT query to be issued.
224+
* We can't use SQLAlchemy lazy loading, so we'll need to make sure relationship are always loaded eagerly,
225+
using either:
226+
* Setup your model/table relationships to always use always eager loading
227+
* Implement ad-hoc methods to deal with relationships as necessary
220228

221229
Also `AsyncSession` has [the same limitation on lazy loading](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#asyncio-orm-avoid-lazyloads)
222230
so it makes sense that the two repository implementations behave consistently.
223231

224-
### Use the Unit Of Work to share a session among multiple repositories [alpha]
232+
### Use the Unit Of Work to share a session among multiple repositories
225233

226-
It is possible we need to run several operations in a single database transaction. While a single
234+
It is possible we need to run several operations in a single database get_session. While a single
227235
repository provide by itself an isolated session for single operations, we have to use a different
228236
approach for multiple operations.
229237

230-
We can use the `SQLAlchemyUnitOfWork` or the `SQLAlchemyUnitOfWork` class to provide a shared session to
238+
We can use the `UnitOfWork` or the `AsyncUnitOfWork` class to provide a shared session to
231239
be used for repository operations, **assumed the same bind is used for all the repositories**.
232240
(Two phase transactions are not currently supported).
233241

234-
All repositories operation methods accept the `session` parameter for this purpose. This makes the
235-
operations to bypass the internal repository-managed session.
236-
237242
```python
243+
class MyRepo(SQLAlchemyRepository):
244+
_model = MyModel
245+
class MyOtherRepo(SQLAlchemyRepository):
246+
_model = MyOtherModel
247+
238248
bind = sa_manager.get_bind()
239-
repo1 = MyRepo(bind)
240-
repo2 = MyOtherRepo(bind)
241-
uow = SQLAlchemyUnitOfWork(bind)
249+
uow = UnitOfWork(bind, (MyRepo, MyOtherRepo))
242250

243-
with uow.get_session() as _session:
244-
repo1.save(some_model, session=_session)
245-
repo2.save(some_model, session=_session)
251+
with uow.transaction():
252+
uow.MyRepo.save(some_model)
253+
uow.MyOtherRepo.save(some_other_model)
246254

247255
# Optionally disable the commit/rollback handling
248-
with uow.get_session(commit=False) as _session:
249-
model1 = repo1.get(1, session=_session)
250-
model2 = repo1.get(1, session=_session)
256+
with uow.transaction(read_only=True):
257+
model1 = uow.MyRepo.get(1)
258+
model2 = uow.MyOtherRepo.get(2)
251259
```
252260

253261
Both the UnitOfWork classes create an internal `scoped_session` or `async_scoped_session`, behaving
254262
in the same way at the repositories do. This provides the freedom to tune the session lifecycle based
255-
on our application requirements (e.g. one session per http request, per domain, etc.)
263+
on our application requirements (e.g. one unit of work per http request, per domain, etc.)

mypy.ini

Lines changed: 0 additions & 2 deletions
This file was deleted.

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ readme = "README.md"
99
packages = [{include = "sqlalchemy_bind_manager"}]
1010
keywords = ["sqlalchemy", "config", "manager"]
1111
classifiers = [
12-
"Development Status :: 3 - Alpha",
12+
"Development Status :: 4 - Beta",
1313
"Framework :: AsyncIO",
1414
"Framework :: Pydantic",
1515
"Intended Audience :: Developers",
@@ -63,3 +63,6 @@ addopts = "-n auto --cov-report=term-missing"
6363
testpaths = [
6464
"tests",
6565
]
66+
67+
[tool.mypy]
68+
files = "sqlalchemy_bind_manager"

sqlalchemy_bind_manager/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
SortDirection,
1010
)
1111
from ._unit_of_work import (
12-
SQLAlchemyUnitOfWork,
13-
SQLAlchemyAsyncUnitOfWork,
12+
UnitOfWork,
13+
AsyncUnitOfWork,
1414
)

sqlalchemy_bind_manager/_bind_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def __init_bind(
9191

9292
session_options: dict = config.session_options or {}
9393
session_options.setdefault("expire_on_commit", False)
94+
session_options.setdefault("autobegin", False)
9495

9596
if isinstance(config, SQLAlchemyAsyncConfig):
9697
self.__binds[name] = self.__build_async_bind(

sqlalchemy_bind_manager/_repository/async_.py

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,102 +3,100 @@
33
from typing import Union, Generic, Tuple, Iterable, List, AsyncIterator, Any, Mapping
44

55
from sqlalchemy import select
6-
from sqlalchemy.ext.asyncio import async_scoped_session
6+
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from .._bind_manager import SQLAlchemyAsyncBind
9-
from .._unit_of_work import SQLAlchemyAsyncUnitOfWork
10-
from ..exceptions import ModelNotFound
9+
from .._transaction_handler import AsyncSessionHandler
10+
from ..exceptions import ModelNotFound, InvalidConfig
1111
from .common import MODEL, PRIMARY_KEY, SortDirection, BaseRepository
1212

1313

1414
class SQLAlchemyAsyncRepository(Generic[MODEL], BaseRepository[MODEL], ABC):
15-
_UOW: SQLAlchemyAsyncUnitOfWork
15+
_session_handler: AsyncSessionHandler
16+
_external_session: Union[AsyncSession, None]
1617

17-
def __init__(self, bind: SQLAlchemyAsyncBind) -> None:
18+
def __init__(
19+
self,
20+
bind: Union[SQLAlchemyAsyncBind, None] = None,
21+
session: Union[AsyncSession, None] = None,
22+
) -> None:
1823
"""
1924
:param bind: A configured instance of SQLAlchemyAsyncBind
2025
:type bind: SQLAlchemyAsyncBind
26+
:param session: An externally managed session
27+
:type session: AsyncSession
2128
"""
2229
super().__init__()
23-
self._UOW = SQLAlchemyAsyncUnitOfWork(bind)
30+
if not (bool(bind) ^ bool(session)):
31+
raise InvalidConfig("Either `bind` or `session` have to be used, not both")
32+
self._external_session = session
33+
if bind:
34+
self._session_handler = AsyncSessionHandler(bind)
2435

2536
@asynccontextmanager
26-
async def _get_session(
27-
self, session: Union[async_scoped_session, None] = None, commit: bool = True
28-
) -> AsyncIterator[async_scoped_session]:
29-
if not session:
30-
async with self._UOW.get_session(commit) as _session:
37+
async def _get_session(self, commit: bool = True) -> AsyncIterator[AsyncSession]:
38+
if not self._external_session:
39+
async with self._session_handler.get_session(not commit) as _session:
3140
yield _session
3241
else:
33-
yield session
42+
yield self._external_session
3443

35-
async def save(
36-
self, instance: MODEL, session: Union[async_scoped_session, None] = None
37-
) -> MODEL:
44+
async def save(self, instance: MODEL) -> MODEL:
3845
"""Persist a model.
3946
4047
:param instance: A mapped object instance to be persisted
41-
:param session: Optional session with externally-managed lifecycle
4248
:return: The model instance after being persisted (e.g. with primary key populated)
4349
"""
44-
async with self._get_session(session) as session: # type: ignore
50+
async with self._get_session() as session:
4551
session.add(instance)
4652
return instance
4753

4854
async def save_many(
4955
self,
5056
instances: Iterable[MODEL],
51-
session: Union[async_scoped_session, None] = None,
5257
) -> Iterable[MODEL]:
53-
"""Persist many models in a single database transaction.
58+
"""Persist many models in a single database get_session.
5459
5560
:param instances: A list of mapped objects to be persisted
5661
:type instances: Iterable
57-
:param session: Optional session with externally-managed lifecycle
5862
:return: The model instances after being persisted (e.g. with primary keys populated)
5963
"""
60-
async with self._get_session(session) as session: # type: ignore
64+
async with self._get_session() as session:
6165
session.add_all(instances)
6266
return instances
6367

64-
async def get(
65-
self, identifier: PRIMARY_KEY, session: Union[async_scoped_session, None] = None
66-
) -> MODEL:
68+
async def get(self, identifier: PRIMARY_KEY) -> MODEL:
6769
"""Get a model by primary key.
6870
6971
:param identifier: The primary key
7072
:return: A model instance
71-
:param session: Optional session with externally-managed lifecycle
7273
:raises ModelNotFound: No model has been found using the primary key
7374
"""
7475
# TODO: implement get_many()
75-
async with self._get_session(session, commit=False) as session:
76-
model = await session.get(self._model, identifier) # type: ignore
76+
async with self._get_session(commit=False) as session:
77+
model = await session.get(self._model, identifier)
7778
if model is None:
7879
raise ModelNotFound("No rows found for provided primary key.")
7980
return model
8081

8182
async def delete(
8283
self,
8384
entity: Union[MODEL, PRIMARY_KEY],
84-
session: Union[async_scoped_session, None] = None,
8585
) -> None:
8686
"""Deletes a model.
8787
8888
:param entity: The model instance or the primary key
8989
:type entity: Union[MODEL, PRIMARY_KEY]
90-
:param session: Optional session with externally-managed lifecycle
9190
"""
9291
# TODO: delete without loading the model
9392
obj = entity if self._is_mapped_object(entity) else await self.get(entity) # type: ignore
94-
async with self._get_session(session) as session: # type: ignore
93+
async with self._get_session() as session:
9594
await session.delete(obj)
9695

9796
async def find(
9897
self,
9998
search_params: Union[None, Mapping[str, Any]] = None,
10099
order_by: Union[None, Iterable[Union[str, Tuple[str, SortDirection]]]] = None,
101-
session: Union[async_scoped_session, None] = None,
102100
) -> List[MODEL]:
103101
"""Find models using filters
104102
@@ -107,17 +105,16 @@ async def find(
107105
108106
:param order_by:
109107
:param search_params: A dictionary containing equality filters
110-
:param session: Optional session with externally-managed lifecycle
111108
:return: A collection of models
112109
:rtype: List
113110
"""
114-
stmt = select(self._model) # type: ignore
111+
stmt = select(self._model)
115112
if search_params:
116113
stmt = self._filter_select(stmt, search_params)
117114

118115
if order_by is not None:
119116
stmt = self._filter_order_by(stmt, order_by)
120117

121-
async with self._get_session(session) as session: # type: ignore
118+
async with self._get_session() as session:
122119
result = await session.execute(stmt)
123120
return [x for x in result.scalars()]

sqlalchemy_bind_manager/_repository/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def _validate_mapped_property(self, property_name: str) -> None:
4949
:raises UnmappedProperty: When the property is not mapped.
5050
"""
5151
m: Mapper = class_mapper(self._model)
52-
if property_name not in m.column_attrs: # type: ignore
52+
if property_name not in m.column_attrs:
5353
raise UnmappedProperty(
5454
f"Property `{property_name}` is not mapped in the ORM for model `{self._model}`"
5555
)

0 commit comments

Comments
 (0)