diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1eff5a3..5404fce 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,5 +86,3 @@ repos: - id: bandit args: [-c, pyproject.toml] additional_dependencies: ['bandit[toml]'] -default_language_version: - python: python3.9 diff --git a/CHANGES.md b/CHANGES.md index 030b38a..9771edf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +## Version 2.0.0 + +* Refactoring of the module has been carried out. Improved class and method interfaces. + ## Version 1.0.0 * Initial public release. diff --git a/README.md b/README.md index 3ddef0a..7f065b8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ CRUD tools for working with database via SQLAlchemy. - [DB-First](#db-first) - [Features](#features) - [Installation](#installation) - - [Example](#example) + - [Examples](#examples) + - [Full example](#full-example) @@ -29,9 +30,9 @@ Install and update using `pip`: $ pip install -U db_first ``` -## Example +## Examples -File with application initialization `main.py`: +### Full example ```python from uuid import UUID @@ -85,7 +86,7 @@ class ItemController(CreateMixin, ReadMixin, UpdateMixin, DeleteMixin, Paginatio output_schema_of_create = OutputSchema output_schema_of_read = OutputSchema output_schema_of_update = OutputSchema - output_schema_of_paginate = OutputSchema + schema_of_paginate = OutputSchema sortable = ['created_at'] @@ -94,24 +95,23 @@ if __name__ == '__main__': first_new_item = item.create(data={'data': 'first'}) print('Item as object:', first_new_item) - second_new_item = item.create(data={'data': 'second'}, jsonify=True) + second_new_item = item.create(data={'data': 'second'}, serialize=True) print('Item as dict:', second_new_item) first_item = item.read(first_new_item.id) print('Item as object:', first_item) - first_item = item.read(first_new_item.id, jsonify=True) + first_item = item.read(first_new_item.id, serialize=True) print('Item as dict:', first_item) updated_first_item = item.update(data={'id': first_new_item.id, 'data': 'updated_first'}) print('Item as object:', updated_first_item) updated_second_item = item.update( - data={'id': UUID(second_new_item['id']), 'data': 'updated_second'}, jsonify=True + data={'id': UUID(second_new_item['id']), 'data': 'updated_second'}, serialize=True ) print('Item as dict:', updated_second_item) items = item.paginate(sort_created_at='desc') print('Items as objects:', items) - items = item.paginate(sort_created_at='desc', jsonify=True) + items = item.paginate(sort_created_at='desc', serialize=True) print('Items as dicts:', items) - ``` diff --git a/examples/full_example.py b/examples/full_example.py index 1d99988..0665611 100644 --- a/examples/full_example.py +++ b/examples/full_example.py @@ -49,7 +49,7 @@ class Meta: output_schema_of_create = OutputSchema output_schema_of_read = OutputSchema output_schema_of_update = OutputSchema - output_schema_of_paginate = OutputSchema + schema_of_paginate = OutputSchema sortable = ['created_at'] @@ -58,22 +58,22 @@ class Meta: first_new_item = item.create(data={'data': 'first'}) print('Item as object:', first_new_item) - second_new_item = item.create(data={'data': 'second'}, jsonify=True) + second_new_item = item.create(data={'data': 'second'}, serialize=True) print('Item as dict:', second_new_item) first_item = item.read(first_new_item.id) print('Item as object:', first_item) - first_item = item.read(first_new_item.id, jsonify=True) + first_item = item.read(first_new_item.id, serialize=True) print('Item as dict:', first_item) updated_first_item = item.update(data={'id': first_new_item.id, 'data': 'updated_first'}) print('Item as object:', updated_first_item) updated_second_item = item.update( - data={'id': UUID(second_new_item['id']), 'data': 'updated_second'}, jsonify=True + data={'id': UUID(second_new_item['id']), 'data': 'updated_second'}, serialize=True ) print('Item as dict:', updated_second_item) items = item.paginate(sort_created_at='desc') print('Items as objects:', items) - items = item.paginate(sort_created_at='desc', jsonify=True) + items = item.paginate(sort_created_at='desc', serialize=True) print('Items as dicts:', items) diff --git a/pyproject.toml b/pyproject.toml index 16f1ab6..7f61d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ license = {file = "LICENSE"} name = "DB-First" readme = "README.md" requires-python = ">=3.9" -version = "1.0.0" +version = "2.0.0" [project.optional-dependencies] dev = [ diff --git a/src/db_first/base.py b/src/db_first/base.py index 2f0ab72..64921c5 100644 --- a/src/db_first/base.py +++ b/src/db_first/base.py @@ -1,5 +1,4 @@ from typing import Any -from typing import Optional from sqlalchemy.engine import Result @@ -9,24 +8,22 @@ class BaseCRUD: @classmethod - def _get_option_from_meta(cls, name: str, default: Any = ...) -> Optional[Any]: - meta = getattr(cls, 'Meta', None) - if meta is None: + def _get_option_from_meta(cls, name: str) -> Any: + try: + meta = cls.Meta + except AttributeError: raise MetaNotFound('You need add class Meta with options.') try: option = getattr(meta, name) except AttributeError: - if default is Ellipsis: - raise OptionNotFound(f'Option <{name}> not set in Meta class.') - else: - option = default + raise OptionNotFound(f'Option <{name}> not set in Meta class.') return option @classmethod - def _deserialize_data(cls, schema_name: str, data: dict) -> dict: - schema = cls._get_option_from_meta(schema_name, None) + def deserialize_data(cls, schema_name: str, data: dict) -> dict: + schema = cls._get_option_from_meta(schema_name) return schema().load(data) @classmethod @@ -39,7 +36,7 @@ def _clean_data(cls, data: Any) -> Any: :return: cleaned object. """ - empty_values = ['', None, [], {}, (), set()] + empty_values = ('', None, [], {}, (), set()) if isinstance(data, dict): cleaned_dict = {k: cls._clean_data(v) for k, v in data.items()} @@ -53,8 +50,8 @@ def _clean_data(cls, data: Any) -> Any: return data @classmethod - def _data_to_json(cls, schema_name: str, data: Result, fields: list = None) -> dict: - output_schema = cls._get_option_from_meta(schema_name, None) + def serialize_data(cls, schema_name: str, data: Result, fields: list = None) -> dict: + output_schema = cls._get_option_from_meta(schema_name) if isinstance(data, list): serialized_data = output_schema(many=True, only=fields).dump(data) diff --git a/src/db_first/mixins/crud.py b/src/db_first/mixins/crud.py index 17627d1..4aadca7 100644 --- a/src/db_first/mixins/crud.py +++ b/src/db_first/mixins/crud.py @@ -2,13 +2,32 @@ from sqlalchemy import delete from sqlalchemy import select +from sqlalchemy import update from sqlalchemy.engine import Result class CreateMixin: - """Create object in database.""" + """Create object in database. + + This mixin supports the following options in the Meta class: + ``` + class CustomController(CreateMixin, BaseCRUD): + class Meta: + session = Session + model = Model + input_schema_of_create = InputSchema + output_schema_of_create = OutputSchema + + custom_controller = CustomController() + ``` + + `input_schema_of_create` - marshmallow schema for validating and deserialization input data. + `output_schema_of_create` - marshmallow schema for serialization output data. + """ + + def create_object(self, **data) -> Result: + """If this method does not suit you, simply override it in your class.""" - def _create_object(self, **data) -> Result: session = self._get_option_from_meta('session') model = self._get_option_from_meta('model') @@ -17,14 +36,12 @@ def _create_object(self, **data) -> Result: session.commit() return new_obj - def create(self, data: dict, validating: bool = True, jsonify: bool = False) -> Result or dict: - if validating: - self._deserialize_data('input_schema_of_create', data) - - new_object = self._create_object(**data) + def create(self, data: dict, serialize: bool = False) -> Result or dict: + deserialized_data = self.deserialize_data('input_schema_of_create', data) + new_object = self.create_object(**deserialized_data) - if jsonify: - return self._data_to_json('output_schema_of_create', new_object) + if serialize: + return self.serialize_data('output_schema_of_create', new_object) return new_object @@ -32,16 +49,18 @@ def create(self, data: dict, validating: bool = True, jsonify: bool = False) -> class ReadMixin: """Read object from database.""" - def _read_object(self, id: Any) -> Result: + def get_object(self, id: Any) -> Result: + """If this method does not suit you, simply override it in your class.""" + session = self._get_option_from_meta('session') model = self._get_option_from_meta('model') return session.scalars(select(model).where(model.id == id)).one() - def read(self, id, jsonify: bool = False) -> Result or dict: - obj = self._read_object(id) + def read(self, id, serialize: bool = False) -> Result or dict: + obj = self.get_object(id) - if jsonify: - return self._data_to_json('output_schema_of_read', obj) + if serialize: + return self.serialize_data('output_schema_of_read', obj) return obj @@ -49,24 +68,24 @@ def read(self, id, jsonify: bool = False) -> Result or dict: class UpdateMixin: """Update object in database.""" - def _update_object(self, id: Any, **data) -> Result: + def update_object(self, id: Any, **data) -> Result: + """If this method does not suit you, simply override it in your class.""" + session = self._get_option_from_meta('session') model = self._get_option_from_meta('model') + stmt = update(model).where(model.id == id).values(**data) + session.execute(stmt) + obj = session.scalars(select(model).where(model.id == id)).one() - for k, v in data.items(): - setattr(obj, k, v) - session.commit() return obj - def update(self, data: dict, validating: bool = True, jsonify: bool = False) -> Result or dict: - if validating: - self._deserialize_data('input_schema_of_update', data) + def update(self, data: dict, serialize: bool = False) -> Result or dict: + deserialized_data = self.deserialize_data('input_schema_of_update', data) + updated_object = self.update_object(**deserialized_data) - updated_object = self._update_object(**data) - - if jsonify: - return self._data_to_json('output_schema_of_update', updated_object) + if serialize: + return self.serialize_data('output_schema_of_update', updated_object) return updated_object @@ -74,12 +93,13 @@ def update(self, data: dict, validating: bool = True, jsonify: bool = False) -> class DeleteMixin: """Delete object from database.""" - def _delete_object(self, id: Any) -> None: + def delete_object(self, id: Any) -> None: + """If this method does not suit you, simply override it in your class.""" + session = self._get_option_from_meta('session') model = self._get_option_from_meta('model') session.execute(delete(model).where(model.id == id)) - session.commit() def delete(self, id: Any) -> None: - self._delete_object(id) + self.delete_object(id) diff --git a/src/db_first/mixins/pagination.py b/src/db_first/mixins/pagination.py index 9d46206..7d99b18 100644 --- a/src/db_first/mixins/pagination.py +++ b/src/db_first/mixins/pagination.py @@ -5,6 +5,7 @@ from sqlalchemy import Select from sqlalchemy import select +from ..exc import OptionNotFound from ..query_maker import QueryMaker @@ -21,7 +22,7 @@ class Meta: interval_filterable = ['id'] sortable = ['id'] searchable = ['id'] - output_schema_of_paginate = Schema + schema_of_paginate = Schema custom_controller = CustomController() ``` @@ -30,15 +31,19 @@ class Meta: `interval_filterable` - list of fields allowed for filtration interval. `sortable` - list of fields allowed for sorting. `searchable` - a list of fields allowed for search for by substring. - `output_schema_of_paginate` - marshmallow schema for jsonify. + `schema_of_paginate` - marshmallow schema for serialize. """ SORT_PREFIX = 'sort_' - def _extract_filterable_params(self, **kwargs) -> Optional[dict]: - fields = self._get_option_from_meta('filterable', tuple()) - + def _extract_filterable_params(self, **kwargs) -> dict: filterable_params = {} + + try: + fields = self._get_option_from_meta('filterable') + except OptionNotFound: + return filterable_params + for field in fields: if field in kwargs: filterable_params[field] = kwargs[field] @@ -46,9 +51,13 @@ def _extract_filterable_params(self, **kwargs) -> Optional[dict]: return filterable_params def _extract_interval_filterable_params(self, **kwargs) -> Optional[dict]: - fields = self._get_option_from_meta('interval_filterable', tuple()) - interval_filterable_params = {} + + try: + fields = self._get_option_from_meta('interval_filterable') + except OptionNotFound: + return interval_filterable_params + for field in fields: start_field_name = f'start_{field}' if start_field_name in kwargs: @@ -61,9 +70,13 @@ def _extract_interval_filterable_params(self, **kwargs) -> Optional[dict]: return interval_filterable_params def _extract_sortable_params(self, **kwargs) -> Optional[dict]: - fields = self._get_option_from_meta('sortable', tuple()) - sortable_params = {} + + try: + fields = self._get_option_from_meta('sortable') + except OptionNotFound: + return sortable_params + for field in fields: field_name_of_sorting = f'{self.SORT_PREFIX}{field}' if field_name_of_sorting in kwargs: @@ -72,7 +85,7 @@ def _extract_sortable_params(self, **kwargs) -> Optional[dict]: return sortable_params def _extract_searchable_params(self, search: str) -> Optional[dict]: - fields = self._get_option_from_meta('searchable', tuple()) + fields = self._get_option_from_meta('searchable') searchable_params = {field: search for field in fields} return searchable_params @@ -99,7 +112,7 @@ def paginate( page: int = 1, per_page: Optional[int] = 20, max_per_page: Optional[int] = 100, - jsonify: bool = False, + serialize: bool = False, search: Optional[str] = ..., include_metadata: bool = False, fields: Optional[list] = None, @@ -146,8 +159,8 @@ def paginate( stmt = stmt.limit(per_page).offset((page - 1) * per_page) paginated_rows = session.scalars(stmt).all() - if jsonify: - items['items'] = self._data_to_json('output_schema_of_paginate', paginated_rows, fields) + if serialize: + items['items'] = self.serialize_data('schema_of_paginate', paginated_rows, fields) else: items['items'] = paginated_rows diff --git a/tests/conftest.py b/tests/conftest.py index aba7fd8..fbd40eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -166,7 +166,7 @@ class Meta: interval_filterable = ['created_at'] sortable = ['created_at'] searchable = ['first'] - output_schema_of_paginate = fx_parent_paginate_schema + schema_of_paginate = fx_parent_paginate_schema return Parent() diff --git a/tests/test_crud_mixin.py b/tests/test_crud_mixin.py index 383d735..5385181 100644 --- a/tests/test_crud_mixin.py +++ b/tests/test_crud_mixin.py @@ -1,9 +1,12 @@ from copy import deepcopy from uuid import UUID +from uuid import uuid4 import pytest from db_first import BaseCRUD from db_first import ModelMixin +from db_first.exc import MetaNotFound +from db_first.exc import OptionNotFound from db_first.mixins.crud import CreateMixin from db_first.mixins.crud import DeleteMixin from db_first.mixins.crud import ReadMixin @@ -42,8 +45,8 @@ class Meta: output_schema_of_create = SchemaOfResultCreate data_for_create = {'first': next(UNIQUE_STRING)} - TestCreate().create(data=data_for_create, jsonify=True) - new_data = TestCreate().create(data=data_for_create, jsonify=True) + TestCreate().create(data=data_for_create, serialize=True) + new_data = TestCreate().create(data=data_for_create, serialize=True) new_data_for_assert = deepcopy(new_data) assert new_data_for_assert.pop('id') assert new_data_for_assert == data_for_create @@ -54,7 +57,7 @@ class Meta: session = db_session output_schema_of_read = SchemaOfResultCreate - data_for_read = TestRead().read(id=UUID(new_data['id']), jsonify=True) + data_for_read = TestRead().read(id=UUID(new_data['id']), serialize=True) assert new_data == data_for_read class TestUpdate(UpdateMixin, BaseCRUD): @@ -65,7 +68,7 @@ class Meta: output_schema_of_update = SchemaOfResultCreate data_for_update = {'id': UUID(new_data['id']), 'first': next(UNIQUE_STRING)} - updated_data = TestUpdate().update(data=data_for_update, jsonify=True) + updated_data = TestUpdate().update(data=data_for_update, serialize=True) data_for_update['id'] = str(data_for_update['id']) assert updated_data == data_for_update @@ -80,3 +83,52 @@ class Meta: with pytest.raises(NoResultFound): TestRead().read(id=UUID(new_data['id'])) + + +def test_crud_mixin__wrong_meta(fx_db_connection): + + class TestController(CreateMixin, ReadMixin, UpdateMixin, DeleteMixin, BaseCRUD): + pass + + data_for_create = {'first': next(UNIQUE_STRING)} + with pytest.raises(MetaNotFound) as e: + TestController().create(data=data_for_create, serialize=True) + assert e.value.args[0] == 'You need add class Meta with options.' + + with pytest.raises(MetaNotFound) as e: + TestController().read(id=uuid4(), serialize=True) + assert e.value.args[0] == 'You need add class Meta with options.' + + data_for_update = {'id': uuid4(), 'first': next(UNIQUE_STRING)} + with pytest.raises(MetaNotFound) as e: + TestController().update(data=data_for_update, serialize=True) + assert e.value.args[0] == 'You need add class Meta with options.' + + with pytest.raises(MetaNotFound) as e: + TestController().delete(id=uuid4()) + assert e.value.args[0] == 'You need add class Meta with options.' + + +def test_crud_mixin__wrong_options_in_meta(fx_db_connection): + + class TestController(CreateMixin, ReadMixin, UpdateMixin, DeleteMixin, BaseCRUD): + class Meta: + pass + + data_for_create = {'first': next(UNIQUE_STRING)} + with pytest.raises(OptionNotFound) as e: + TestController().create(data=data_for_create, serialize=True) + assert e.value.args[0] == 'Option not set in Meta class.' + + with pytest.raises(OptionNotFound) as e: + TestController().read(id=uuid4(), serialize=True) + assert e.value.args[0] == 'Option not set in Meta class.' + + data_for_update = {'id': uuid4(), 'first': next(UNIQUE_STRING)} + with pytest.raises(OptionNotFound) as e: + TestController().update(data=data_for_update, serialize=True) + assert e.value.args[0] == 'Option not set in Meta class.' + + with pytest.raises(OptionNotFound) as e: + TestController().delete(id=uuid4()) + assert e.value.args[0] == 'Option not set in Meta class.' diff --git a/tests/test_pagination_mixin.py b/tests/test_pagination_mixin.py index 7daf303..158e6d9 100644 --- a/tests/test_pagination_mixin.py +++ b/tests/test_pagination_mixin.py @@ -63,7 +63,7 @@ def test_controller__get_fields_of_list(fx_db, fx_parent_controller): fx_parent_controller.create(data={'first': next(UNIQUE_STRING)}) fields = ['id'] - items = fx_parent_controller.paginate(fields=fields, jsonify=True) + items = fx_parent_controller.paginate(fields=fields, serialize=True) assert items['items'] for item in items['items']: @@ -154,7 +154,7 @@ class Meta: custom_controller = CustomController() custom_controller.create(data={'first': next(UNIQUE_STRING)}) - items = custom_controller.paginate(fields={'id': ...}) + items = custom_controller.paginate(fields=['id']) assert '_metadata' not in items assert items['items'] @@ -200,7 +200,7 @@ def test_controller__fields_for_relations( data={'first': next(UNIQUE_STRING), 'parent_id': new_parent.id} ) - items = fx_parent_controller.paginate(id=new_parent.id, jsonify=True) + items = fx_parent_controller.paginate(id=new_parent.id, serialize=True) assert len(items['items']) == 1 assert items['items'][0]['id'] == str(new_parent.id) assert items['items'][0]['created_at'] == new_parent.created_at.isoformat()