From 3191b0ba64cc0927024c6618ae9d900e6efd6938 Mon Sep 17 00:00:00 2001 From: Ilia Kaziamov Date: Sat, 27 Jan 2024 12:20:59 +0300 Subject: [PATCH] Feat/update 3 (#14) Update logic and tests --- .github/workflows/compatibility.yml | 4 +- .github/workflows/coverage.yml | 7 +- .github/workflows/linter.yml | 17 +++ .github/workflows/pypi-publish.yml | 2 +- .github/workflows/tests.yml | 0 pyproject.toml | 2 +- simplecrud/crud.py | 137 +++++++++++++------ simplecrud/tests/test_crud.py | 201 ++++++++++++++++++---------- simplecrud/utils.py | 33 +++++ 9 files changed, 286 insertions(+), 117 deletions(-) delete mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index f481549..fff2050 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -3,7 +3,7 @@ name: Compatibility Check on: push: branches: - - 'dev' + - 'staging' jobs: compatibility: @@ -11,7 +11,7 @@ jobs: strategy: matrix: - python-version: [3.6, "3.10"] + python-version: [ 3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12" ] steps: - name: Checkout code diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9f95239..289f8e9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,7 +2,7 @@ name: Coverage on: push: - branches: [ "main" ] + branches: [ "dev" ] jobs: build: @@ -11,8 +11,9 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - - run: pip install poetry - - run: make install + - run: | + pip install poetry + poetry install - name: Test & publish code coverage uses: paambaati/codeclimate-action@v2.7.4 env: diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index e69de29..d422516 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -0,0 +1,17 @@ +name: lint-test + +on: + push: + branches: [ "dev" ] + +jobs: + build: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: | + pip install poetry + poetry install + poetry run flake8 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index bf88e4c..00d2904 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -15,4 +15,4 @@ jobs: - uses: actions/setup-python@v2 - run: pip install poetry - run: poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} - - run: poetry publish --build \ No newline at end of file + - run: poetry publish --build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index 2027022..8342647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,13 +10,13 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.9" +sqlalchemy = "^2.0.0" [tool.poetry.group.dev.dependencies] flake8 = "^7.0.0" [tool.poetry.group.test.dependencies] pytest = "^7.4.3" -sqlalchemy = "^2.0.25" aiosqlite = "^0.19.0" [build-system] diff --git a/simplecrud/crud.py b/simplecrud/crud.py index 645f7a6..d8feca7 100644 --- a/simplecrud/crud.py +++ b/simplecrud/crud.py @@ -2,29 +2,22 @@ from typing import Dict # from cachetools import LFUCache -from sqlalchemy import select +from sqlalchemy import select, delete from .settings import session +from .utils import inject_connection logger = logging.getLogger(__name__) -async def create_obj(model, **params): - """Create object in db""" - logger.debug(f"{__name__}.create_obj: model = {model}, params = {params}") - new_obj = model(**params) - async with session() as conn: - conn.add(new_obj) - await conn.commit() - await conn.refresh(new_obj) - return new_obj - -async def get_object(model, **filters): +# READ / GET +@inject_connection +async def get_object(model, filters, conn=None): """Get object from db""" key = f"{model.__name__}{filters}" query = select(model).filter_by(**filters) logger.debug(f"{__name__}.get_obj: query = {query}") - async with session() as conn: + async with conn: result = await conn.execute(query) logger.debug(f"{__name__}.get_obj: result = {result}") obj = result.scalars().first() @@ -32,10 +25,11 @@ async def get_object(model, **filters): return obj -async def get_all(model): +@inject_connection +async def get_all(model, conn=None): """Get objects from db""" query = select(model) - async with session() as conn: + async with conn: result = await conn.execute(query) logger.debug(f"{__name__}.get_all: result = {result}") objects = result.scalars().all() @@ -43,10 +37,11 @@ async def get_all(model): return objects -async def get_all_with_filter(model, filters: dict): +@inject_connection +async def get_all_with_filter(model, filters: dict, conn=None): """Get objects from db""" query = select(model).filter_by(**filters) - async with session() as conn: + async with conn: result = await conn.execute(query) logger.debug(f"{__name__}.get_all: result = {result}") objects = result.scalars().all() @@ -54,11 +49,12 @@ async def get_all_with_filter(model, filters: dict): return objects -async def get_objects(model, filters: Dict, limit=10, per_page=10): +@inject_connection +async def get_objects(model, filters: Dict, limit=10, per_page=10, conn=None): """Get objects from db""" query = select(model).filter_by(**filters).limit(limit).offset(per_page) logger.debug(f"{__name__}.get_objects: query = {query}") - async with session() as conn: + async with conn: result = await conn.execute(query) logger.debug(f"{__name__}.get_objects: result = {result}") objects = result.scalars().all() @@ -66,52 +62,113 @@ async def get_objects(model, filters: Dict, limit=10, per_page=10): return objects -async def get_or_create_object(model, **params): +async def get_or_create_object(model, params, conn=None): """Get object from db or create new one""" key = f"{model.__name__}{params}" - obj = await get_object(model, **params) + obj = await get_object(model, params, conn=conn) if not obj: - obj = await create_object(model, **params) + obj = await create_object(model, params, conn=conn) return obj -async def create_object(model, **params): +# CREATE +@inject_connection +async def create_object(model, params, conn=None): """Create object in db""" logger.debug(f"{__name__}.create_obj: model = {model}, params = {params}") new_obj = model(**params) - async with session() as conn: + async with conn: conn.add(new_obj) await conn.commit() - await conn.refresh(new_obj) return new_obj -def create_objects(): - pass +@inject_connection +async def bulk_create(objects, conn=None): + for obj in objects: + await create_object(obj, conn=conn) -async def update_object(model, id: int, **params): - async with session() as conn: - obj = await get_object(model, id=id) - for key, value in params.items(): +# UPDATE +@inject_connection +async def update_object(obj, params, conn=None): + """ + Soft Update object in db. + If attribute not exists in model`s fields, then skip field without error + """ + avaliable_fields = obj.__class__.__table__.columns.keys() + for key, value in params.items(): + if key in avaliable_fields: setattr(obj, key, value) - conn.add(obj) + async with conn: await conn.commit() - await conn.refresh(obj) + conn.refresh(obj) return obj -def update_objects(): - pass +@inject_connection +async def update_or_error(obj, params, conn=None): + """ + Soft Update object in db. + If attribute not exists in model`s fields, then skip field without error + """ + avaliable_fields = obj.__class__.__table__.columns.keys() + for key, value in params.items(): + if key in avaliable_fields: + setattr(obj, key, value) + else: + raise AttributeError(f"Attribute {key} not exists in {obj.__class__.__name__}") + async with conn: + await conn.commit() + conn.refresh(obj) + return obj -def update_or_create_object(): - pass +@inject_connection +async def update_object_by_id(model, id: int, params, conn=None): + obj = await get_object(model, id=id) + updated_obj = await update_object(obj, params, conn=conn) + return updated_obj -async def delete_object(model, id: int): +def bulk_update(): pass -def delete_objects(): - pass +@inject_connection +async def update_or_create_object(model, filters, params, conn=None): + obj = await get_or_create_object(model, filters, conn=conn) + return await update_object(obj, params, conn=conn) + + +# DELETE +@inject_connection +async def delete_object(obj, conn=None): + model = obj.__class__ + id_ = obj.id + return await delete_object_by_id(model, id_, conn=conn) + + +@inject_connection +async def delete_object_by_id(model, id_: int, conn=None): + query = delete(model).where(model.id == id_) + async with conn: + await conn.execute(query) + await conn.commit() + logger.debug(f"{__name__}.delete_object_by_id: model = {model}, id = {id_}") + return True + + +@inject_connection +async def bulk_delete(objects, conn=None): + for obj in objects: + await delete_object(obj, conn=conn) + return True + + +@inject_connection +async def bulk_delete_by_id(model, ids, conn=None): + for id_ in ids: + await delete_object_by_id(model, id_, conn=conn) + return True + diff --git a/simplecrud/tests/test_crud.py b/simplecrud/tests/test_crud.py index fa7bbb8..64efb7a 100644 --- a/simplecrud/tests/test_crud.py +++ b/simplecrud/tests/test_crud.py @@ -1,4 +1,6 @@ +import time import unittest +from unittest import skip from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy.exc import InvalidRequestError @@ -59,155 +61,214 @@ def tearDown(self): @async_to_sync async def test_async_create_obj(self): - params_1 = dict(name="test1") - new_obj_1 = await create_object(ExampleModel, **params_1) - self.assertEqual(new_obj_1.name, "test1") + params_1 = dict(name="test_async_create_obj1") + new_obj_1 = await create_object(ExampleModel, params_1) + self.assertEqual(new_obj_1.name, "test_async_create_obj1") - params_2 = dict(name="test2") - new_obj_2 = await create_object(ExampleModel, **params_2) - self.assertEqual(new_obj_2.name, "test2") + params_2 = dict(name="test_async_create_obj2") + new_obj_2 = await create_object(ExampleModel, params_2) + self.assertEqual(new_obj_2.name, "test_async_create_obj2") @async_to_sync async def test_create_obj_params_error(self): - params_1 = dict(name="test1", wrong="wrong") + params_1 = dict(name="test_create_obj_params_error", wrong="wrong") with self.assertRaises(TypeError): new_obj_1 = await create_object(ExampleModel, **params_1) @async_to_sync async def test_get_object(self): - params_1 = dict(name="test1") - await create_object(ExampleModel, **params_1) - obj = await get_object(ExampleModel, **params_1) - self.assertEqual(obj.name, "test1") + params_1 = dict(name="test_get_object") + await create_object(ExampleModel, params_1) + obj = await get_object(ExampleModel, params_1) + self.assertEqual(obj.name, "test_get_object") @async_to_sync async def test_get_object_not_exist(self): - params_1 = dict(name="test1") - obj = await create_object(ExampleModel, **params_1) - none_expected = await get_object(ExampleModel, name="test0") + params_1 = dict(name="test_get_object_not_exist1") + obj = await create_object(ExampleModel, params_1) + none_expected = await get_object(ExampleModel, filters=dict(name="test_get_object_not_exist0")) self.assertEqual(none_expected, None) @async_to_sync async def test_get_object_error(self): - params_1 = dict(name="test1") - obj = await create_object(ExampleModel, **params_1) + params_1 = dict(name="test_get_object_error") + obj = await create_object(ExampleModel, params_1) with self.assertRaises(InvalidRequestError): - error_expected = await get_object(ExampleModel, wrong="wrong") + error_expected = await get_object(ExampleModel, filters=dict(wrong="wrong")) @async_to_sync async def test_get_all_objects(self): all_ = await get_all(ExampleModel) self.assertEqual(len(all_), 0) for i in range(5): - params_1 = dict(name=f"test{i}") - await create_object(ExampleModel, **params_1) + params_1 = dict(name=f"test_get_all_objects{i}") + await create_object(ExampleModel, params_1) all_ = await get_all(ExampleModel) - self.assertEqual(len(all_), 5) + self.assertEqual(5, len(all_)) self.assertTrue(isinstance(all_, list)) + await delete_object(all_[0]) @async_to_sync - async def test_get_objects_error(self): - raise Exception("Test not complete") - - @async_to_sync - async def test_get_all(self): - raise Exception("Test not complete") - - @async_to_sync - async def test_get_all_negative(self): - raise Exception("Test not complete") + async def test_get_all_if_objects_not_exist(self): + all_ = await get_all(ExampleModel) + self.assertEqual(len(all_), 0) + self.assertTrue(isinstance(all_, list)) - @async_to_sync + # + @skip async def test_get_all_error(self): raise Exception("Test not complete") - @async_to_sync + # + @skip async def test_get_all_with_filter(self): raise Exception("Test not complete") - @async_to_sync + # + @skip async def test_get_all_with_filter_negative(self): raise Exception("Test not complete") - @async_to_sync + # + @skip async def test_get_all_with_filter_error(self): raise Exception("Test not complete") @async_to_sync async def test_get_object_by_filters(self): - params_1 = dict(name="test1") - new_ = await create_object(ExampleModel, **params_1) - obj = await get_object(ExampleModel, id=new_.id) - self.assertEqual(obj.name ,"test1") + params_1 = dict(name="test_get_object_by_filters") + new_ = await create_object(ExampleModel, params_1) + obj = await get_object(ExampleModel, filters=dict(id=new_.id)) + self.assertEqual(obj.name, "test_get_object_by_filters") @async_to_sync async def test_get_object_by_filters_negative(self): - params_1 = dict(name="test1") - new_ = await create_object(ExampleModel, **params_1) + params_1 = dict(name="test_get_object_by_filters_negative") + new_ = await create_object(ExampleModel, params_1) with self.assertRaises(InvalidRequestError): - obj = await get_object(ExampleModel, pk=new_.id) + obj = await get_object(ExampleModel, filters=dict(pk=new_.id)) - @async_to_sync + @skip async def test_get_object_by_filters_error(self): raise Exception("Test not complete") @async_to_sync async def test_get_or_create_object(self): - raise Exception("Test not complete") - - @async_to_sync - async def test_get_or_create_object_negative(self): - raise Exception("Test not complete") - - @async_to_sync - async def test_get_or_create_object_error(self): - raise Exception("Test not complete") + self.assertEqual(len(await get_all(ExampleModel)), 0) + params_1 = dict(name="test_get_or_create_object") + new_1 = await get_or_create_object(ExampleModel, params_1) + self.assertEqual(len(await get_all(ExampleModel)), 1) + new_2 = await get_or_create_object(ExampleModel, params_1) + self.assertEqual(len(await get_all(ExampleModel)), 1) + self.assertEqual(new_1.id, new_2.id) @async_to_sync async def test_update_object(self): - raise Exception("Test not complete") + params_1 = dict(name="test_update_object") + obj1 = await create_object(ExampleModel, params_1) + params_2 = dict(name="test_update_object2") + obj2 = await update_object(obj1, params_2) + self.assertEqual(obj2.name, "test_update_object2") + self.assertEqual(obj1.id, obj2.id) @async_to_sync - async def test_update_object_negative(self): - raise Exception("Test not complete") + async def test_update_or_error(self): + params_1 = dict(name="test_update_object") + obj1 = await create_object(ExampleModel, params_1) + wrong_params = dict(wrong="test_update_object2") + with self.assertRaises(AttributeError) as error: + obj2 = await update_or_error(obj1, wrong_params) + error_msg = "Attribute wrong not exists in ExampleModel" + self.assertEqual(error.exception.args[0], error_msg) @async_to_sync - async def test_update_object_error(self): - raise Exception("Test not complete") - - @async_to_sync - async def test_update_or_create_object(self): - raise Exception("Test not complete") + async def test_soft_update_without_error(self): + params_1 = dict(name="test_update_object_negative") + obj1 = await create_object(ExampleModel, params_1) + self.assertFalse(hasattr(obj1, "wrong")) + wrong_params = dict(wrong="wrong") + obj2 = await update_object(obj1, wrong_params) + self.assertEqual(obj1.id, obj2.id) + self.assertFalse(hasattr(obj2, "wrong")) @async_to_sync - async def test_update_or_create_object_negative(self): - raise Exception("Test not complete") + async def test_update_or_error(self): + params_1 = dict(name="test1") + obj1 = await create_object(ExampleModel, params_1) + with self.assertRaises(AttributeError): + wrong_params = dict(wrong="wrong") + obj2 = await update_or_error(obj1, wrong_params) @async_to_sync + async def test_update_or_create_object(self): + self.assertEqual(len(await get_all(ExampleModel)), 0) + params_1 = dict(name="test_update_or_create_object1") + new_1 = await update_or_create_object(ExampleModel, params_1, params_1) + self.assertEqual(len(await get_all(ExampleModel)), 1) + params_2 = dict(name="test_update_or_create_object2") + new_2 = await update_or_create_object(ExampleModel, params_1, params_2) + self.assertEqual(new_2.name, "test_update_or_create_object2") + self.assertEqual(new_1.id, new_2.id) + + @skip async def test_update_or_create_object_error(self): raise Exception("Test not complete") @async_to_sync async def test_delete_object(self): - raise Exception("Test not complete") + all_ = await get_all(ExampleModel) + self.assertEqual(len(all_), 0) + params_1 = dict(name="test_delete_object") + new_1 = await create_object(ExampleModel, params_1) + all_ = await get_all(ExampleModel) + self.assertEqual(len(all_), 1) + result = await delete_object(new_1) + self.assertEqual(result, True) + all_ = await get_all(ExampleModel) + get_ = await get_object(ExampleModel, filters=dict(id=new_1.id)) + self.assertEqual(len(all_), 0) - @async_to_sync + @skip async def test_delete_object_negative(self): raise Exception("Test not complete") - @async_to_sync + @skip async def test_delete_object_error(self): raise Exception("Test not complete") @async_to_sync async def test_delete_objects(self): - raise Exception("Test not complete") + for i in range(1, 12): + params_1 = dict(name=f"test_delete_objects{i}") + await create_object(ExampleModel, params_1) + all_ = await get_all(ExampleModel) + result = await bulk_delete(all_[0:10]) + all_ = await get_all(ExampleModel) + self.assertEqual(1, len(all_)) + self.assertEqual(all_[0].name, "test_delete_objects11") + self.assertEqual(all_[0].id, 11) @async_to_sync - async def test_delete_objects_negative(self): - raise Exception("Test not complete") + async def test_delete_objects(self): + for i in range(1, 12): + params_1 = dict(name=f"test_delete_objects{i}") + await create_object(ExampleModel, params_1) + objects = await get_all(ExampleModel) + self.assertEqual(11, len(objects)) + await bulk_delete(objects) + all_ = await get_all(ExampleModel) + self.assertEqual(0, len(all_)) + + @async_to_sync - async def test_delete_objects_error(self): - raise Exception("Test not complete") + async def test_bulk_delete_by_id(self): + for i in range(1, 12): + params_1 = dict(name=f"test_delete_objects{i}") + await create_object(ExampleModel, params_1) + ids = [i.id for i in await get_all(ExampleModel)] + self.assertEqual(11, len(ids)) + await bulk_delete(ExampleModel, ids) + all_ = await get_all(ExampleModel) + self.assertEqual(0, len(all_)) diff --git a/simplecrud/utils.py b/simplecrud/utils.py index 1cebd6d..9593b6c 100644 --- a/simplecrud/utils.py +++ b/simplecrud/utils.py @@ -1,4 +1,5 @@ import asyncio +import logging from functools import wraps @@ -12,3 +13,35 @@ def wrapper(*args, **kwargs): return result return wrapper + + +def inject_connection(func): + """Decorator to inject database connection to function""" + + @wraps(func) + def inner(*args, **kwargs): + from simplecrud.settings import session + if kwargs.get('conn') is None: + kwargs['conn'] = session() + result = func(*args, **kwargs) + # try: + # kwargs['conn'].close() + # except: + # pass + return result + + return inner + + +def add_log(func): + """Decorator to add log""" + + @wraps(func) + def inner(*args, **kwargs): + logger = logging.getLogger(__name__) + logger.debug(f"{__name__}.{func.__name__}: args = {args}, kwargs = {kwargs}") + result = func(*args, **kwargs) + logger.debug(f"{__name__}.{func.__name__}: result = {result}") + return result + + return inner