Skip to content

Commit 28c07d1

Browse files
committed
Add paginate_rows() method
1 parent d349bdb commit 28c07d1

File tree

5 files changed

+137
-3
lines changed

5 files changed

+137
-3
lines changed

CHANGES.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
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+
19
Version 3.1.1
210
-------------
311

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
@@ -814,7 +815,8 @@ def paginate(
814815
815816
The statement should select a model class, like ``select(User)``. This applies
816817
``unique()`` and ``scalars()`` modifiers to the result, so compound selects will
817-
not return the expected results.
818+
not return the expected results. To paginate a compound select, use
819+
:meth:`paginate_rows` instead.
818820
819821
:param select: The ``select`` statement to paginate.
820822
:param page: The current page, used to calculate the offset. Defaults to the
@@ -846,6 +848,54 @@ def paginate(
846848
count=count,
847849
)
848850

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