Skip to main content

rustlavel_db/
pagination.rs

1//! Pagination.
2//!
3//! Two shapes, because they solve different problems: page numbers, which
4//! users understand and which need a count query; and cursors, which stay
5//! correct and stay fast when rows are being inserted underneath the reader.
6
7use crate::builder::{Direction, QueryBuilder};
8use crate::{Database, Row, Value, rows_to_json};
9use rustlavel_core::{Json, Result};
10
11/// One page of rows, plus what a view needs to draw the links.
12#[derive(Debug)]
13pub struct Page {
14    pub rows: Vec<Row>,
15    pub total: i64,
16    pub per_page: i64,
17    pub current_page: i64,
18}
19
20impl Page {
21    /// The rows as models, for handing to a resource:
22    /// `UserResource::collection(&page.hydrate::<User>()?)`.
23    pub fn hydrate<M: crate::ModelExt>(&self) -> Result<Vec<M>> {
24        M::hydrate(&self.rows)
25    }
26
27    pub fn last_page(&self) -> i64 {
28        if self.per_page <= 0 {
29            return 1;
30        }
31        // A total of 0 still has one (empty) page, which is what a view expects.
32        ((self.total as f64) / (self.per_page as f64)).ceil().max(1.0) as i64
33    }
34
35    pub fn has_more(&self) -> bool {
36        self.current_page < self.last_page()
37    }
38
39    pub fn is_empty(&self) -> bool {
40        self.rows.is_empty()
41    }
42
43    /// The 1-based index of the first row on this page, or `None` when empty.
44    pub fn from(&self) -> Option<i64> {
45        (!self.is_empty()).then(|| (self.current_page - 1) * self.per_page + 1)
46    }
47
48    pub fn to(&self) -> Option<i64> {
49        self.from().map(|from| from + self.rows.len() as i64 - 1)
50    }
51
52    /// The page numbers to show, with `None` standing for a gap.
53    ///
54    /// Always includes the first and last page and a window around the current
55    /// one, so the control stays a fixed width however many pages there are.
56    pub fn links(&self, window: i64) -> Vec<Option<i64>> {
57        let last = self.last_page();
58        let mut out = Vec::new();
59        let mut previous: Option<i64> = None;
60
61        for page in 1..=last {
62            let near_current = (page - self.current_page).abs() <= window;
63            if page == 1 || page == last || near_current {
64                if previous.is_some_and(|p| page - p > 1) {
65                    out.push(None);
66                }
67                out.push(Some(page));
68                previous = Some(page);
69            }
70        }
71        out
72    }
73
74    /// The API shape, matching Laravel's paginator so a client library written
75    /// against one works against the other.
76    pub fn to_json(&self) -> Json {
77        Json::object([
78            ("data", rows_to_json(&self.rows)),
79            ("total", Json::from(self.total)),
80            ("per_page", Json::from(self.per_page)),
81            ("current_page", Json::from(self.current_page)),
82            ("last_page", Json::from(self.last_page())),
83            ("from", self.from().map_or(Json::Null, Json::from)),
84            ("to", self.to().map_or(Json::Null, Json::from)),
85        ])
86    }
87}
88
89/// A page fetched by cursor rather than by offset.
90#[derive(Debug)]
91pub struct CursorPage {
92    pub rows: Vec<Row>,
93    /// Pass to the next call to continue; `None` at the end.
94    pub next_cursor: Option<String>,
95    pub per_page: i64,
96}
97
98impl CursorPage {
99    pub fn hydrate<M: crate::ModelExt>(&self) -> Result<Vec<M>> {
100        M::hydrate(&self.rows)
101    }
102
103    pub fn to_json(&self) -> Json {
104        Json::object([
105            ("data", rows_to_json(&self.rows)),
106            ("per_page", Json::from(self.per_page)),
107            ("next_cursor", self.next_cursor.clone().map_or(Json::Null, Json::from)),
108        ])
109    }
110}
111
112impl QueryBuilder {
113    /// Fetch one page, with a count query for the total.
114    ///
115    /// Convenient and familiar, but the count scans the whole matching set:
116    /// past a few hundred thousand rows, reach for [`QueryBuilder::cursor_paginate`].
117    pub async fn paginate(&self, db: &Database, page: i64, per_page: i64) -> Result<Page> {
118        let per_page = per_page.clamp(1, 1000);
119        let page = page.max(1);
120
121        let total = self.count(db).await?;
122        let rows = self.clone().page(page, per_page).get(db).await?;
123
124        Ok(Page { rows, total, per_page, current_page: page })
125    }
126
127    /// Fetch one page by cursor, ordered by a unique column.
128    ///
129    /// No count and no offset, so the cost does not grow with the page number,
130    /// and a row inserted while the reader pages through cannot cause another
131    /// row to be skipped or repeated.
132    pub async fn cursor_paginate(
133        &self,
134        db: &Database,
135        column: &str,
136        after: Option<&str>,
137        per_page: i64,
138    ) -> Result<CursorPage> {
139        let per_page = per_page.clamp(1, 1000);
140
141        let mut query = self.clone().order_by(column, Direction::Asc).limit(per_page + 1);
142        if let Some(cursor) = after {
143            // The cursor is the last value seen, so the comparison is strict.
144            query = query.filter_op(column, ">", decode_cursor(cursor));
145        }
146
147        let mut rows = query.get(db).await?;
148
149        // One row beyond the page proves there is a next page without a count.
150        let has_more = rows.len() as i64 > per_page;
151        if has_more {
152            rows.truncate(per_page as usize);
153        }
154
155        let next_cursor = has_more
156            .then(|| rows.last().and_then(|row| row.value(column).ok().map(Value::to_display)))
157            .flatten();
158
159        Ok(CursorPage { rows, next_cursor, per_page })
160    }
161}
162
163/// Cursors travel through a URL, so they arrive as text.
164///
165/// A numeric-looking cursor becomes a number; anything else stays text, which
166/// keeps both an integer id and a uuid working without the caller saying which.
167fn decode_cursor(cursor: &str) -> Value {
168    match cursor.parse::<i64>() {
169        Ok(number) => Value::Int(number),
170        Err(_) => Value::Text(cursor.to_string()),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::sync::Arc;
178
179    fn page(total: i64, per_page: i64, current: i64, rows: usize) -> Page {
180        let columns = Arc::new(vec!["id".to_string()]);
181        Page {
182            rows: (0..rows).map(|i| Row::new(Arc::clone(&columns), vec![Value::Int(i as i64)])).collect(),
183            total,
184            per_page,
185            current_page: current,
186        }
187    }
188
189    #[test]
190    fn computes_the_page_count() {
191        assert_eq!(page(100, 10, 1, 10).last_page(), 10);
192        assert_eq!(page(101, 10, 1, 10).last_page(), 11);
193        assert_eq!(page(0, 10, 1, 0).last_page(), 1);
194    }
195
196    #[test]
197    fn reports_the_range_on_the_page() {
198        let second = page(100, 10, 2, 10);
199
200        assert_eq!(second.from(), Some(11));
201        assert_eq!(second.to(), Some(20));
202        assert!(second.has_more());
203
204        let empty = page(0, 10, 1, 0);
205        assert_eq!(empty.from(), None);
206        assert!(!empty.has_more());
207    }
208
209    #[test]
210    fn link_windows_stay_a_fixed_width() {
211        let middle = page(1000, 10, 50, 10);
212        let links = middle.links(2);
213
214        assert_eq!(links.first(), Some(&Some(1)));
215        assert_eq!(links.last(), Some(&Some(100)));
216        assert!(links.contains(&None), "distant pages should be elided");
217        assert!(links.contains(&Some(50)));
218        assert!(links.contains(&Some(48)));
219        assert!(!links.contains(&Some(47)));
220    }
221
222    #[test]
223    fn a_short_run_of_pages_has_no_gaps() {
224        let links = page(30, 10, 2, 10).links(2);
225        assert_eq!(links, vec![Some(1), Some(2), Some(3)]);
226    }
227
228    #[test]
229    fn serializes_the_laravel_shape() {
230        let json = page(25, 10, 3, 5).to_json();
231
232        assert_eq!(json.get("total").unwrap().as_i64(), Some(25));
233        assert_eq!(json.get("last_page").unwrap().as_i64(), Some(3));
234        assert_eq!(json.get("from").unwrap().as_i64(), Some(21));
235        assert_eq!(json.get("to").unwrap().as_i64(), Some(25));
236        assert_eq!(json.get("data").unwrap().as_array().map(<[Json]>::len), Some(5));
237    }
238
239    #[test]
240    fn cursors_keep_their_type() {
241        assert_eq!(decode_cursor("42"), Value::Int(42));
242        assert_eq!(
243            decode_cursor("018f2c1a-0000-7000-8000-000000000000"),
244            Value::Text("018f2c1a-0000-7000-8000-000000000000".into())
245        );
246    }
247
248    #[test]
249    fn cursor_pagination_asks_for_one_extra_row() {
250        // The +1 is what lets the next page be detected without a count query.
251        let (sql, _) = QueryBuilder::new("posts")
252            .clone()
253            .order_by("id", Direction::Asc)
254            .limit(11)
255            .to_sql(&crate::dialect::Postgres)
256            .unwrap();
257
258        assert!(sql.ends_with("order by \"id\" asc limit 11"));
259    }
260}