diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index e472d75..725a080 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -14,6 +14,8 @@ jobs: 3.11 3.12 3.13 + 3.14 + pypy3.11 - run: make format test: runs-on: ubuntu-latest @@ -27,6 +29,8 @@ jobs: 3.11 3.12 3.13 + 3.14 + pypy3.11 - run: make test tox: runs-on: ubuntu-latest @@ -40,6 +44,8 @@ jobs: 3.11 3.12 3.13 + 3.14 + pypy3.11 - run: make tox build: runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ba6abf..820eab7 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,13 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.0 hooks: - id: pyupgrade args: [--py39] +- repo: https://github.com/MarcoGorelli/auto-walrus + rev: 0.3.4 + hooks: + - id: auto-walrus - repo: https://github.com/dannysepler/rm_unneeded_f_str rev: v0.2.0 hooks: @@ -43,11 +47,11 @@ repos: - --check-protected - --ignore-no-params - repo: https://github.com/psf/black - rev: 25.1.0 + rev: 25.9.0 hooks: - id: black - repo: https://github.com/asottile/reorder_python_imports - rev: v3.15.0 + rev: v3.16.0 hooks: - id: reorder-python-imports - repo: https://github.com/pre-commit/pygrep-hooks diff --git a/CHANGES.md b/CHANGES.md index 8f52211..1b7da73 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +## Version 4.1.0 + +* Add bulk methods to `CreateMixin`, `ReadMixin`, `UpdateMixin` and `DeleteMixin` mixins. + ## Version 4.0.0 * Add `Validation` decorators. @@ -10,7 +14,7 @@ ## Version 2.1.0 * `PaginateMixin` removed, this functional moved to `ReadMixin`. -* Add parameter `deserialize` to `CreateMixin`, `ReadMixin` and `UpdateMixine`. +* Add parameter `deserialize` to `CreateMixin`, `ReadMixin` and `UpdateMixin`. * Improved docstrings. diff --git a/README.md b/README.md index e9527a0..c193ac5 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ CRUD tools for working with database via SQLAlchemy. ## Features * CreateMixin, ReadMixin, UpdateMixin, DeleteMixin for CRUD operation for database. +* Bulk methods for CreateMixin, ReadMixin, UpdateMixin, DeleteMixin. * ReadMixin support paginated data from database. * StatementMaker class for create query 'per-one-model'. +* Decorators `Validation` for validation and serialize/deserialize arguments, parameters and + return data for function and methods. * Marshmallow (https://github.com/marshmallow-code/marshmallow) schemas for serialization input data. * Marshmallow schemas for deserialization SQLAlchemy result object to `dict`. diff --git a/pyproject.toml b/pyproject.toml index 7f7a1d2..e3bf78b 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 = "4.0.0" +version = "4.1.0" [project.optional-dependencies] dev = [ @@ -34,7 +34,7 @@ dev = [ "pytest==8.4.2", "pytest-cov==7.0.0", "python-dotenv==1.1.1", - "tox==4.30.2", + "tox==4.30.3", "twine==6.2.0" ] @@ -78,11 +78,13 @@ where = ["src"] legacy_tox_ini = """ [tox] env_list = + py314 py313 py312 py311 py310 py39 + pypy311 [testenv] allowlist_externals = * commands = diff --git a/src/db_first/decorators/validation.py b/src/db_first/decorators/validation.py index b59e0dd..3f4ef9d 100644 --- a/src/db_first/decorators/validation.py +++ b/src/db_first/decorators/validation.py @@ -13,7 +13,7 @@ def input( ) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(self, **data) -> Any: + def wrapper(self, **data) -> Any or dict[Any, Any]: if deserialize: deserialized_data = schema(only=keys).load(data) return func(self, **deserialized_data) @@ -31,7 +31,7 @@ def output( ) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(self, *args, **kwargs) -> Any: + def wrapper(self, *args, **kwargs) -> Any or dict[Any, Any]: obj = func(self, *args, **kwargs) if serialize: diff --git a/src/db_first/mixins/crud.py b/src/db_first/mixins/crud.py index bb33e16..9064057 100644 --- a/src/db_first/mixins/crud.py +++ b/src/db_first/mixins/crud.py @@ -4,6 +4,7 @@ from sqlalchemy import delete from sqlalchemy import func +from sqlalchemy import insert from sqlalchemy import Select from sqlalchemy import select from sqlalchemy import update @@ -43,6 +44,14 @@ def create_object(self, **kwargs) -> Result: session.commit() return new_obj + def bulk_create_object(self, data: list[dict]) -> 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(insert(model), data) + class ReadMixin: """Read objects from database. @@ -177,6 +186,13 @@ def read_object(self, id: Any) -> Result: stmt = select(model).where(model.id == id) return session.scalars(stmt).one() + def bulk_read_object(self, ids: list[Any]) -> list[Result]: + session = self._get_option_from_meta('session') + model = self._get_option_from_meta('model') + + stmt = select(model).where(model.id.in_(ids)) + return session.scalars(stmt).all() + class UpdateMixin: """Update object in database. @@ -207,6 +223,14 @@ def update_object(self, id: Any, **kwargs) -> Result: obj = session.scalars(stmt).one() return obj + def bulk_update_object(self, data: list[dict]) -> 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(update(model), data) + class DeleteMixin: """Delete object from database.""" @@ -218,3 +242,11 @@ def delete_object(self, id: Any) -> None: model = self._get_option_from_meta('model') session.execute(delete(model).where(model.id == id)) + + def bulk_delete_object(self, data: list[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.in_(data))) diff --git a/tests/test_crud_mixin__bulk.py b/tests/test_crud_mixin__bulk.py new file mode 100644 index 0000000..e63edb0 --- /dev/null +++ b/tests/test_crud_mixin__bulk.py @@ -0,0 +1,79 @@ +from uuid import uuid4 + +from marshmallow import fields +from marshmallow import Schema +from sqlalchemy import Result +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column + +from src.db_first import BaseCRUD +from src.db_first import ModelMixin +from src.db_first.decorators import Validation +from src.db_first.mixins.crud import CreateMixin +from src.db_first.mixins.crud import DeleteMixin +from src.db_first.mixins.crud import ReadMixin +from src.db_first.mixins.crud import UpdateMixin +from tests.conftest import UNIQUE_STRING + + +class TestSchema(Schema): + id = fields.UUID() + first = fields.String() + + +class BulkTestSchema(Schema): + items = fields.Nested(TestSchema, many=True) + + +class BulkReadTestSchema(Schema): + items = fields.List(fields.UUID()) + + +def test_crud_mixin__bulk(fx_db_connection): + Base, engine, db_session = fx_db_connection + + class TestModel(Base, ModelMixin): + __tablename__ = 'test_model__bulk' + + first: Mapped[str] = mapped_column() + + Base.metadata.create_all(engine) + + class TestCRUD(CreateMixin, ReadMixin, UpdateMixin, DeleteMixin, BaseCRUD): + class Meta: + model = TestModel + session = db_session + filterable = ['id'] + + @Validation.input(BulkTestSchema) + def create(self, **data) -> None: + super().bulk_create_object(data['items']) + + @Validation.input(BulkReadTestSchema) + @Validation.output(BulkTestSchema, serialize=True) + def read(self, **data) -> dict[str, list[Result]]: + return {'items': super().bulk_read_object(data['items'])} + + @Validation.input(BulkTestSchema) + @Validation.output(BulkTestSchema, serialize=True) + def update(self, **data) -> None: + return super().bulk_update_object(data['items']) + + @Validation.input(BulkReadTestSchema) + def delete(self, **data) -> None: + super().bulk_delete_object(data['items']) + + id_ = str(uuid4()) + data_for_create = {'items': [{'id': id_, 'first': next(UNIQUE_STRING)}]} + TestCRUD().create(**data_for_create) + + data_for_read = TestCRUD().read(**{'items': [id_]}) + assert data_for_create == data_for_read + + data_for_update = {'items': [{'id': id_, 'first': next(UNIQUE_STRING)}]} + TestCRUD().update(**data_for_update) + updated_data = TestCRUD().read(**{'items': [id_]}) + assert updated_data == data_for_update + + TestCRUD().delete(**{'items': [id_]}) + assert not TestCRUD().read(**{'items': [id_]})['items']