Skip to content

Commit 5b95e0b

Browse files
committed
add Validate decorators.
1 parent 094d2ee commit 5b95e0b

File tree

15 files changed

+341
-338
lines changed

15 files changed

+341
-338
lines changed

.github/workflows/testing.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
3.10
2727
3.11
2828
3.12
29+
3.13
2930
- run: make test
3031
tox:
3132
runs-on: ubuntu-latest
@@ -38,6 +39,7 @@ jobs:
3839
3.10
3940
3.11
4041
3.12
42+
3.13
4143
- run: make tox
4244
build:
4345
runs-on: ubuntu-latest

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Version 4.0.0
2+
3+
* Add `Validation` decorators.
4+
15
## Version 3.0.0
26

37
* Add method `paginate()` to `ReadMixin`.

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ build: clean venv
3838

3939
install: build
4040
$(PIP) install dist/db_first-*.tar.gz
41+
$(PRE_COMMIT) install
4142

4243
upload_to_testpypi: build
4344
$(PYTHON_VENV) -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ license = {file = "LICENSE"}
2525
name = "DB-First"
2626
readme = "README.md"
2727
requires-python = ">=3.9"
28-
version = "3.0.0"
28+
version = "4.0.0"
2929

3030
[project.optional-dependencies]
3131
dev = [
3232
"build==1.3.0",
33+
"mypy",
3334
"pre-commit==4.3.0",
3435
"pytest==8.4.2",
3536
"pytest-cov==7.0.0",

src/db_first/base.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from typing import Any
22
from typing import Optional
33

4-
from sqlalchemy.engine import Result
5-
64
from .exc import MetaNotFound
75
from .exc import OptionNotFound
86

@@ -24,42 +22,3 @@ def _get_option_from_meta(cls, name: str, default: Optional[Any] = ...) -> Any:
2422
option = default
2523

2624
return option
27-
28-
@classmethod
29-
def deserialize_data(cls, schema_name: str, data: dict) -> dict:
30-
schema = cls._get_option_from_meta(schema_name)
31-
return schema().load(data)
32-
33-
@classmethod
34-
def _clean_data(cls, data: Any) -> Any:
35-
"""Clearing hierarchical structures from empty values.
36-
37-
Cleaning occurs for objects of the list and dict types, other types do not clean.
38-
39-
:param data: an object for cleaning.
40-
:return: cleaned object.
41-
"""
42-
43-
empty_values = ('', None, ..., [], {}, (), set())
44-
45-
if isinstance(data, dict):
46-
cleaned_dict = {k: cls._clean_data(v) for k, v in data.items()}
47-
return {k: v for k, v in cleaned_dict.items() if v not in empty_values}
48-
49-
elif isinstance(data, list):
50-
cleaned_list = [cls._clean_data(item) for item in data]
51-
return [item for item in cleaned_list if item not in empty_values]
52-
53-
else:
54-
return data
55-
56-
@classmethod
57-
def serialize_data(cls, schema_name: str, data: Result, fields: list = None) -> dict:
58-
output_schema = cls._get_option_from_meta(schema_name)
59-
60-
if isinstance(data, list):
61-
serialized_data = output_schema(many=True, only=fields).dump(data)
62-
else:
63-
serialized_data = output_schema(only=fields).dump(data)
64-
65-
return cls._clean_data(serialized_data)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .validation import Validation
2+
3+
__all__ = ['Validation']
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from collections.abc import Callable
2+
from collections.abc import Iterable
3+
from functools import wraps
4+
from typing import Any
5+
6+
from marshmallow.schema import SchemaMeta
7+
8+
9+
class Validation:
10+
@classmethod
11+
def input(
12+
cls, schema: SchemaMeta, deserialize: bool = True, keys: Iterable[str] | None = None
13+
) -> Callable:
14+
def decorator(func: Callable) -> Callable:
15+
@wraps(func)
16+
def wrapper(self, **data) -> Any:
17+
if deserialize:
18+
deserialized_data = schema(only=keys).load(data)
19+
return func(self, **deserialized_data)
20+
else:
21+
schema(only=keys).validate(data)
22+
return func(self, **data)
23+
24+
return wrapper
25+
26+
return decorator
27+
28+
@classmethod
29+
def output(
30+
cls, schema: SchemaMeta, serialize: bool = False, keys: Iterable[str] | None = None
31+
) -> Callable:
32+
def decorator(func: Callable) -> Callable:
33+
@wraps(func)
34+
def wrapper(self, *args, **kwargs) -> Any:
35+
obj = func(self, *args, **kwargs)
36+
37+
if serialize:
38+
serialized_data = schema(only=keys).dump(obj)
39+
return serialized_data
40+
else:
41+
schema(only=keys).validate(obj)
42+
return obj
43+
44+
return wrapper
45+
46+
return decorator

src/db_first/mixins/crud.py

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,6 @@ def create_object(self, **kwargs) -> Result:
4343
session.commit()
4444
return new_obj
4545

46-
def create(
47-
self, data: dict, deserialize: bool = False, serialize: bool = False
48-
) -> Result or dict:
49-
if deserialize:
50-
data = self.deserialize_data('input_schema_of_create', data)
51-
52-
new_object = self.create_object(**data)
53-
54-
if serialize:
55-
return self.serialize_data('output_schema_of_create', new_object)
56-
57-
return new_object
58-
5946

6047
class ReadMixin:
6148
"""Read objects from database.
@@ -95,7 +82,7 @@ def _calculate_items_per_page(
9582
self, session: Session, statement: Select, per_page: int
9683
) -> tuple[int, int]:
9784
total = session.execute(
98-
statement.with_only_columns(func.count()).order_by(None)
85+
statement.with_only_columns(func.count(self.Meta.model.id)).order_by(None)
9986
).scalar_one()
10087

10188
if per_page == 0:
@@ -115,9 +102,7 @@ def _paginate(
115102
page: int = 1,
116103
per_page: Optional[int] = 20,
117104
max_per_page: Optional[int] = 100,
118-
serialize: bool = False,
119105
include_metadata: bool = False,
120-
fields: Optional[list] = None,
121106
) -> dict:
122107
session: Session = self._get_option_from_meta('session')
123108

@@ -139,30 +124,21 @@ def _paginate(
139124
else:
140125
paginated_rows = []
141126

142-
if serialize:
143-
items['items'] = self.serialize_data('output_schema_of_read', paginated_rows, fields)
144-
else:
145-
items['items'] = paginated_rows
127+
items['items'] = paginated_rows
146128

147129
return items
148130

149-
def paginate(
131+
def base_paginate(
150132
self,
151133
page: int = 1,
152134
per_page: Optional[int] = None,
153135
max_per_page: Optional[int] = None,
154136
statement: Optional[Select] = None,
155-
deserialize: bool = False,
156-
serialize: bool = False,
157137
include_metadata: bool = False,
158-
fields: Optional[list] = None,
159138
**kwargs,
160139
) -> Result or dict:
161140
model = self._get_option_from_meta('model')
162141

163-
if deserialize:
164-
kwargs = self.deserialize_data('input_schema_of_read', kwargs)
165-
166142
filterable_fields = self._get_option_from_meta('filterable', ())
167143
interval_filterable_fields = self._get_option_from_meta('interval_filterable', ())
168144
searchable_fields = self._get_option_from_meta('searchable', ())
@@ -189,9 +165,7 @@ def paginate(
189165
page=page,
190166
per_page=per_page,
191167
max_per_page=max_per_page,
192-
serialize=serialize,
193168
include_metadata=include_metadata,
194-
fields=fields,
195169
)
196170

197171
return items
@@ -204,19 +178,6 @@ def read_object(self, id: Any) -> Result:
204178
stmt = select(model).where(model.id == id)
205179
return session.scalars(stmt).one()
206180

207-
def read(
208-
self, data: dict, deserialize: bool = False, serialize: bool = False
209-
) -> Result or dict:
210-
if deserialize:
211-
data = self.deserialize_data('input_schema_of_read', data)
212-
213-
object_ = self.read_object(**data)
214-
215-
if serialize:
216-
return self.serialize_data('output_schema_of_read', object_)
217-
218-
return object_
219-
220181

221182
class UpdateMixin:
222183
"""Update object in database.
@@ -247,19 +208,6 @@ def update_object(self, id: Any, **kwargs) -> Result:
247208
obj = session.scalars(stmt).one()
248209
return obj
249210

250-
def update(
251-
self, data: dict, deserialize: bool = False, serialize: bool = False
252-
) -> Result or dict:
253-
if deserialize:
254-
data = self.deserialize_data('input_schema_of_update', data)
255-
256-
updated_object = self.update_object(**data)
257-
258-
if serialize:
259-
return self.serialize_data('output_schema_of_update', updated_object)
260-
261-
return updated_object
262-
263211

264212
class DeleteMixin:
265213
"""Delete object from database."""
@@ -271,8 +219,3 @@ def delete_object(self, id: Any) -> None:
271219
model = self._get_option_from_meta('model')
272220

273221
session.execute(delete(model).where(model.id == id))
274-
275-
def delete(self, data: dict, deserialize: bool = False) -> None:
276-
if deserialize:
277-
data = self.deserialize_data('input_schema_of_read', data)
278-
self.delete_object(**data)

src/db_first/schemas/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .base import BaseSchema
2+
3+
__all__ = ['BaseSchema']

src/db_first/schemas/base.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from typing import Any
2+
3+
from marshmallow import post_dump
4+
from marshmallow import Schema
5+
6+
7+
class BaseSchema(Schema):
8+
__empty_values__ = ('', None, ..., [], {}, (), set())
9+
__skipped_keys__ = ()
10+
11+
@post_dump()
12+
def _delete_keys_with_empty_value(
13+
self, data, many=False
14+
) -> dict[Any, dict] | list[dict] | dict | list:
15+
"""Clearing hierarchical structures from empty values.
16+
17+
Cleaning occurs for objects of the list and dict types, other types do not clean.
18+
19+
:param data: an object for cleaning.
20+
:param many: Should be set to `True` if ``obj`` is a collection so that the object will
21+
be serialized to a list.
22+
:return: cleaned object.
23+
"""
24+
25+
if isinstance(data, dict):
26+
pre_cleaned_dict = {
27+
k: self._delete_keys_with_empty_value(v, many=many) for k, v in data.items()
28+
}
29+
30+
cleaned_dict = {}
31+
for k, v in pre_cleaned_dict.items():
32+
if k not in self.__skipped_keys__ and v in self.__empty_values__:
33+
continue
34+
else:
35+
cleaned_dict[k] = v
36+
37+
return cleaned_dict
38+
39+
elif isinstance(data, list):
40+
pre_cleaned_list = [
41+
self._delete_keys_with_empty_value(item, many=many) for item in data
42+
]
43+
return [item for item in pre_cleaned_list if item not in self.__empty_values__]
44+
45+
else:
46+
return data

0 commit comments

Comments
 (0)