Skip to content

Commit b3515d7

Browse files
committed
Feat: Added Pagination and some view query utilities
1 parent 8e20ce5 commit b3515d7

File tree

22 files changed

+1918
-16
lines changed

22 files changed

+1918
-16
lines changed

ellar_sqlalchemy/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
__version__ = "0.0.1"
44

55
from .module import EllarSQLAlchemyModule
6+
from .pagination import LimitOffsetPagination, PageNumberPagination, paginate
7+
from .query import (
8+
first_or_404,
9+
first_or_404_async,
10+
get_or_404,
11+
get_or_404_async,
12+
one_or_404,
13+
one_or_404_async,
14+
)
615
from .schemas import MigrationOption, SQLAlchemyConfig
716
from .services import EllarSQLAlchemyService
817

@@ -11,4 +20,13 @@
1120
"EllarSQLAlchemyService",
1221
"SQLAlchemyConfig",
1322
"MigrationOption",
23+
"get_or_404_async",
24+
"get_or_404",
25+
"first_or_404_async",
26+
"first_or_404",
27+
"one_or_404_async",
28+
"one_or_404",
29+
"paginate",
30+
"PageNumberPagination",
31+
"LimitOffsetPagination",
1432
]

ellar_sqlalchemy/constant.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
DATABASE_KEY = "__database__"
66
TABLE_KEY = "__table__"
77
ABSTRACT_KEY = "__abstract__"
8-
8+
PAGINATION_OPTIONS = "__PAGINATION_OPTIONS__"
99
NAMING_CONVERSION = {
1010
"ix": "ix_%(column_0_label)s",
1111
"uq": "uq_%(table_name)s_%(column_0_name)s",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .base import Paginator, PaginatorBase
2+
from .decorator import paginate
3+
from .view import LimitOffsetPagination, PageNumberPagination
4+
5+
__all__ = [
6+
"Paginator",
7+
"PaginatorBase",
8+
"paginate",
9+
"PageNumberPagination",
10+
"LimitOffsetPagination",
11+
]
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import typing as t
2+
from abc import abstractmethod
3+
from math import ceil
4+
5+
import ellar.common as ecm
6+
import sqlalchemy as sa
7+
import sqlalchemy.orm as sa_orm
8+
from ellar.app import current_injector
9+
from ellar.threading import execute_coroutine_with_sync_worker
10+
from sqlalchemy.ext.asyncio import AsyncSession
11+
12+
from ellar_sqlalchemy.model.base import ModelBase
13+
from ellar_sqlalchemy.services import EllarSQLAlchemyService
14+
15+
16+
class PaginatorBase:
17+
def __init__(
18+
self,
19+
page: int = 1,
20+
per_page: int = 20,
21+
max_per_page: t.Optional[int] = 100,
22+
error_out: bool = True,
23+
count: bool = True,
24+
) -> None:
25+
page, per_page = self._prepare_page_args(
26+
page=page,
27+
per_page=per_page,
28+
max_per_page=max_per_page,
29+
error_out=error_out,
30+
)
31+
32+
self.page: int = page
33+
"""The current page."""
34+
35+
self.per_page: int = per_page
36+
"""The maximum number of items on a page."""
37+
38+
self.max_per_page: t.Optional[int] = max_per_page
39+
"""The maximum allowed value for ``per_page``."""
40+
41+
items = self._query_items()
42+
43+
if not items and page != 1 and error_out:
44+
raise ecm.NotFound()
45+
46+
self.items: t.List[t.Any] = items
47+
"""The items on the current page. Iterating over the pagination object is
48+
equivalent to iterating over the items.
49+
"""
50+
51+
if count:
52+
total = self._query_count()
53+
else:
54+
total = None
55+
56+
self.total: t.Optional[int] = total
57+
"""The total number of items across all pages."""
58+
59+
def _prepare_page_args(
60+
self,
61+
*,
62+
page: int,
63+
per_page: int,
64+
max_per_page: t.Optional[int],
65+
error_out: bool,
66+
) -> t.Tuple[int, int]:
67+
if max_per_page is not None:
68+
per_page = min(per_page, max_per_page)
69+
70+
if page < 1:
71+
if error_out:
72+
raise ecm.NotFound()
73+
else:
74+
page = 1
75+
76+
if per_page < 1:
77+
if error_out:
78+
raise ecm.NotFound()
79+
else:
80+
per_page = 20
81+
82+
return page, per_page
83+
84+
@property
85+
def _query_offset(self) -> int:
86+
""" """
87+
return (self.page - 1) * self.per_page
88+
89+
@abstractmethod
90+
def _query_items(self) -> t.List[t.Any]:
91+
"""Execute the query to get the items on the current page."""
92+
93+
@abstractmethod
94+
def _get_init_kwargs(self) -> t.Dict[str, t.Any]:
95+
"""Returns dictionary of other attributes a child class requires for initialization"""
96+
97+
@abstractmethod
98+
def _query_count(self) -> int:
99+
"""Execute the query to get the total number of items."""
100+
101+
@property
102+
def first(self) -> int:
103+
"""The number of the first item on the page, starting from 1, or 0 if there are
104+
no items.
105+
"""
106+
if len(self.items) == 0:
107+
return 0
108+
109+
return (self.page - 1) * self.per_page + 1
110+
111+
@property
112+
def last(self) -> int:
113+
"""The number of the last item on the page, starting from 1, inclusive, or 0 if
114+
there are no items.
115+
"""
116+
first = self.first
117+
return max(first, first + len(self.items) - 1)
118+
119+
@property
120+
def pages(self) -> int:
121+
"""The total number of pages."""
122+
if self.total == 0 or self.total is None:
123+
return 0
124+
125+
return ceil(self.total / self.per_page)
126+
127+
@property
128+
def has_prev(self) -> bool:
129+
"""``True`` if this is not the first page."""
130+
return self.page > 1
131+
132+
@property
133+
def prev_num(self) -> t.Optional[int]:
134+
"""The previous page number, or ``None`` if this is the first page."""
135+
if not self.has_prev:
136+
return None
137+
138+
return self.page - 1
139+
140+
def prev(self, *, error_out: bool = False) -> "PaginatorBase":
141+
"""Query the pagination object for the previous page.
142+
143+
:param error_out: Abort with a ``404 Not Found`` error if no items are returned
144+
and ``page`` is not 1, or if ``page`` or ``per_page`` is less than 1, or if
145+
either are not ints.
146+
"""
147+
init_kwargs = self._get_init_kwargs()
148+
init_kwargs.update(
149+
page=self.page - 1,
150+
per_page=self.per_page,
151+
error_out=error_out,
152+
count=False,
153+
)
154+
p = self.__class__(**init_kwargs)
155+
p.total = self.total
156+
return p
157+
158+
@property
159+
def has_next(self) -> bool:
160+
"""``True`` if this is not the last page."""
161+
return self.page < self.pages
162+
163+
@property
164+
def next_num(self) -> t.Optional[int]:
165+
"""The next page number, or ``None`` if this is the last page."""
166+
if not self.has_next:
167+
return None
168+
169+
return self.page + 1
170+
171+
def next(self, *, error_out: bool = False) -> "PaginatorBase":
172+
""" """
173+
init_kwargs = self._get_init_kwargs()
174+
init_kwargs.update(
175+
page=self.page + 1,
176+
per_page=self.per_page,
177+
max_per_page=self.max_per_page,
178+
error_out=error_out,
179+
count=False,
180+
)
181+
p = self.__class__(**init_kwargs)
182+
p.total = self.total
183+
return p
184+
185+
def iter_pages(
186+
self,
187+
*,
188+
left_edge: int = 2,
189+
left_current: int = 2,
190+
right_current: int = 4,
191+
right_edge: int = 2,
192+
) -> t.Iterator[t.Optional[int]]:
193+
"""Yield page numbers for a pagination widget. Skipped pages between the edges
194+
and middle are represented by a ``None``.
195+
196+
For example, if there are 20 pages and the current page is 7, the following
197+
values are yielded.
198+
199+
.. code-block:: python
200+
201+
1, 2, None, 5, 6, 7, 8, 9, 10, 11, None, 19, 20
202+
203+
:param left_edge: How many pages to show from the first page.
204+
:param left_current: How many pages to show left of the current page.
205+
:param right_current: How many pages to show right of the current page.
206+
:param right_edge: How many pages to show from the last page.
207+
208+
"""
209+
pages_end = self.pages + 1
210+
211+
if pages_end == 1:
212+
return
213+
214+
left_end = min(1 + left_edge, pages_end)
215+
yield from range(1, left_end)
216+
217+
if left_end == pages_end:
218+
return
219+
220+
mid_start = max(left_end, self.page - left_current)
221+
mid_end = min(self.page + right_current + 1, pages_end)
222+
223+
if mid_start - left_end > 0:
224+
yield None
225+
226+
yield from range(mid_start, mid_end)
227+
228+
if mid_end == pages_end:
229+
return
230+
231+
right_start = max(mid_end, pages_end - right_edge)
232+
233+
if right_start - mid_end > 0:
234+
yield None
235+
236+
yield from range(right_start, pages_end)
237+
238+
def __iter__(self) -> t.Iterator[t.Any]:
239+
yield from self.items
240+
241+
242+
class Paginator(PaginatorBase):
243+
def __init__(
244+
self,
245+
model: t.Union[t.Type[ModelBase], sa.sql.Select[t.Any]],
246+
session: t.Optional[t.Union[sa_orm.Session, AsyncSession]] = None,
247+
page: int = 1,
248+
per_page: int = 20,
249+
max_per_page: t.Optional[int] = 100,
250+
error_out: bool = True,
251+
count: bool = True,
252+
) -> None:
253+
if isinstance(model, type) and issubclass(model, ModelBase):
254+
self._select = sa.select(model)
255+
else:
256+
self._select = t.cast(sa.sql.Select, model)
257+
258+
self._created_session = False
259+
260+
self._session: t.Union[sa_orm.Session, AsyncSession] = (
261+
session or self._get_session()
262+
)
263+
self._is_async = self._session.get_bind().dialect.is_async
264+
265+
super().__init__(
266+
page=page,
267+
per_page=per_page,
268+
max_per_page=max_per_page,
269+
error_out=error_out,
270+
count=count,
271+
)
272+
273+
if self._created_session:
274+
self._session.close() # session usage is done but only if Paginator created the session
275+
276+
def _get_session(self) -> t.Union[sa_orm.Session, AsyncSession, t.Any]:
277+
self._created_session = True
278+
service = current_injector.get(EllarSQLAlchemyService)
279+
return service.get_scoped_session()()
280+
281+
def _query_items(self) -> t.List[t.Any]:
282+
if self._is_async:
283+
res = execute_coroutine_with_sync_worker(self._query_items_async())
284+
return list(res)
285+
return self._query_items_sync()
286+
287+
def _query_items_sync(self) -> t.List[t.Any]:
288+
select = self._select.limit(self.per_page).offset(self._query_offset)
289+
return list(self._session.execute(select).unique().scalars())
290+
291+
async def _query_items_async(self) -> t.List[t.Any]:
292+
session = t.cast(AsyncSession, self._session)
293+
294+
select = self._select.limit(self.per_page).offset(self._query_offset)
295+
res = await session.execute(select)
296+
297+
return list(res.unique().scalars())
298+
299+
def _query_count(self) -> int:
300+
if self._is_async:
301+
res = execute_coroutine_with_sync_worker(self._query_count_async())
302+
return int(res)
303+
return self._query_count_sync()
304+
305+
def _query_count_sync(self) -> int:
306+
sub = self._select.options(sa_orm.lazyload("*")).order_by(None).subquery()
307+
out = self._session.execute(
308+
sa.select(sa.func.count()).select_from(sub)
309+
).scalar()
310+
return out # type:ignore[return-value]
311+
312+
async def _query_count_async(self) -> int:
313+
session = t.cast(AsyncSession, self._session)
314+
315+
sub = self._select.options(sa_orm.lazyload("*")).order_by(None).subquery()
316+
317+
out = await session.execute(sa.select(sa.func.count()).select_from(sub))
318+
return out.scalar() # type:ignore[return-value]
319+
320+
def _get_init_kwargs(self) -> t.Dict[str, t.Any]:
321+
return {"model": self._select}

0 commit comments

Comments
 (0)