Skip to content

Commit 0b5873d

Browse files
committed
Add paginate_rows() method
1 parent 3e3e92b commit 0b5873d

File tree

5 files changed

+138
-3
lines changed

5 files changed

+138
-3
lines changed

CHANGES.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
Version 3.2.0
2+
-------------
3+
4+
Unreleased
5+
6+
- Added ``paginate_rows`` method to the extension object for paginating over
7+
``Row`` objects :issue:`1168`:
8+
9+
110
Version 3.1.2
211
-------------
312

docs/api.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ Pagination
6565
based on the current page and number of items per page.
6666

6767
Don't create pagination objects manually. They are created by
68-
:meth:`.SQLAlchemy.paginate` and :meth:`.Query.paginate`.
68+
:meth:`.SQLAlchemy.paginate`, :meth:`.SQLAlchemy.paginate_rows`, and
69+
:meth:`.Query.paginate`.
6970

7071
.. versionchanged:: 3.0
7172
Iterating over a pagination object iterates over its items.

src/flask_sqlalchemy/extension.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .model import Model
2323
from .model import NameMixin
2424
from .pagination import Pagination
25+
from .pagination import RowPagination
2526
from .pagination import SelectPagination
2627
from .query import Query
2728
from .session import _app_ctx_id
@@ -816,7 +817,8 @@ def paginate(
816817
817818
The statement should select a model class, like ``select(User)``. This applies
818819
``unique()`` and ``scalars()`` modifiers to the result, so compound selects will
819-
not return the expected results.
820+
not return the expected results. To paginate a compound select, use
821+
:meth:`paginate_rows` instead.
820822
821823
:param select: The ``select`` statement to paginate.
822824
:param page: The current page, used to calculate the offset. Defaults to the
@@ -848,6 +850,54 @@ def paginate(
848850
count=count,
849851
)
850852

853+
def paginate_rows(
854+
self,
855+
select: sa.sql.Select[t.Any],
856+
*,
857+
page: int | None = None,
858+
per_page: int | None = None,
859+
max_per_page: int | None = None,
860+
error_out: bool = True,
861+
count: bool = True,
862+
) -> Pagination:
863+
"""Apply an offset and limit to a select statment based on the current page and
864+
number of items per page, returning a :class:`.Pagination` object.
865+
866+
Unlike :meth:`paginate`, the statement may select any number of
867+
columns, like ``select(User.name, User.password)``. Regardless of how
868+
many columns are selected, the :attr:`.Pagination.items` attribute of
869+
the returned :class:`.Pagination` instance will contain :class:`Row
870+
<sqlalchemy.engine.Row>` objects.
871+
872+
Note that the ``unique()`` modifier is applied to the result.
873+
874+
:param select: The ``select`` statement to paginate.
875+
:param page: The current page, used to calculate the offset. Defaults to the
876+
``page`` query arg during a request, or 1 otherwise.
877+
:param per_page: The maximum number of items on a page, used to calculate the
878+
offset and limit. Defaults to the ``per_page`` query arg during a request,
879+
or 20 otherwise.
880+
:param max_per_page: The maximum allowed value for ``per_page``, to limit a
881+
user-provided value. Use ``None`` for no limit. Defaults to 100.
882+
:param error_out: Abort with a ``404 Not Found`` error if no items are returned
883+
and ``page`` is not 1, or if ``page`` or ``per_page`` is less than 1, or if
884+
either are not ints.
885+
:param count: Calculate the total number of values by issuing an extra count
886+
query. For very complex queries this may be inaccurate or slow, so it can be
887+
disabled and set manually if necessary.
888+
889+
.. versionadded:: 3.2
890+
"""
891+
return RowPagination(
892+
select=select,
893+
session=self.session(),
894+
page=page,
895+
per_page=per_page,
896+
max_per_page=max_per_page,
897+
error_out=error_out,
898+
count=count,
899+
)
900+
851901
def _call_for_binds(
852902
self, bind_key: str | None | list[str | None], op_name: str
853903
) -> None:

src/flask_sqlalchemy/pagination.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class Pagination:
1414
items per page.
1515
1616
Don't create pagination objects manually. They are created by
17-
:meth:`.SQLAlchemy.paginate` and :meth:`.Query.paginate`.
17+
:meth:`.SQLAlchemy.paginate`, :meth:`.SQLAlchemy.paginate_rows`, and
18+
:meth:`.Query.paginate`.
1819
1920
This is a base class, a subclass must implement :meth:`_query_items` and
2021
:meth:`_query_count`. Those methods will use arguments passed as ``kwargs`` to
@@ -346,6 +347,28 @@ def _query_count(self) -> int:
346347
return out # type: ignore[no-any-return]
347348

348349

350+
class RowPagination(Pagination):
351+
"""Returned by :meth:`.SQLAlchemy.paginate_rows`. Takes ``select`` and ``session``
352+
arguments in addition to the :class:`Pagination` arguments.
353+
354+
.. versionadded:: 3.2
355+
"""
356+
357+
def _query_items(self) -> list[t.Any]:
358+
# Like SelectPagination._query_items(), but without the `.scalars()`
359+
select = self._query_args["select"]
360+
select = select.limit(self.per_page).offset(self._query_offset)
361+
session = self._query_args["session"]
362+
return list(session.execute(select).unique())
363+
364+
def _query_count(self) -> int:
365+
select = self._query_args["select"]
366+
sub = select.options(sa_orm.lazyload("*")).order_by(None).subquery()
367+
session = self._query_args["session"]
368+
out = session.execute(sa.select(sa.func.count()).select_from(sub)).scalar()
369+
return out # type: ignore[no-any-return]
370+
371+
349372
class QueryPagination(Pagination):
350373
"""Returned by :meth:`.Query.paginate`. Takes a ``query`` argument in addition to
351374
the :class:`Pagination` arguments.

tests/test_pagination.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import typing as t
44

55
import pytest
6+
import sqlalchemy as sa
67
from flask import Flask
78
from werkzeug.exceptions import NotFound
89

@@ -158,6 +159,8 @@ def test_paginate(paginate: _PaginateCallable) -> None:
158159
assert p.page == 1
159160
assert p.per_page == 20
160161
assert len(p.items) == 20
162+
for it in p.items:
163+
assert isinstance(it, paginate.Todo)
161164
assert p.total == 250
162165
assert p.pages == 13
163166

@@ -203,3 +206,52 @@ def test_no_items_404(db: SQLAlchemy, Todo: t.Any) -> None:
203206

204207
with pytest.raises(NotFound):
205208
db.paginate(db.select(Todo), page=2)
209+
210+
211+
class _RowPaginateCallable:
212+
def __init__(self, app: Flask, db: SQLAlchemy, Todo: t.Any) -> None:
213+
self.app = app
214+
self.db = db
215+
self.Todo = Todo
216+
217+
def __call__(
218+
self,
219+
page: int | None = None,
220+
per_page: int | None = None,
221+
max_per_page: int | None = None,
222+
error_out: bool = True,
223+
count: bool = True,
224+
) -> Pagination:
225+
qs = {"page": page, "per_page": per_page}
226+
with self.app.test_request_context(query_string=qs):
227+
return self.db.paginate_rows(
228+
self.db.select(self.Todo.id, self.Todo.title),
229+
max_per_page=max_per_page,
230+
error_out=error_out,
231+
count=count,
232+
)
233+
234+
235+
@pytest.fixture
236+
def paginate_rows(app: Flask, db: SQLAlchemy, Todo: t.Any) -> _RowPaginateCallable:
237+
with app.app_context():
238+
for i in range(1, 251):
239+
db.session.add(Todo(title=f"task {i}"))
240+
241+
db.session.commit()
242+
243+
return _RowPaginateCallable(app, db, Todo)
244+
245+
246+
def test_paginate_rows(paginate_rows: _RowPaginateCallable) -> None:
247+
p = paginate_rows()
248+
assert p.page == 1
249+
assert p.per_page == 20
250+
assert len(p.items) == 20
251+
for it in p.items:
252+
assert isinstance(it, sa.Row)
253+
assert len(it) == 2
254+
assert isinstance(it[0], int)
255+
assert isinstance(it[1], str)
256+
assert p.total == 250
257+
assert p.pages == 13

0 commit comments

Comments
 (0)