diff --git a/crates/api-web/src/pagination.rs b/crates/api-web/src/pagination.rs index 86afe1560c..268343da08 100644 --- a/crates/api-web/src/pagination.rs +++ b/crates/api-web/src/pagination.rs @@ -176,34 +176,6 @@ fn resolve_params(params: &PaginationParams) -> (usize, usize) { (current_page, limit) } -/// Paginate a slice of IDs. Returns pagination metadata and the IDs for the -/// requested page. The caller should then batch-fetch details for only these IDs. -#[allow(dead_code)] -pub fn paginate_ids( - all_ids: &[T], - params: &PaginationParams, -) -> (PaginationInfo, Vec) { - let (current_page, limit) = resolve_params(params); - let total_items = all_ids.len(); - let info = PaginationInfo { - current_page, - limit, - total_items, - }; - - if limit == 0 { - return (info, all_ids.to_vec()); - } - - let offset = current_page.saturating_mul(limit); - if offset >= total_items { - return (info, vec![]); - } - - let page_ids: Vec = all_ids.iter().skip(offset).take(limit).cloned().collect(); - (info, page_ids) -} - /// Paginate an already-collected Vec (e.g. after in-memory filtering). /// Drains elements outside the page window so only the current page remains. pub fn paginate_vec(items: Vec, params: &PaginationParams) -> (PaginationInfo, Vec) { @@ -230,101 +202,387 @@ pub fn paginate_vec(items: Vec, params: &PaginationParams) -> (PaginationI #[cfg(test)] mod tests { + use axum::extract::Query; + use carbide_test_support::Outcome::*; + use carbide_test_support::{scenarios, value_scenarios}; + use http::Uri; + use super::*; - const LIMIT: usize = 1; + #[derive(Debug, PartialEq)] + struct PageResult { + current_page: usize, + limit: usize, + total_items: usize, + pages: usize, + items: Vec, + } - #[test] - fn paginate_ids_first_page() { - let ids: Vec = (0..5).collect(); - let params = PaginationParams { - current_page: Some(0), - limit: Some(LIMIT), - }; - let (info, page) = paginate_ids(&ids, ¶ms); - assert_eq!(page.len(), LIMIT); - assert_eq!(page[0], 0); - assert_eq!(info.pages(), 5); - assert_eq!(info.total_items, 5); + #[derive(Debug, PartialEq)] + struct NavigationResult { + pages: usize, + previous: usize, + next: usize, + range_start: usize, + range_end: usize, + } + + #[derive(Debug, PartialEq)] + struct ContextResult { + current_page: usize, + limit: usize, + total_items: usize, + path: String, + extra_query_params: String, } #[test] - fn paginate_ids_last_page() { - let ids: Vec = (0..5).collect(); - let params = PaginationParams { - current_page: Some(4), - limit: Some(LIMIT), + fn paginate_vec_cases() { + value_scenarios!(run = |(items, params)| { + let (info, page) = paginate_vec(items, ¶ms); + PageResult { + current_page: info.current_page, + limit: info.limit, + total_items: info.total_items, + pages: info.pages(), + items: page, + } }; - let (info, page) = paginate_ids(&ids, ¶ms); - assert_eq!(page.len(), LIMIT); - assert_eq!(page[0], 4); - assert_eq!(info.pages(), 5); + "bounded pages" { + ( + (0..5).collect::>(), + PaginationParams { + current_page: Some(0), + limit: Some(1), + }, + ) => PageResult { + current_page: 0, + limit: 1, + total_items: 5, + pages: 5, + items: vec![0], + }, + + ( + (0..5).collect::>(), + PaginationParams { + current_page: Some(1), + limit: Some(1), + }, + ) => PageResult { + current_page: 1, + limit: 1, + total_items: 5, + pages: 5, + items: vec![1], + }, + + ( + (0..5).collect::>(), + PaginationParams { + current_page: Some(4), + limit: Some(1), + }, + ) => PageResult { + current_page: 4, + limit: 1, + total_items: 5, + pages: 5, + items: vec![4], + }, + + ( + (0..5).collect::>(), + PaginationParams { + current_page: Some(10), + limit: Some(1), + }, + ) => PageResult { + current_page: 10, + limit: 1, + total_items: 5, + pages: 5, + items: vec![], + }, + } + + "new boundaries" { + ( + (0..5).collect::>(), + PaginationParams { + current_page: Some(1), + limit: Some(3), + }, + ) => PageResult { + current_page: 1, + limit: 3, + total_items: 5, + pages: 2, + items: vec![3, 4], + }, + + ( + (0..150).collect::>(), + PaginationParams { + current_page: Some(0), + limit: Some(MAX_PAGE_RECORD_LIMIT + 1), + }, + ) => PageResult { + current_page: 0, + limit: MAX_PAGE_RECORD_LIMIT, + total_items: 150, + pages: 2, + items: (0..100).collect::>(), + }, + + ( + (0..5).collect::>(), + PaginationParams { + current_page: Some(usize::MAX), + limit: Some(2), + }, + ) => PageResult { + current_page: usize::MAX, + limit: 2, + total_items: 5, + pages: 3, + items: vec![], + }, + } + + "unbounded and default limits" { + ( + (0..5).collect::>(), + PaginationParams { + current_page: None, + limit: Some(0), + }, + ) => PageResult { + current_page: 0, + limit: 0, + total_items: 5, + pages: 1, + items: (0..5).collect::>(), + }, + + ( + (0..5).collect::>(), + PaginationParams { + current_page: None, + limit: None, + }, + ) => PageResult { + current_page: 0, + limit: DEFAULT_PAGE_RECORD_LIMIT, + total_items: 5, + pages: 1, + items: (0..5).collect::>(), + }, + } + + "empty collection" { + ( + vec![], + PaginationParams { + current_page: None, + limit: None, + }, + ) => PageResult { + current_page: 0, + limit: DEFAULT_PAGE_RECORD_LIMIT, + total_items: 0, + pages: 0, + items: vec![], + }, + } + ); } #[test] - fn paginate_ids_beyond_range() { - let ids: Vec = (0..5).collect(); - let params = PaginationParams { - current_page: Some(10), - limit: Some(LIMIT), + fn pagination_info_helpers() { + value_scenarios!(run = |info: PaginationInfo| NavigationResult { + pages: info.pages(), + previous: info.previous(), + next: info.next(), + range_start: info.page_range_start(), + range_end: info.page_range_end(), }; - let (info, page) = paginate_ids(&ids, ¶ms); - assert!(page.is_empty()); - assert_eq!(info.pages(), 5); - assert_eq!(info.total_items, 5); + "empty and unbounded collections" { + PaginationInfo { + current_page: 0, + limit: 0, + total_items: 0, + } => NavigationResult { + pages: 0, + previous: 0, + next: 1, + range_start: 0, + range_end: 0, + }, + + PaginationInfo { + current_page: 0, + limit: 0, + total_items: 5, + } => NavigationResult { + pages: 1, + previous: 0, + next: 1, + range_start: 0, + range_end: 1, + }, + } + + "partial and bounded ranges" { + PaginationInfo { + current_page: 0, + limit: 2, + total_items: 5, + } => NavigationResult { + pages: 3, + previous: 0, + next: 1, + range_start: 0, + range_end: 3, + }, + + PaginationInfo { + current_page: 4, + limit: 2, + total_items: 10, + } => NavigationResult { + pages: 5, + previous: 3, + next: 5, + range_start: 1, + range_end: 5, + }, + } + + "saturating navigation" { + PaginationInfo { + current_page: usize::MAX, + limit: 1, + total_items: usize::MAX, + } => NavigationResult { + pages: usize::MAX, + previous: usize::MAX - 1, + next: usize::MAX, + range_start: usize::MAX - 3, + range_end: usize::MAX, + }, + } + ); } #[test] - fn paginate_ids_limit_zero_returns_all() { - let ids: Vec = (0..5).collect(); - let params = PaginationParams { - current_page: None, - limit: Some(0), + fn page_context_constructors_and_accessors() { + value_scenarios!(run = |context: PageContext| ContextResult { + current_page: context.current_page(), + limit: context.limit(), + total_items: context.total_items(), + path: context.path, + extra_query_params: context.extra_query_params, }; - let (info, page) = paginate_ids(&ids, ¶ms); - assert_eq!(page.len(), 5); - assert_eq!(info.pages(), 1); - assert_eq!(info.total_items, 5); + "direct context" { + PageContext::new( + PaginationInfo { + current_page: 4, + limit: 2, + total_items: 10, + }, + "/machines", + ) + .with_extra_params("state=ready".to_string()) => ContextResult { + current_page: 4, + limit: 2, + total_items: 10, + path: "/machines".to_string(), + extra_query_params: "state=ready".to_string(), + }, + } + + "precomputed page count" { + PageContext::from_page_count(2, 25, 4, "/switches") => ContextResult { + current_page: 2, + limit: 25, + total_items: 100, + path: "/switches".to_string(), + extra_query_params: String::new(), + }, + } + + "show all" { + PageContext::all(7, "/domains") => ContextResult { + current_page: 0, + limit: 0, + total_items: 7, + path: "/domains".to_string(), + extra_query_params: String::new(), + }, + } + ); } #[test] - fn paginate_ids_defaults() { - let ids: Vec = (0..5).collect(); - let params = PaginationParams { - current_page: None, - limit: None, + fn page_context_navigation_delegates_to_info() { + let info = PaginationInfo { + current_page: 4, + limit: 2, + total_items: 10, }; - let (info, page) = paginate_ids(&ids, ¶ms); - assert_eq!(page.len(), 5); - assert_eq!(info.pages(), 1); - assert_eq!(info.limit, DEFAULT_PAGE_RECORD_LIMIT); - assert_eq!(info.current_page, 0); + let expected = NavigationResult { + pages: info.pages(), + previous: info.previous(), + next: info.next(), + range_start: info.page_range_start(), + range_end: info.page_range_end(), + }; + let context = PageContext::new(info, "/machines"); + + assert_eq!( + NavigationResult { + pages: context.pages(), + previous: context.previous(), + next: context.next(), + range_start: context.page_range_start(), + range_end: context.page_range_end(), + }, + expected, + ); } #[test] - fn paginate_vec_with_filter() { - let items: Vec = (0..5).collect(); - let params = PaginationParams { - current_page: Some(1), - limit: Some(LIMIT), + fn pagination_query_deserialization() { + scenarios!(run = |raw_uri: &str| { + let uri = raw_uri.parse::().expect("test URI must be valid"); + Query::::try_from_uri(&uri) + .map(|Query(params)| (params.limit, params.current_page)) + .map_err(drop) }; - let (info, page) = paginate_vec(items, ¶ms); - assert_eq!(page.len(), LIMIT); - assert_eq!(page[0], 1); - assert_eq!(info.pages(), 5); - assert_eq!(info.total_items, 5); + "missing and empty values" { + "/machines" => Yields((None, None)), + "/machines?limit=¤t_page=" => Yields((None, None)), + } + + "numeric values" { + "/machines?limit=25¤t_page=3" => Yields((Some(25), Some(3))), + } + + "invalid values" { + "/machines?limit=many" => Fails, + "/machines?current_page=-1" => Fails, + } + ); } #[test] - fn paginate_empty() { - let ids: Vec = vec![]; - let params = PaginationParams { - current_page: None, - limit: None, - }; - let (info, page) = paginate_ids(&ids, ¶ms); - assert!(page.is_empty()); - assert_eq!(info.pages(), 0); - assert_eq!(info.total_items, 0); + fn pagination_params_accept_null_values() { + let params: PaginationParams = + serde_json::from_str(r#"{"limit":null,"current_page":null}"#) + .expect("null pagination fields must deserialize"); + + assert_eq!(params.limit, None); + assert_eq!(params.current_page, None); } }