From 478839456546abab3aee8d22f9cc60b32ab752bd Mon Sep 17 00:00:00 2001 From: collerek Date: Fri, 29 Jan 2021 14:24:53 +0100 Subject: [PATCH] add pagination method, add tests, update api docs, bump version, add release info --- docs/api/fields/foreign-key.md | 2 +- docs/api/fields/many-to-many.md | 2 +- docs/api/query-set/query-set.md | 19 +++++ docs/api/relations/queryset-proxy.md | 21 +++++ docs/releases.md | 9 ++- ormar/__init__.py | 2 +- ormar/queryset/queryset.py | 31 ++++++++ ormar/relations/querysetproxy.py | 17 +++++ tests/test_pagination.py | 110 +++++++++++++++++++++++++++ 9 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 tests/test_pagination.py diff --git a/docs/api/fields/foreign-key.md b/docs/api/fields/foreign-key.md index 3f74f1c..019e2c9 100644 --- a/docs/api/fields/foreign-key.md +++ b/docs/api/fields/foreign-key.md @@ -94,7 +94,7 @@ to produce sqlalchemy.ForeignKeys #### ForeignKey ```python -ForeignKey(to: Union[Type["Model"], "ForwardRef"], *, name: str = None, unique: bool = False, nullable: bool = True, related_name: str = None, virtual: bool = False, onupdate: str = None, ondelete: str = None, **kwargs: Any, ,) -> Any +ForeignKey(to: "ToType", *, name: str = None, unique: bool = False, nullable: bool = True, related_name: str = None, virtual: bool = False, onupdate: str = None, ondelete: str = None, **kwargs: Any, ,) -> Any ``` Despite a name it's a function that returns constructed ForeignKeyField. diff --git a/docs/api/fields/many-to-many.md b/docs/api/fields/many-to-many.md index 0b12763..72c95e1 100644 --- a/docs/api/fields/many-to-many.md +++ b/docs/api/fields/many-to-many.md @@ -24,7 +24,7 @@ pydantic field to use and type of the target column field. #### ManyToMany ```python -ManyToMany(to: Union[Type["Model"], ForwardRef], through: Union[Type["Model"], ForwardRef], *, name: str = None, unique: bool = False, virtual: bool = False, **kwargs: Any, ,) -> Any +ManyToMany(to: "ToType", through: "ToType", *, name: str = None, unique: bool = False, virtual: bool = False, **kwargs: Any, ,) -> Any ``` Despite a name it's a function that returns constructed ManyToManyField. diff --git a/docs/api/query-set/query-set.md b/docs/api/query-set/query-set.md index 0821a66..4c2ad6a 100644 --- a/docs/api/query-set/query-set.md +++ b/docs/api/query-set/query-set.md @@ -444,6 +444,25 @@ each=True flag to affect whole table. `(int)`: number of deleted rows + +#### paginate + +```python + | paginate(page: int, page_size: int = 20) -> "QuerySet" +``` + +You can paginate the result which is a combination of offset and limit clauses. +Limit is set to page size and offset is set to (page-1) * page_size. + +**Arguments**: + +- `page_size (int)`: numbers of items per page +- `page (int)`: page number + +**Returns**: + +`(QuerySet)`: QuerySet + #### limit diff --git a/docs/api/relations/queryset-proxy.md b/docs/api/relations/queryset-proxy.md index 6df774d..627e995 100644 --- a/docs/api/relations/queryset-proxy.md +++ b/docs/api/relations/queryset-proxy.md @@ -416,6 +416,27 @@ Actual call delegated to QuerySet. `(QuerysetProxy)`: QuerysetProxy + +#### paginate + +```python + | paginate(page: int, page_size: int = 20) -> "QuerysetProxy" +``` + +You can paginate the result which is a combination of offset and limit clauses. +Limit is set to page size and offset is set to (page-1) * page_size. + +Actual call delegated to QuerySet. + +**Arguments**: + +- `page_size (int)`: numbers of items per page +- `page (int)`: page number + +**Returns**: + +`(QuerySet)`: QuerySet + #### limit diff --git a/docs/releases.md b/docs/releases.md index 3521ac9..4fb6fa5 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,7 @@ # 0.8.1 +## Features + * Introduce processing of `ForwardRef` in relations. Now you can create self-referencing models - both `ForeignKey` and `ManyToMany` relations. `ForwardRef` can be used both for `to` and `through` `Models`. @@ -13,8 +15,13 @@ unless two different relation were used (two relation fields with different names) ``` +* Introduce the `paginate` method that allows to limit/offset by `page` and `page_size`. + Available for `QuerySet` and `QuerysetProxy`. + +## Other + * Refactoring and performance optimization in queries and joins. -* Update API docs and docs. +* Update API docs and docs -> i.e. split of queries documentation. # 0.8.0 diff --git a/ormar/__init__.py b/ormar/__init__.py index 355b862..40da69b 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -65,7 +65,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.8.0" +__version__ = "0.8.1" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index 2094be8..8a03866 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -592,6 +592,37 @@ class QuerySet: ) return await self.database.execute(expr) + def paginate(self, page: int, page_size: int = 20) -> "QuerySet": + """ + You can paginate the result which is a combination of offset and limit clauses. + Limit is set to page size and offset is set to (page-1) * page_size. + + :param page_size: numbers of items per page + :type page_size: int + :param page: page number + :type page: int + :return: QuerySet + :rtype: QuerySet + """ + if page < 1 or page_size < 1: + raise QueryDefinitionError("Page size and page have to be greater than 0.") + + limit_count = page_size + query_offset = (page - 1) * page_size + return self.__class__( + model_cls=self.model, + filter_clauses=self.filter_clauses, + exclude_clauses=self.exclude_clauses, + select_related=self._select_related, + limit_count=limit_count, + offset=query_offset, + columns=self._columns, + exclude_columns=self._exclude_columns, + order_bys=self.order_bys, + prefetch_related=self._prefetch_related, + limit_raw_sql=self.limit_sql_raw, + ) + def limit(self, limit_count: int, limit_raw_sql: bool = None) -> "QuerySet": """ You can limit the results to desired number of parent models. diff --git a/ormar/relations/querysetproxy.py b/ormar/relations/querysetproxy.py index 0740a17..360e863 100644 --- a/ormar/relations/querysetproxy.py +++ b/ormar/relations/querysetproxy.py @@ -415,6 +415,23 @@ class QuerysetProxy(ormar.QuerySetProtocol): queryset = self.queryset.prefetch_related(related) return self.__class__(relation=self.relation, type_=self.type_, qryset=queryset) + def paginate(self, page: int, page_size: int = 20) -> "QuerysetProxy": + """ + You can paginate the result which is a combination of offset and limit clauses. + Limit is set to page size and offset is set to (page-1) * page_size. + + Actual call delegated to QuerySet. + + :param page_size: numbers of items per page + :type page_size: int + :param page: page number + :type page: int + :return: QuerySet + :rtype: QuerySet + """ + queryset = self.queryset.paginate(page=page, page_size=page_size) + return self.__class__(relation=self.relation, type_=self.type_, qryset=queryset) + def limit(self, limit_count: int) -> "QuerysetProxy": """ You can limit the results to desired number of parent models. diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..d49e5bf --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,110 @@ +import databases +import pytest +import sqlalchemy + +import ormar +from ormar import ModelMeta +from ormar.exceptions import QueryDefinitionError +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ModelMeta): + metadata = metadata + database = database + + +class Car(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + + +class UsersCar(ormar.Model): + class Meta(BaseMeta): + tablename = "cars_x_users" + + +class User(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + cars = ormar.ManyToMany(Car, through=UsersCar) + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +@pytest.mark.asyncio +async def test_pagination_errors(): + async with database: + async with database.transaction(force_rollback=True): + with pytest.raises(QueryDefinitionError): + await Car.objects.paginate(0).all() + + with pytest.raises(QueryDefinitionError): + await Car.objects.paginate(1, page_size=0).all() + + +@pytest.mark.asyncio +async def test_pagination_on_single_model(): + async with database: + async with database.transaction(force_rollback=True): + for i in range(20): + await Car(name=f"{i}").save() + + cars_page1 = await Car.objects.paginate(1, page_size=5).all() + assert len(cars_page1) == 5 + assert cars_page1[0].name == "0" + assert cars_page1[4].name == "4" + cars_page2 = await Car.objects.paginate(2, page_size=5).all() + assert len(cars_page2) == 5 + assert cars_page2[0].name == "5" + assert cars_page2[4].name == "9" + + all_cars = await Car.objects.paginate(1).all() + assert len(all_cars) == 20 + + half_cars = await Car.objects.paginate(2, page_size=10).all() + assert len(half_cars) == 10 + assert half_cars[0].name == "10" + + +@pytest.mark.asyncio +async def test_proxy_pagination(): + async with database: + async with database.transaction(force_rollback=True): + user = await User(name="Jon").save() + + for i in range(20): + c = await Car(name=f"{i}").save() + await user.cars.add(c) + + await user.cars.paginate(1, page_size=5).all() + assert len(user.cars) == 5 + assert user.cars[0].name == "0" + assert user.cars[4].name == "4" + + await user.cars.paginate(2, page_size=5).all() + assert len(user.cars) == 5 + assert user.cars[0].name == "5" + assert user.cars[4].name == "9" + + await user.cars.paginate(1).all() + assert len(user.cars) == 20 + + await user.cars.paginate(2, page_size=10).all() + assert len(user.cars) == 10 + assert user.cars[0].name == "10"