Skip to main content

klauthed_data/pagination/
offset.rs

1//! Offset-based pagination: [`OffsetPageRequest`] and [`Page`].
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::DataError;
6
7use super::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, SortKey};
8
9/// A request for a single page of results using classic `LIMIT … OFFSET …`.
10///
11/// Pages are **1-indexed** (`page = 1` is the first page). `per_page` is
12/// silently capped at [`MAX_PAGE_SIZE`].
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct OffsetPageRequest {
15    /// Current page number (1-indexed, must be ≥ 1).
16    pub page: u32,
17    /// Maximum number of items per page (capped at [`MAX_PAGE_SIZE`]).
18    pub per_page: u32,
19    /// Optional sort keys; an empty slice means caller-supplied default ordering.
20    pub sort: Vec<SortKey>,
21}
22
23impl OffsetPageRequest {
24    /// Create a new request, validating that `page >= 1` and capping
25    /// `per_page` at [`MAX_PAGE_SIZE`].
26    pub fn new(page: u32, per_page: u32) -> Result<Self, DataError> {
27        if page < 1 {
28            return Err(DataError::InvalidPage("page must be >= 1 (pages are 1-indexed)".into()));
29        }
30        let per_page = per_page.clamp(1, MAX_PAGE_SIZE);
31        Ok(OffsetPageRequest { page, per_page, sort: Vec::new() })
32    }
33
34    /// Convenience: create a request for the **first** page with `per_page` items.
35    pub fn first(per_page: u32) -> Result<Self, DataError> {
36        Self::new(1, per_page)
37    }
38
39    /// The SQL `OFFSET` value: `(page - 1) * per_page`.
40    pub fn offset(&self) -> u64 {
41        (self.page as u64 - 1) * self.per_page as u64
42    }
43
44    /// The SQL `LIMIT` value: equal to `per_page`.
45    pub fn limit(&self) -> u64 {
46        self.per_page as u64
47    }
48}
49
50impl Default for OffsetPageRequest {
51    fn default() -> Self {
52        OffsetPageRequest { page: 1, per_page: DEFAULT_PAGE_SIZE, sort: Vec::new() }
53    }
54}
55
56/// A single page of results with full metadata.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Page<T> {
59    /// The items on this page.
60    pub items: Vec<T>,
61    /// Total number of matching items across all pages.
62    pub total_items: u64,
63    /// The current page number (1-indexed).
64    pub page: u32,
65    /// The maximum items per page that was requested.
66    pub per_page: u32,
67    /// Total number of pages (`ceil(total_items / per_page)`).
68    pub total_pages: u64,
69    /// Whether there is a next page.
70    pub has_next: bool,
71    /// Whether there is a previous page.
72    pub has_prev: bool,
73}
74
75impl<T> Page<T> {
76    /// Build a `Page` from `items`, the overall `total_items` count, and the
77    /// original `request`. All derived fields are computed automatically.
78    pub fn new(items: Vec<T>, total_items: u64, req: &OffsetPageRequest) -> Self {
79        let per_page = req.per_page as u64;
80        let total_pages = if per_page == 0 { 0 } else { total_items.div_ceil(per_page) };
81        let page = req.page;
82        let has_prev = page > 1;
83        let has_next = (page as u64) < total_pages;
84        Page { items, total_items, page, per_page: req.per_page, total_pages, has_next, has_prev }
85    }
86
87    /// Build an empty `Page` (zero items, zero total).
88    pub fn empty(req: &OffsetPageRequest) -> Self {
89        Page::new(Vec::new(), 0, req)
90    }
91
92    /// Transform every item with `f`, preserving all pagination metadata.
93    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Page<U> {
94        Page {
95            items: self.items.into_iter().map(f).collect(),
96            total_items: self.total_items,
97            page: self.page,
98            per_page: self.per_page,
99            total_pages: self.total_pages,
100            has_next: self.has_next,
101            has_prev: self.has_prev,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn offset_request_default() {
112        let req = OffsetPageRequest::default();
113        assert_eq!(req.page, 1);
114        assert_eq!(req.per_page, DEFAULT_PAGE_SIZE);
115        assert!(req.sort.is_empty());
116    }
117
118    #[test]
119    fn offset_request_new_valid() {
120        let req = OffsetPageRequest::new(3, 10).unwrap();
121        assert_eq!(req.page, 3);
122        assert_eq!(req.per_page, 10);
123        assert_eq!(req.offset(), 20);
124        assert_eq!(req.limit(), 10);
125    }
126
127    #[test]
128    fn offset_request_first() {
129        let req = OffsetPageRequest::first(5).unwrap();
130        assert_eq!(req.page, 1);
131        assert_eq!(req.offset(), 0);
132        assert_eq!(req.limit(), 5);
133    }
134
135    #[test]
136    fn offset_request_page_zero_is_error() {
137        let err = OffsetPageRequest::new(0, 10).unwrap_err();
138        match err {
139            DataError::InvalidPage(msg) => assert!(msg.contains("1-indexed")),
140            other => panic!("expected InvalidPage, got {other:?}"),
141        }
142    }
143
144    #[test]
145    fn offset_request_per_page_capped() {
146        let req = OffsetPageRequest::new(1, 9999).unwrap();
147        assert_eq!(req.per_page, MAX_PAGE_SIZE);
148    }
149
150    // ── Page<T> ───────────────────────────────────────────────────────────────
151
152    #[test]
153    fn page_new_computes_metadata() {
154        let req = OffsetPageRequest::new(2, 10).unwrap();
155        let page: Page<i32> = Page::new(vec![1, 2, 3], 25, &req);
156        assert_eq!(page.total_pages, 3); // ceil(25/10)
157        assert!(page.has_prev); // page 2 has prev
158        assert!(page.has_next); // page 2 of 3 has next
159    }
160
161    #[test]
162    fn page_last_page_has_no_next() {
163        let req = OffsetPageRequest::new(3, 10).unwrap();
164        let page: Page<i32> = Page::new(vec![1], 21, &req);
165        assert_eq!(page.total_pages, 3);
166        assert!(page.has_prev);
167        assert!(!page.has_next);
168    }
169
170    #[test]
171    fn page_first_page_has_no_prev() {
172        let req = OffsetPageRequest::new(1, 10).unwrap();
173        let page: Page<i32> = Page::new(vec![1], 5, &req);
174        assert!(!page.has_prev);
175    }
176
177    #[test]
178    fn page_empty() {
179        let req = OffsetPageRequest::default();
180        let page: Page<i32> = Page::empty(&req);
181        assert!(page.items.is_empty());
182        assert_eq!(page.total_items, 0);
183        assert_eq!(page.total_pages, 0);
184        assert!(!page.has_prev);
185        assert!(!page.has_next);
186    }
187
188    #[test]
189    fn page_map_transforms_items_preserves_meta() {
190        let req = OffsetPageRequest::new(2, 5).unwrap();
191        let page = Page::new(vec![1u32, 2, 3], 13, &req);
192        let mapped = page.map(|x| x * 10);
193        assert_eq!(mapped.items, vec![10, 20, 30]);
194        assert_eq!(mapped.total_items, 13);
195        assert_eq!(mapped.total_pages, 3);
196        assert!(mapped.has_prev);
197        assert!(mapped.has_next);
198    }
199}