Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jobs:
3.11
3.12
3.13
3.14
pypy3.11
- run: make format
test:
runs-on: ubuntu-latest
Expand All @@ -27,6 +29,8 @@ jobs:
3.11
3.12
3.13
3.14
pypy3.11
- run: make test
tox:
runs-on: ubuntu-latest
Expand All @@ -40,6 +44,8 @@ jobs:
3.11
3.12
3.13
3.14
pypy3.11
- run: make tox
build:
runs-on: ubuntu-latest
Expand Down
10 changes: 7 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.


Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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"
]

Expand Down Expand Up @@ -78,11 +78,13 @@ where = ["src"]
legacy_tox_ini = """
[tox]
env_list =
py314
py313
py312
py311
py310
py39
pypy311
[testenv]
allowlist_externals = *
commands =
Expand Down
4 changes: 2 additions & 2 deletions src/db_first/decorators/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions src/db_first/mixins/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand All @@ -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)))
79 changes: 79 additions & 0 deletions tests/test_crud_mixin__bulk.py
Original file line number Diff line number Diff line change
@@ -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']