1use crate::builder::{Direction, QueryBuilder};
8use crate::{Database, Row, Value, rows_to_json};
9use rustlavel_core::{Json, Result};
10
11#[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 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 ((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 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 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 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#[derive(Debug)]
91pub struct CursorPage {
92 pub rows: Vec<Row>,
93 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 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 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 query = query.filter_op(column, ">", decode_cursor(cursor));
145 }
146
147 let mut rows = query.get(db).await?;
148
149 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
163fn 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 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}