oxify_storage/
pagination.rs

1//! Pagination utilities for database queries
2//!
3//! This module provides standardized pagination helpers for both cursor-based
4//! and offset-based pagination strategies.
5//!
6//! # Pagination Strategies
7//!
8//! ## Offset-Based Pagination
9//! Simple pagination using `OFFSET` and `LIMIT`. Best for small datasets
10//! or when users need to jump to arbitrary pages.
11//!
12//! ```ignore
13//! use oxify_storage::pagination::{PaginationRequest, paginate_query};
14//!
15//! let request = PaginationRequest::offset(0, 20);
16//! let (items, response) = paginate_query(
17//!     pool,
18//!     "SELECT * FROM workflows WHERE user_id = $1",
19//!     &[user_id],
20//!     request,
21//! ).await?;
22//! ```
23//!
24//! ## Cursor-Based Pagination
25//! Uses a cursor (typically a unique identifier or timestamp) for efficient
26//! pagination. Best for large datasets and infinite scroll scenarios.
27//!
28//! ```ignore
29//! use oxify_storage::pagination::{PaginationRequest, CursorDirection};
30//!
31//! let request = PaginationRequest::cursor(
32//!     Some("last_id_from_previous_page"),
33//!     20,
34//!     CursorDirection::Forward,
35//! );
36//! ```
37
38use serde::{Deserialize, Serialize};
39
40/// Default page size if not specified
41pub const DEFAULT_PAGE_SIZE: u32 = 20;
42
43/// Maximum allowed page size to prevent excessive memory usage
44pub const MAX_PAGE_SIZE: u32 = 1000;
45
46/// Minimum allowed page size
47pub const MIN_PAGE_SIZE: u32 = 1;
48
49/// Pagination strategy
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum PaginationStrategy {
53    /// Offset-based pagination (page number + size)
54    Offset {
55        /// Zero-based offset
56        offset: u64,
57        /// Number of items per page
58        limit: u32,
59    },
60    /// Cursor-based pagination (cursor + size)
61    Cursor {
62        /// Cursor value (typically an ID or timestamp)
63        cursor: Option<String>,
64        /// Number of items to fetch
65        limit: u32,
66        /// Direction to paginate
67        direction: CursorDirection,
68    },
69}
70
71/// Direction for cursor-based pagination
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum CursorDirection {
75    /// Fetch items after the cursor
76    Forward,
77    /// Fetch items before the cursor
78    Backward,
79}
80
81/// Pagination request
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct PaginationRequest {
84    /// Pagination strategy
85    pub strategy: PaginationStrategy,
86}
87
88impl PaginationRequest {
89    /// Create an offset-based pagination request
90    ///
91    /// # Examples
92    /// ```
93    /// # use oxify_storage::pagination::PaginationRequest;
94    /// let request = PaginationRequest::offset(0, 20);
95    /// ```
96    pub fn offset(offset: u64, limit: u32) -> Self {
97        let limit = Self::validate_limit(limit);
98        Self {
99            strategy: PaginationStrategy::Offset { offset, limit },
100        }
101    }
102
103    /// Create a cursor-based pagination request
104    ///
105    /// # Examples
106    /// ```
107    /// # use oxify_storage::pagination::{PaginationRequest, CursorDirection};
108    /// let request = PaginationRequest::cursor(None, 20, CursorDirection::Forward);
109    /// ```
110    pub fn cursor(cursor: Option<String>, limit: u32, direction: CursorDirection) -> Self {
111        let limit = Self::validate_limit(limit);
112        Self {
113            strategy: PaginationStrategy::Cursor {
114                cursor,
115                limit,
116                direction,
117            },
118        }
119    }
120
121    /// Get the limit value
122    pub fn limit(&self) -> u32 {
123        match &self.strategy {
124            PaginationStrategy::Offset { limit, .. } => *limit,
125            PaginationStrategy::Cursor { limit, .. } => *limit,
126        }
127    }
128
129    /// Validate and clamp limit to allowed range
130    fn validate_limit(limit: u32) -> u32 {
131        limit.clamp(MIN_PAGE_SIZE, MAX_PAGE_SIZE)
132    }
133}
134
135impl Default for PaginationRequest {
136    fn default() -> Self {
137        Self::offset(0, DEFAULT_PAGE_SIZE)
138    }
139}
140
141/// Pagination response with metadata
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct PaginationResponse<T> {
144    /// Items in the current page
145    pub items: Vec<T>,
146    /// Total number of items (if available)
147    pub total: Option<u64>,
148    /// Current page number (for offset pagination)
149    pub page: Option<u64>,
150    /// Number of items per page
151    pub page_size: u32,
152    /// Whether there are more items available
153    pub has_more: bool,
154    /// Cursor for the next page (for cursor pagination)
155    pub next_cursor: Option<String>,
156    /// Cursor for the previous page (for cursor pagination)
157    pub prev_cursor: Option<String>,
158}
159
160impl<T> PaginationResponse<T> {
161    /// Create a new pagination response for offset-based pagination
162    pub fn offset(items: Vec<T>, total: u64, page: u64, page_size: u32) -> Self {
163        let has_more = (page + 1) * (page_size as u64) < total;
164        Self {
165            items,
166            total: Some(total),
167            page: Some(page),
168            page_size,
169            has_more,
170            next_cursor: None,
171            prev_cursor: None,
172        }
173    }
174
175    /// Create a new pagination response for cursor-based pagination
176    pub fn cursor(
177        items: Vec<T>,
178        page_size: u32,
179        next_cursor: Option<String>,
180        prev_cursor: Option<String>,
181    ) -> Self {
182        let has_more = next_cursor.is_some();
183        Self {
184            items,
185            total: None,
186            page: None,
187            page_size,
188            has_more,
189            next_cursor,
190            prev_cursor,
191        }
192    }
193
194    /// Map the items to a different type
195    pub fn map<U, F>(self, f: F) -> PaginationResponse<U>
196    where
197        F: FnMut(T) -> U,
198    {
199        PaginationResponse {
200            items: self.items.into_iter().map(f).collect(),
201            total: self.total,
202            page: self.page,
203            page_size: self.page_size,
204            has_more: self.has_more,
205            next_cursor: self.next_cursor,
206            prev_cursor: self.prev_cursor,
207        }
208    }
209}
210
211/// Page information for offset-based pagination
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213pub struct PageInfo {
214    /// Current page number (0-based)
215    pub page: u64,
216    /// Number of items per page
217    pub page_size: u32,
218    /// Total number of items
219    pub total_items: u64,
220    /// Total number of pages
221    pub total_pages: u64,
222    /// Whether there is a next page
223    pub has_next: bool,
224    /// Whether there is a previous page
225    pub has_prev: bool,
226}
227
228impl PageInfo {
229    /// Create page information from total count and pagination request
230    ///
231    /// # Examples
232    /// ```
233    /// # use oxify_storage::pagination::{PageInfo, PaginationRequest};
234    /// let request = PaginationRequest::offset(40, 20);
235    /// let info = PageInfo::from_total(100, &request);
236    /// assert_eq!(info.page, 2);
237    /// assert_eq!(info.total_pages, 5);
238    /// assert!(info.has_next);
239    /// assert!(info.has_prev);
240    /// ```
241    pub fn from_total(total: u64, request: &PaginationRequest) -> Self {
242        match &request.strategy {
243            PaginationStrategy::Offset { offset, limit } => {
244                let page_size = *limit;
245                let page = offset / page_size as u64;
246                let total_pages = total.div_ceil(page_size as u64);
247                let has_next = page + 1 < total_pages;
248                let has_prev = page > 0;
249
250                Self {
251                    page,
252                    page_size,
253                    total_items: total,
254                    total_pages,
255                    has_next,
256                    has_prev,
257                }
258            }
259            PaginationStrategy::Cursor { .. } => {
260                // For cursor pagination, we don't have traditional page numbers
261                Self {
262                    page: 0,
263                    page_size: request.limit(),
264                    total_items: total,
265                    total_pages: 1,
266                    has_next: false,
267                    has_prev: false,
268                }
269            }
270        }
271    }
272}
273
274/// Builder for constructing SQL LIMIT/OFFSET clauses
275pub struct PaginationBuilder {
276    request: PaginationRequest,
277}
278
279impl PaginationBuilder {
280    /// Create a new pagination builder
281    pub fn new(request: PaginationRequest) -> Self {
282        Self { request }
283    }
284
285    /// Get the LIMIT value
286    pub fn limit(&self) -> u32 {
287        self.request.limit()
288    }
289
290    /// Get the OFFSET value (for offset-based pagination)
291    pub fn offset(&self) -> Option<u64> {
292        match &self.request.strategy {
293            PaginationStrategy::Offset { offset, .. } => Some(*offset),
294            PaginationStrategy::Cursor { .. } => None,
295        }
296    }
297
298    /// Build LIMIT clause SQL
299    pub fn limit_clause(&self) -> String {
300        format!("LIMIT {}", self.limit())
301    }
302
303    /// Build OFFSET clause SQL (for offset-based pagination)
304    pub fn offset_clause(&self) -> String {
305        if let Some(offset) = self.offset() {
306            format!("OFFSET {offset}")
307        } else {
308            String::new()
309        }
310    }
311
312    /// Build complete LIMIT/OFFSET SQL
313    ///
314    /// # Examples
315    /// ```
316    /// # use oxify_storage::pagination::{PaginationRequest, PaginationBuilder};
317    /// let request = PaginationRequest::offset(40, 20);
318    /// let builder = PaginationBuilder::new(request);
319    /// assert_eq!(builder.build_sql(), "LIMIT 20 OFFSET 40");
320    /// ```
321    pub fn build_sql(&self) -> String {
322        let mut sql = self.limit_clause();
323        let offset = self.offset_clause();
324        if !offset.is_empty() {
325            sql.push(' ');
326            sql.push_str(&offset);
327        }
328        sql
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_pagination_request_offset() {
338        let request = PaginationRequest::offset(20, 10);
339        assert_eq!(request.limit(), 10);
340        match request.strategy {
341            PaginationStrategy::Offset { offset, limit } => {
342                assert_eq!(offset, 20);
343                assert_eq!(limit, 10);
344            }
345            _ => panic!("Expected Offset strategy"),
346        }
347    }
348
349    #[test]
350    fn test_pagination_request_cursor() {
351        let request =
352            PaginationRequest::cursor(Some("cursor123".to_string()), 15, CursorDirection::Forward);
353        assert_eq!(request.limit(), 15);
354        match request.strategy {
355            PaginationStrategy::Cursor {
356                cursor,
357                limit,
358                direction,
359            } => {
360                assert_eq!(cursor, Some("cursor123".to_string()));
361                assert_eq!(limit, 15);
362                assert_eq!(direction, CursorDirection::Forward);
363            }
364            _ => panic!("Expected Cursor strategy"),
365        }
366    }
367
368    #[test]
369    fn test_pagination_request_default() {
370        let request = PaginationRequest::default();
371        assert_eq!(request.limit(), DEFAULT_PAGE_SIZE);
372    }
373
374    #[test]
375    fn test_pagination_limit_validation() {
376        // Test limit too high
377        let request = PaginationRequest::offset(0, 5000);
378        assert_eq!(request.limit(), MAX_PAGE_SIZE);
379
380        // Test limit too low
381        let request = PaginationRequest::offset(0, 0);
382        assert_eq!(request.limit(), MIN_PAGE_SIZE);
383
384        // Test valid limit
385        let request = PaginationRequest::offset(0, 50);
386        assert_eq!(request.limit(), 50);
387    }
388
389    #[test]
390    fn test_pagination_response_offset() {
391        let items = vec![1, 2, 3, 4, 5];
392        let response = PaginationResponse::offset(items, 100, 2, 20);
393
394        assert_eq!(response.items.len(), 5);
395        assert_eq!(response.total, Some(100));
396        assert_eq!(response.page, Some(2));
397        assert_eq!(response.page_size, 20);
398        assert!(response.has_more);
399    }
400
401    #[test]
402    fn test_pagination_response_cursor() {
403        let items = vec!["a", "b", "c"];
404        let response = PaginationResponse::cursor(
405            items,
406            20,
407            Some("next_cursor".to_string()),
408            Some("prev_cursor".to_string()),
409        );
410
411        assert_eq!(response.items.len(), 3);
412        assert_eq!(response.total, None);
413        assert!(response.has_more);
414        assert_eq!(response.next_cursor, Some("next_cursor".to_string()));
415        assert_eq!(response.prev_cursor, Some("prev_cursor".to_string()));
416    }
417
418    #[test]
419    fn test_pagination_response_map() {
420        let items = vec![1, 2, 3];
421        let response = PaginationResponse::offset(items, 10, 0, 20);
422        let mapped = response.map(|x| x * 2);
423
424        assert_eq!(mapped.items, vec![2, 4, 6]);
425        assert_eq!(mapped.total, Some(10));
426    }
427
428    #[test]
429    fn test_page_info_from_total() {
430        let request = PaginationRequest::offset(40, 20);
431        let info = PageInfo::from_total(100, &request);
432
433        assert_eq!(info.page, 2);
434        assert_eq!(info.page_size, 20);
435        assert_eq!(info.total_items, 100);
436        assert_eq!(info.total_pages, 5);
437        assert!(info.has_next);
438        assert!(info.has_prev);
439    }
440
441    #[test]
442    fn test_page_info_first_page() {
443        let request = PaginationRequest::offset(0, 20);
444        let info = PageInfo::from_total(100, &request);
445
446        assert_eq!(info.page, 0);
447        assert!(!info.has_prev);
448        assert!(info.has_next);
449    }
450
451    #[test]
452    fn test_page_info_last_page() {
453        let request = PaginationRequest::offset(80, 20);
454        let info = PageInfo::from_total(100, &request);
455
456        assert_eq!(info.page, 4);
457        assert!(info.has_prev);
458        assert!(!info.has_next);
459    }
460
461    #[test]
462    fn test_pagination_builder() {
463        let request = PaginationRequest::offset(40, 20);
464        let builder = PaginationBuilder::new(request);
465
466        assert_eq!(builder.limit(), 20);
467        assert_eq!(builder.offset(), Some(40));
468        assert_eq!(builder.limit_clause(), "LIMIT 20");
469        assert_eq!(builder.offset_clause(), "OFFSET 40");
470        assert_eq!(builder.build_sql(), "LIMIT 20 OFFSET 40");
471    }
472
473    #[test]
474    fn test_pagination_builder_no_offset() {
475        let request = PaginationRequest::offset(0, 10);
476        let builder = PaginationBuilder::new(request);
477
478        assert_eq!(builder.build_sql(), "LIMIT 10 OFFSET 0");
479    }
480
481    #[test]
482    fn test_pagination_builder_cursor() {
483        let request =
484            PaginationRequest::cursor(Some("abc".to_string()), 25, CursorDirection::Forward);
485        let builder = PaginationBuilder::new(request);
486
487        assert_eq!(builder.limit(), 25);
488        assert_eq!(builder.offset(), None);
489        assert_eq!(builder.build_sql(), "LIMIT 25");
490    }
491}