Skip to content

Commit

Permalink
v1.3.0: 100% coverage + fix unit of work commit, rollback, close meth…
Browse files Browse the repository at this point in the history
…ods, when not using via context manager
  • Loading branch information
ALittleMoron committed Apr 20, 2024
1 parent 8a1162d commit 58827a4
Show file tree
Hide file tree
Showing 10 changed files with 150 additions and 23 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ format:
.PHONY: test
test:
@if [ -z $(PDM) ]; then echo "Poetry could not be found. See https://python-poetry.org/docs/"; exit 2; fi
$(PDM) run pytest ./tests --cov-report xml --cov-fail-under 60 --cov ./$(NAME) -vv
$(PDM) run pytest ./tests --cov-report xml --cov-fail-under 95 --cov ./$(NAME) -vv


.PHONY: test_docker
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@

![coverage](./coverage.svg)

>SQLAlchemy repository pattern.
> Repository pattern implementation for SQLAlchemy models.
## About repository pattern

Actually, I know, that my implementation is not good as repository pattern. I know, that
repository must has abstract interface, which must be implemented for different backends. I have
plans to make sqlrepo part of repository-pattern package, which will implements all possible
backends.

## Current state

Now, some features of repository pattern works incorrect or some parts of it is hard to understand
or use. I want to simplify work with repositories, so this is TODO for my project:

* [ ] Add more backends for repository pattern. Now, only SQLAlchemy adapter implemented. I want
to implement other backends to make this repository better to use in different situations.
NOTE: in future sqlrepo will be replaced with something like python-repository-pattern.
* [ ] Add more test cases for main functionality. Now, tested only base cases of repository
method use.
* [ ] Add wrapper for all non sqlrepo exceptions. Now, some functionality could raise
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ dev = [

[project]
name = "sqlrepo"
version = "1.2.1"
version = "1.3.0"
description = "sqlalchemy repositories with crud operations and other utils for it."
authors = [{ name = "Dmitriy Lunev", email = "dima.lunev14@gmail.com" }]
requires-python = ">=3.11"
Expand Down
14 changes: 14 additions & 0 deletions sqlrepo/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ class RepositoryAttributeError(RepositoryError):

class QueryError(BaseSQLRepoError):
"""Base query error."""


# |--------------| Unit of work |--------------|


class UnitOfWorkError(BaseSQLRepoError):
"""Base unit of work error."""


class NonContextManagerUOWUsageError(UnitOfWorkError):
"""Error, caused by incorrect usage of unit of work.
There is only one use case - via context manager. Other use-cases are not valid.
"""
54 changes: 37 additions & 17 deletions sqlrepo/uow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@
from typing import Self

from dev_utils.core.abstract import Abstract, abstract_class_property
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, async_sessionmaker
from sqlalchemy.orm import Session, scoped_session, sessionmaker

from sqlrepo.exc import NonContextManagerUOWUsageError
from sqlrepo.logging import logger

_uow_non_context_manager_usage_msg = (
"Unit of work only provide context manager access. "
"Don't initialize your Unit of work class directly."
)


class BaseAsyncUnitOfWork(ABC, Abstract):
"""Base async unit of work pattern."""

__skip_session_use__: bool = False
session_factory: "async_sessionmaker[AsyncSession]" = abstract_class_property(
async_sessionmaker[AsyncSession],
session_factory: "async_sessionmaker[AsyncSession] | async_scoped_session[AsyncSession]" = (
abstract_class_property(
async_sessionmaker[AsyncSession],
)
)

@abstractmethod
Expand Down Expand Up @@ -47,28 +55,34 @@ async def __aexit__( # noqa: D105

async def commit(self) -> None:
"""Alias for session ``commit``."""
if not self.session or self.__skip_session_use__:
if self.__skip_session_use__:
return
await self.session.commit()
if not hasattr(self, 'session'):
raise NonContextManagerUOWUsageError(_uow_non_context_manager_usage_msg)
await self.session.commit() # pragma: no coverage

async def rollback(self) -> None:
"""Alias for session ``rollback``."""
if not self.session or self.__skip_session_use__:
if self.__skip_session_use__:
return
await self.session.rollback()
if not hasattr(self, 'session'):
raise NonContextManagerUOWUsageError(_uow_non_context_manager_usage_msg)
await self.session.rollback() # pragma: no coverage

async def close(self) -> None:
"""Alias for session ``close``."""
if not self.session or self.__skip_session_use__:
if self.__skip_session_use__:
return
await self.session.close()
if not hasattr(self, 'session'):
raise NonContextManagerUOWUsageError(_uow_non_context_manager_usage_msg)
await self.session.close() # pragma: no coverage


class BaseSyncUnitOfWork(ABC, Abstract):
"""Base sync unit of work pattern."""

__skip_session_use__: bool = False
session_factory: "sessionmaker[Session]" = abstract_class_property(
session_factory: "sessionmaker[Session] | scoped_session[Session]" = abstract_class_property(
sessionmaker[Session],
)

Expand Down Expand Up @@ -100,18 +114,24 @@ def __exit__( # noqa: D105

def commit(self) -> None:
"""Alias for session ``commit``."""
if not self.session or self.__skip_session_use__:
if self.__skip_session_use__:
return
self.session.commit()
if not hasattr(self, 'session'):
raise NonContextManagerUOWUsageError(_uow_non_context_manager_usage_msg)
self.session.commit() # pragma: no coverage

def rollback(self) -> None:
"""Alias for session ``rollback``."""
if not self.session or self.__skip_session_use__:
if self.__skip_session_use__:
return
self.session.rollback()
if not hasattr(self, 'session'):
raise NonContextManagerUOWUsageError(_uow_non_context_manager_usage_msg)
self.session.rollback() # pragma: no coverage

def close(self) -> None:
"""Alias for session ``close``."""
if not self.session or self.__skip_session_use__:
if self.__skip_session_use__:
return
self.session.close()
if not hasattr(self, 'session'):
raise NonContextManagerUOWUsageError(_uow_non_context_manager_usage_msg)
self.session.close() # pragma: no coverage
41 changes: 41 additions & 0 deletions tests/test_async_uow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session

from sqlrepo.exc import NonContextManagerUOWUsageError
from sqlrepo.uow import BaseAsyncUnitOfWork


@pytest.mark.asyncio()
async def test_skip_session_use(
db_async_session_factory: async_scoped_session[AsyncSession],
) -> None:
class SkipUOW(BaseAsyncUnitOfWork):
__skip_session_use__ = True
session_factory = db_async_session_factory # type: ignore

def init_repositories(self, session: AsyncSession) -> None:
pass

async with SkipUOW() as uow:
await uow.commit()
await uow.rollback()
await uow.close()


@pytest.mark.asyncio()
async def test_incorrect_uow_usage(
db_async_session_factory: async_scoped_session[AsyncSession],
) -> None:
class IncorrectUOW(BaseAsyncUnitOfWork):
session_factory = db_async_session_factory # type: ignore

def init_repositories(self, session: AsyncSession) -> None:
pass

instance = IncorrectUOW()
with pytest.raises(NonContextManagerUOWUsageError):
await instance.commit()
with pytest.raises(NonContextManagerUOWUsageError):
await instance.rollback()
with pytest.raises(NonContextManagerUOWUsageError):
await instance.close()
2 changes: 1 addition & 1 deletion tests/test_base_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
],
)
def test_resolve_specific_columns( # noqa
specific_column_mapping: dict[str, ColumnElement[Any]],
specific_column_mapping: Any, # noqa: ANN401
elements: list[str | ColumnElement[Any]],
expected_result: list[str | ColumnElement[Any]], # noqa
) -> None:
Expand Down
11 changes: 9 additions & 2 deletions tests/test_base_repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ class MyRepo(BaseRepository["OtherModel"]): # type: ignore
...


def test_eval_forward_ref() -> None:
class MyRepo(BaseRepository["MyModel"]): # type: ignore
...

assert MyRepo.model_class == MyModel # type: ignore


def test_generic_incorrect_type() -> None:
with pytest.warns(
RepositoryModelClassIncorrectUseWarning,
Expand Down Expand Up @@ -66,8 +73,8 @@ class CorrectRepo(BaseRepository[MyModel]): ...

def test_validate_disable_attributes() -> None:
class CorrectRepo(BaseRepository[MyModel]):
disable_id_field = MyModel.id
disable_field = MyModel.bl
disable_id_field = "id"
disable_field = "bl"
disable_field_type = bool

CorrectRepo._validate_disable_attributes() # type: ignore
Expand Down
35 changes: 35 additions & 0 deletions tests/test_sync_uow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import pytest
from sqlalchemy.orm import Session, scoped_session

from sqlrepo.exc import NonContextManagerUOWUsageError
from sqlrepo.uow import BaseSyncUnitOfWork


def test_skip_session_use(db_sync_session_factory: scoped_session[Session]) -> None:
class SkipUOW(BaseSyncUnitOfWork):
__skip_session_use__ = True
session_factory = db_sync_session_factory # type: ignore

def init_repositories(self, session: Session) -> None:
pass

with SkipUOW() as uow:
uow.commit()
uow.rollback()
uow.close()


def test_incorrect_uow_usage(db_sync_session_factory: scoped_session[Session]) -> None:
class IncorrectUOW(BaseSyncUnitOfWork):
session_factory = db_sync_session_factory # type: ignore

def init_repositories(self, session: Session) -> None:
pass

instance = IncorrectUOW()
with pytest.raises(NonContextManagerUOWUsageError):
instance.commit()
with pytest.raises(NonContextManagerUOWUsageError):
instance.rollback()
with pytest.raises(NonContextManagerUOWUsageError):
instance.close()
Empty file removed tests/test_uow.py
Empty file.

0 comments on commit 58827a4

Please sign in to comment.