Skip to main content

umbral_core/
pagination.rs

1//! Template-rendered list-view pagination - a `Paginator`/`Page` pair.
2//!
3//! This is the page-of-rows helper for **server-rendered (Jinja) list
4//! views**, distinct from REST's JSON pagination. It lives in `umbral-core`
5//! as an ORM-adjacent core utility so any handler can paginate a [`QuerySet`]
6//! without registering a plugin:
7//!
8//! ```rust,ignore
9//! let paginator = Paginator::new(Post::objects().order_by(post::ID.asc()), 10);
10//! let page = paginator.page(n).await?;          // strict: errors out of range
11//! // or paginator.page_clamped(n).await — forgiving: clamps to [1, num_pages]
12//! render("posts/list.html", context! { page => page.context(), base_query })
13//! ```
14//!
15//! The [`Paginator`] holds the queryset and counts once + slices per page
16//! via the queryset's *by-value* `limit`/`offset`/`count`/`fetch`. Because
17//! `QuerySet<T>: Clone`, each terminal operates on a fresh clone, so the
18//! paginator never consumes the queryset and can serve many pages.
19//!
20//! For the nav markup, [`Page::elided_page_range`] produces the windowed
21//! `1 … 4 5 [6] 7 8 … 20` shape (mirroring the admin's prior-art elision),
22//! and [`Page::context`] yields a [`PageContext`] that serializes straight
23//! into a template so `{% include "_pagination.html" %}` renders the nav
24//! from `{{ page }}`.
25
26use std::fmt;
27
28use serde::Serialize;
29
30use crate::orm::queryset::QuerySet;
31use crate::orm::{HydrateRelated, Model};
32
33/// Error raised when a requested page number is invalid for the paginator.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum PaginationError {
36    /// The page number is `< 1` or `> num_pages`.
37    InvalidPage {
38        /// The page number that was requested.
39        requested: i64,
40        /// The highest valid page number for this paginator.
41        num_pages: i64,
42    },
43    /// A database error occurred while counting or fetching a slice.
44    Db(String),
45}
46
47/// Umbrella alias so call-sites and docs can refer to a `PageError`.
48pub type PageError = PaginationError;
49
50impl fmt::Display for PaginationError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            PaginationError::InvalidPage {
54                requested,
55                num_pages,
56            } => write!(
57                f,
58                "invalid page number {requested}: valid pages are 1..={num_pages}"
59            ),
60            PaginationError::Db(msg) => write!(f, "pagination query failed: {msg}"),
61        }
62    }
63}
64
65impl std::error::Error for PaginationError {}
66
67impl From<sqlx::Error> for PaginationError {
68    fn from(e: sqlx::Error) -> Self {
69        PaginationError::Db(e.to_string())
70    }
71}
72
73/// Paginates a [`QuerySet`] into fixed-size pages.
74///
75/// Holds the queryset by value and clones it per terminal, so a single
76/// paginator counts once and serves any number of [`Page`]s without
77/// consuming the underlying query. `per_page` is clamped to `>= 1`.
78#[derive(Debug, Clone)]
79pub struct Paginator<T> {
80    queryset: QuerySet<T>,
81    per_page: usize,
82}
83
84impl<T> Paginator<T>
85where
86    T: Model
87        + Clone
88        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
89        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
90        + HydrateRelated,
91{
92    /// Build a paginator over `queryset`, `per_page` rows per page.
93    ///
94    /// `per_page` is clamped to a minimum of 1: we take the forgiving route
95    /// and treat 0 as 1 so a misconfigured page size never divides by zero.
96    pub fn new(queryset: QuerySet<T>, per_page: usize) -> Self {
97        Self {
98            queryset,
99            per_page: per_page.max(1),
100        }
101    }
102
103    /// Rows per page (always `>= 1`).
104    pub fn per_page(&self) -> usize {
105        self.per_page
106    }
107
108    /// Total number of rows across every page.
109    pub async fn count(&self) -> Result<i64, PaginationError> {
110        Ok(self.queryset.clone().count().await?)
111    }
112
113    /// Number of pages. Always `>= 1` (an empty queryset still has one
114    /// empty page).
115    pub async fn num_pages(&self) -> Result<i64, PaginationError> {
116        let count = self.count().await?;
117        Ok(num_pages_for(count, self.per_page))
118    }
119
120    /// Fetch the slice for `number`, **erroring** on an out-of-range page
121    /// (the strict page accessor). Page numbers are 1-based.
122    pub async fn page(&self, number: i64) -> Result<Page<T>, PaginationError> {
123        let count = self.count().await?;
124        let num_pages = num_pages_for(count, self.per_page);
125        if number < 1 || number > num_pages {
126            return Err(PaginationError::InvalidPage {
127                requested: number,
128                num_pages,
129            });
130        }
131        self.build_page(number, count, num_pages).await
132    }
133
134    /// Fetch the slice for `number`, **clamping** out-of-range requests into
135    /// `[1, num_pages]` (the forgiving variant — handy when `?page=N` comes
136    /// straight from an untrusted querystring).
137    pub async fn page_clamped(&self, number: i64) -> Result<Page<T>, PaginationError> {
138        let count = self.count().await?;
139        let num_pages = num_pages_for(count, self.per_page);
140        let number = number.clamp(1, num_pages);
141        self.build_page(number, count, num_pages).await
142    }
143
144    /// Shared slice fetch for [`Self::page`]/[`Self::page_clamped`]. `number`
145    /// is assumed already validated/clamped into `[1, num_pages]`.
146    async fn build_page(
147        &self,
148        number: i64,
149        count: i64,
150        num_pages: i64,
151    ) -> Result<Page<T>, PaginationError> {
152        let per_page = self.per_page as u64;
153        let offset = (number - 1) as u64 * per_page;
154        let object_list = self
155            .queryset
156            .clone()
157            .limit(per_page)
158            .offset(offset)
159            .fetch()
160            .await?;
161        Ok(Page {
162            object_list,
163            number,
164            per_page: self.per_page,
165            total_count: count,
166            num_pages,
167        })
168    }
169}
170
171/// `ceil(count / per_page)`, clamped to a minimum of 1 even for an empty
172/// queryset (an empty paginator reports `num_pages == 1`).
173fn num_pages_for(count: i64, per_page: usize) -> i64 {
174    if count <= 0 {
175        return 1;
176    }
177    let per_page = per_page.max(1) as i64;
178    // Manual ceil: `i64::div_ceil` is unstable on this toolchain. `count` and
179    // `per_page` are both `> 0` here, so `(count + per_page - 1) / per_page`
180    // is the standard non-overflowing ceil for positive operands.
181    (count + per_page - 1) / per_page
182}
183
184/// A single page of paginated rows.
185///
186/// All the page-relative helpers (`has_next`, `start_index`, …) follow
187/// conventional page semantics. [`Page::context`] derives the
188/// serializable [`PageContext`] a template renders the nav from.
189#[derive(Debug, Clone)]
190pub struct Page<T> {
191    /// The rows on this page.
192    pub object_list: Vec<T>,
193    /// This page's 1-based number.
194    pub number: i64,
195    /// Rows per page (the paginator's clamped `per_page`).
196    pub per_page: usize,
197    /// Total rows across all pages.
198    pub total_count: i64,
199    /// Total page count (`>= 1`).
200    pub num_pages: i64,
201}
202
203impl<T> Page<T> {
204    /// Is there a page after this one?
205    pub fn has_next(&self) -> bool {
206        self.number < self.num_pages
207    }
208
209    /// Is there a page before this one?
210    pub fn has_previous(&self) -> bool {
211        self.number > 1
212    }
213
214    /// Is there any other page besides this one?
215    pub fn has_other_pages(&self) -> bool {
216        self.has_next() || self.has_previous()
217    }
218
219    /// The next page number, or `None` on the last page.
220    pub fn next_page_number(&self) -> Option<i64> {
221        self.has_next().then(|| self.number + 1)
222    }
223
224    /// The previous page number, or `None` on the first page.
225    pub fn previous_page_number(&self) -> Option<i64> {
226        self.has_previous().then(|| self.number - 1)
227    }
228
229    /// 1-based index of this page's first row within the full result set.
230    /// Returns 0 when the page is empty.
231    pub fn start_index(&self) -> i64 {
232        if self.total_count == 0 {
233            return 0;
234        }
235        (self.number - 1) * self.per_page as i64 + 1
236    }
237
238    /// 1-based index of this page's last row within the full result set.
239    /// On the final page this is `total_count`; on an empty set it's 0.
240    pub fn end_index(&self) -> i64 {
241        if self.total_count == 0 {
242            return 0;
243        }
244        (self.number * self.per_page as i64).min(self.total_count)
245    }
246
247    /// The windowed page range for nav rendering: `on_each_side` numbers
248    /// either side of the current page, `on_ends` numbers pinned to each
249    /// end, and [`PageItem::Ellipsis`] markers where the run is elided.
250    ///
251    /// For 20 pages on
252    /// page 6 with `(on_each_side, on_ends) = (2, 1)` this yields
253    /// `1 … 4 5 [6] 7 8 … 20`.
254    pub fn elided_page_range(&self, on_each_side: i64, on_ends: i64) -> Vec<PageItem> {
255        elided_range(self.number, self.num_pages, on_each_side, on_ends)
256    }
257
258    /// Derive the serializable template view of this page (the nav uses a
259    /// default `(on_each_side, on_ends)` of `(3, 1)`).
260    pub fn context(&self) -> PageContext {
261        self.context_with(3, 1)
262    }
263
264    /// [`Self::context`] with an explicit elision window.
265    pub fn context_with(&self, on_each_side: i64, on_ends: i64) -> PageContext {
266        PageContext {
267            number: self.number,
268            num_pages: self.num_pages,
269            total_count: self.total_count,
270            per_page: self.per_page,
271            has_next: self.has_next(),
272            has_previous: self.has_previous(),
273            next_page_number: self.next_page_number(),
274            previous_page_number: self.previous_page_number(),
275            start_index: self.start_index(),
276            end_index: self.end_index(),
277            page_range: self
278                .elided_page_range(on_each_side, on_ends)
279                .into_iter()
280                .map(PageItemContext::from)
281                .collect(),
282        }
283    }
284}
285
286/// One entry in a windowed page range: either a concrete page number or an
287/// ellipsis gap.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum PageItem {
290    /// A concrete, linkable page number.
291    Number(i64),
292    /// An elided run of pages, rendered as `…`.
293    Ellipsis,
294}
295
296/// Build the windowed range `[1 .. on_ends] … [n-on_each_side .. n+on_each_side] … [last-on_ends .. last]`.
297///
298/// Free function so it's unit-testable without a `Page`/DB. Collapses an
299/// ellipsis to nothing when the gap is a single missing page (we show
300/// the number rather than a `…` that hides exactly one page).
301fn elided_range(current: i64, num_pages: i64, on_each_side: i64, on_ends: i64) -> Vec<PageItem> {
302    let on_each_side = on_each_side.max(0);
303    let on_ends = on_ends.max(0);
304
305    // Small enough to show every page: no elision.
306    if num_pages <= (on_each_side + on_ends) * 2 + 1 {
307        return (1..=num_pages).map(PageItem::Number).collect();
308    }
309
310    let mut items = Vec::new();
311
312    // Left end + left ellipsis.
313    let left_window_start = current - on_each_side;
314    if left_window_start > on_ends + 1 {
315        for p in 1..=on_ends {
316            items.push(PageItem::Number(p));
317        }
318        // Only emit `…` if it actually hides >1 page; otherwise show the
319        // lone page it would have hidden.
320        if left_window_start > on_ends + 2 {
321            items.push(PageItem::Ellipsis);
322        } else {
323            items.push(PageItem::Number(on_ends + 1));
324        }
325    } else {
326        for p in 1..left_window_start.max(1) {
327            items.push(PageItem::Number(p));
328        }
329    }
330
331    // Central window around the current page.
332    let window_start = left_window_start.max(1);
333    let window_end = (current + on_each_side).min(num_pages);
334    for p in window_start..=window_end {
335        items.push(PageItem::Number(p));
336    }
337
338    // Right ellipsis + right end.
339    let right_window_end = current + on_each_side;
340    if right_window_end < num_pages - on_ends {
341        if right_window_end < num_pages - on_ends - 1 {
342            items.push(PageItem::Ellipsis);
343        } else {
344            items.push(PageItem::Number(num_pages - on_ends));
345        }
346        for p in (num_pages - on_ends + 1)..=num_pages {
347            items.push(PageItem::Number(p));
348        }
349    } else {
350        for p in (right_window_end + 1)..=num_pages {
351            items.push(PageItem::Number(p));
352        }
353    }
354
355    items
356}
357
358/// Serializable template view of a [`Page`].
359///
360/// A handler renders the nav by passing this (via [`Page::context`]) into a
361/// template as `page`; the bundled `_pagination.html` partial reads exactly
362/// these fields. `page_range` is the elided window as serializable items.
363#[derive(Debug, Clone, Serialize)]
364pub struct PageContext {
365    /// 1-based current page number.
366    pub number: i64,
367    /// Total number of pages.
368    pub num_pages: i64,
369    /// Total rows across all pages.
370    pub total_count: i64,
371    /// Rows per page.
372    pub per_page: usize,
373    /// Whether a next page exists.
374    pub has_next: bool,
375    /// Whether a previous page exists.
376    pub has_previous: bool,
377    /// Next page number (null on the last page).
378    pub next_page_number: Option<i64>,
379    /// Previous page number (null on the first page).
380    pub previous_page_number: Option<i64>,
381    /// 1-based index of the first row on this page.
382    pub start_index: i64,
383    /// 1-based index of the last row on this page.
384    pub end_index: i64,
385    /// The elided nav window.
386    pub page_range: Vec<PageItemContext>,
387}
388
389/// Serializable form of a [`PageItem`]: a number entry renders `{n}` (with
390/// `ellipsis: false`); an ellipsis entry renders `{ellipsis: true}` (with
391/// `n: null`). A template branches on `item.ellipsis`.
392#[derive(Debug, Clone, Serialize)]
393pub struct PageItemContext {
394    /// The page number, or `null` for an ellipsis.
395    pub n: Option<i64>,
396    /// Whether this entry is an elided `…` gap.
397    pub ellipsis: bool,
398}
399
400impl From<PageItem> for PageItemContext {
401    fn from(item: PageItem) -> Self {
402        match item {
403            PageItem::Number(n) => PageItemContext {
404                n: Some(n),
405                ellipsis: false,
406            },
407            PageItem::Ellipsis => PageItemContext {
408                n: None,
409                ellipsis: true,
410            },
411        }
412    }
413}
414
415/// Rebuild a querystring, replacing (or inserting) `key`'s value with
416/// `value`, preserving every other parameter and their order.
417///
418/// The fiddly bit behind a pagination nav: a `?sort=name` filter has to
419/// survive every `?page=N` link, so the partial writes
420/// `{{ querystring_with(base_query, "page", item.n) }}`. The returned string
421/// has no leading `?`; the template prepends one.
422///
423/// - An empty/`None`-ish `current_query` yields just `key=value`.
424/// - The replaced/inserted key is value-encoded; existing untouched pairs
425///   pass through verbatim (they were already encoded in the inbound URL).
426pub fn querystring_with(current_query: &str, key: &str, value: &str) -> String {
427    let mut pairs: Vec<(String, String)> = Vec::new();
428    let mut replaced = false;
429
430    for pair in current_query.trim_start_matches('?').split('&') {
431        if pair.is_empty() {
432            continue;
433        }
434        let (k, v) = match pair.split_once('=') {
435            Some((k, v)) => (k.to_string(), v.to_string()),
436            None => (pair.to_string(), String::new()),
437        };
438        if k == key {
439            pairs.push((k, encode_component(value)));
440            replaced = true;
441        } else {
442            pairs.push((k, v));
443        }
444    }
445
446    if !replaced {
447        pairs.push((key.to_string(), encode_component(value)));
448    }
449
450    pairs
451        .into_iter()
452        .map(|(k, v)| format!("{k}={v}"))
453        .collect::<Vec<_>>()
454        .join("&")
455}
456
457/// Minimal percent-encoding for a querystring value: spaces and the handful
458/// of delimiter characters that would otherwise break parsing. Keeps the
459/// helper dependency-free; the common pagination values (`page` numbers,
460/// `sort` column names) are already URL-safe.
461fn encode_component(value: &str) -> String {
462    let mut out = String::with_capacity(value.len());
463    for ch in value.chars() {
464        match ch {
465            ' ' => out.push_str("%20"),
466            '&' => out.push_str("%26"),
467            '=' => out.push_str("%3D"),
468            '#' => out.push_str("%23"),
469            '?' => out.push_str("%3F"),
470            '%' => out.push_str("%25"),
471            c => out.push(c),
472        }
473    }
474    out
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    fn nums(items: &[PageItem]) -> Vec<String> {
482        items
483            .iter()
484            .map(|i| match i {
485                PageItem::Number(n) => n.to_string(),
486                PageItem::Ellipsis => "…".to_string(),
487            })
488            .collect()
489    }
490
491    #[test]
492    fn num_pages_math() {
493        assert_eq!(num_pages_for(23, 10), 3);
494        assert_eq!(num_pages_for(20, 10), 2);
495        assert_eq!(num_pages_for(21, 10), 3);
496        // Empty set: still one page.
497        assert_eq!(num_pages_for(0, 10), 1);
498        assert_eq!(num_pages_for(-5, 10), 1);
499        // per_page clamps to >= 1.
500        assert_eq!(num_pages_for(5, 0), 5);
501    }
502
503    fn page_of(number: i64, per_page: usize, total: i64, num_pages: i64) -> Page<()> {
504        Page {
505            object_list: Vec::new(),
506            number,
507            per_page,
508            total_count: total,
509            num_pages,
510        }
511    }
512
513    #[test]
514    fn start_end_index_semantics() {
515        // 23 rows, 10 per page, 3 pages.
516        let p1 = page_of(1, 10, 23, 3);
517        assert_eq!(p1.start_index(), 1);
518        assert_eq!(p1.end_index(), 10);
519        assert!(!p1.has_previous());
520        assert!(p1.has_next());
521        assert_eq!(p1.next_page_number(), Some(2));
522        assert_eq!(p1.previous_page_number(), None);
523
524        let p3 = page_of(3, 10, 23, 3);
525        assert_eq!(p3.start_index(), 21);
526        assert_eq!(p3.end_index(), 23);
527        assert!(p3.has_previous());
528        assert!(!p3.has_next());
529        assert_eq!(p3.next_page_number(), None);
530        assert_eq!(p3.previous_page_number(), Some(2));
531    }
532
533    #[test]
534    fn empty_page_indices_are_zero() {
535        let p = page_of(1, 10, 0, 1);
536        assert_eq!(p.start_index(), 0);
537        assert_eq!(p.end_index(), 0);
538        assert!(!p.has_other_pages());
539    }
540
541    #[test]
542    fn elided_range_shows_all_when_small() {
543        let r = elided_range(2, 5, 2, 1);
544        assert_eq!(nums(&r), vec!["1", "2", "3", "4", "5"]);
545    }
546
547    #[test]
548    fn elided_range_windows_middle_with_both_ellipses() {
549        // 20 pages, page 6, on_each_side=2, on_ends=1 -> 1 … 4 5 6 7 8 … 20
550        let r = elided_range(6, 20, 2, 1);
551        assert_eq!(
552            nums(&r),
553            vec!["1", "…", "4", "5", "6", "7", "8", "…", "20"]
554        );
555        assert!(r.contains(&PageItem::Ellipsis));
556    }
557
558    #[test]
559    fn elided_range_near_start_only_right_ellipsis() {
560        // page 2 of 20: left side fully shown, right elided.
561        let r = elided_range(2, 20, 2, 1);
562        assert_eq!(nums(&r), vec!["1", "2", "3", "4", "…", "20"]);
563    }
564
565    #[test]
566    fn elided_range_near_end_only_left_ellipsis() {
567        let r = elided_range(19, 20, 2, 1);
568        assert_eq!(nums(&r), vec!["1", "…", "17", "18", "19", "20"]);
569    }
570
571    #[test]
572    fn querystring_replaces_page_preserving_others() {
573        // Other params survive; page is replaced in place.
574        assert_eq!(
575            querystring_with("page=1&sort=name", "page", "3"),
576            "page=3&sort=name"
577        );
578        // Inserted when absent.
579        assert_eq!(querystring_with("sort=name", "page", "2"), "sort=name&page=2");
580        // Empty current query -> just the pair.
581        assert_eq!(querystring_with("", "page", "5"), "page=5");
582        // Leading `?` tolerated.
583        assert_eq!(querystring_with("?page=1", "page", "2"), "page=2");
584        // Value with a space gets encoded.
585        assert_eq!(
586            querystring_with("page=1", "sort", "first name"),
587            "page=1&sort=first%20name"
588        );
589    }
590}