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