177 lines
5.8 KiB
Rust
177 lines
5.8 KiB
Rust
// file: ks-store/src/contracts/pagination.rs
|
|
// version: 5
|
|
|
|
//! Backend-neutral pagination contracts for repository operations.
|
|
|
|
/// Default page size for repository list operations.
|
|
pub const DEFAULT_PAGE_SIZE: u16 = 100;
|
|
/// Maximum page size for repository list operations.
|
|
pub const MAX_PAGE_SIZE: u16 = 500;
|
|
/// Maximum serialized opaque cursor length accepted by repository list operations.
|
|
pub const MAX_PAGE_CURSOR_LENGTH: usize = 512;
|
|
|
|
/// Sort direction for repository list operations.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub enum SortDirection {
|
|
/// Sort values in ascending order.
|
|
Asc,
|
|
/// Sort values in descending order.
|
|
Desc,
|
|
}
|
|
|
|
/// Stable cursor request contract for repository list operations.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct PageRequest {
|
|
/// Maximum number of rows to return.
|
|
pub limit: u16,
|
|
/// Optional opaque cursor returned by the previous page.
|
|
pub cursor: std::option::Option<std::string::String>,
|
|
}
|
|
|
|
impl crate::PageRequest {
|
|
/// Builds a page request after validating the limit and opaque cursor bounds.
|
|
pub fn new(
|
|
limit: u16,
|
|
cursor: std::option::Option<std::string::String>,
|
|
) -> ks_core::Result<Self> {
|
|
if limit == 0 {
|
|
return std::result::Result::Err(ks_core::Error::db(
|
|
"page limit must be greater than zero",
|
|
));
|
|
}
|
|
if limit > crate::MAX_PAGE_SIZE {
|
|
return std::result::Result::Err(ks_core::Error::db(
|
|
"page limit exceeds maximum page size",
|
|
));
|
|
}
|
|
let cursor_value = cursor.and_then(|value| {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return std::option::Option::None;
|
|
}
|
|
return std::option::Option::Some(trimmed.to_string());
|
|
});
|
|
if cursor_value
|
|
.as_deref()
|
|
.is_some_and(|value| return value.len() > crate::MAX_PAGE_CURSOR_LENGTH)
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::db(
|
|
"page cursor exceeds maximum encoded length",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(Self { limit, cursor: cursor_value });
|
|
}
|
|
|
|
/// Builds the default first page request.
|
|
pub fn first_page() -> Self {
|
|
return Self {
|
|
limit: crate::DEFAULT_PAGE_SIZE,
|
|
cursor: std::option::Option::None,
|
|
};
|
|
}
|
|
|
|
/// Builds a request continuing after an opaque cursor returned by a previous page.
|
|
pub fn after(
|
|
limit: u16,
|
|
cursor: impl std::convert::Into<std::string::String>,
|
|
) -> ks_core::Result<Self> {
|
|
return Self::new(limit, std::option::Option::Some(cursor.into()));
|
|
}
|
|
}
|
|
|
|
/// One bounded page of rows and its continuation cursor.
|
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct PageSlice<T> {
|
|
/// Rows returned for this page.
|
|
pub rows: std::vec::Vec<T>,
|
|
/// Opaque cursor for the next page when additional rows exist.
|
|
pub next_cursor: std::option::Option<std::string::String>,
|
|
}
|
|
|
|
impl<T> crate::PageSlice<T> {
|
|
/// Builds one page from already bounded rows and an optional continuation cursor.
|
|
pub fn new(
|
|
rows: std::vec::Vec<T>,
|
|
next_cursor: std::option::Option<std::string::String>,
|
|
) -> Self {
|
|
return Self { rows, next_cursor };
|
|
}
|
|
|
|
/// Returns true when this page has no continuation cursor.
|
|
pub fn is_last_page(&self) -> bool {
|
|
return self.next_cursor.is_none();
|
|
}
|
|
}
|
|
|
|
/// One bounded page with a refreshed total row count for the same filter.
|
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct CountedPageSlice<T> {
|
|
/// Rows returned for this page.
|
|
pub rows: std::vec::Vec<T>,
|
|
/// Opaque cursor for the next page when additional rows exist.
|
|
pub next_cursor: std::option::Option<std::string::String>,
|
|
/// Total number of rows matching the filter at query time.
|
|
pub total_rows: u64,
|
|
}
|
|
|
|
impl<T> crate::CountedPageSlice<T> {
|
|
/// Builds one counted page.
|
|
pub fn new(
|
|
rows: std::vec::Vec<T>,
|
|
next_cursor: std::option::Option<std::string::String>,
|
|
total_rows: u64,
|
|
) -> Self {
|
|
return Self { rows, next_cursor, total_rows };
|
|
}
|
|
|
|
/// Returns true when this page has no continuation cursor.
|
|
pub fn is_last_page(&self) -> bool {
|
|
return self.next_cursor.is_none();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn page_request_rejects_zero_limit() {
|
|
let result = crate::PageRequest::new(0, std::option::Option::None);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn page_request_rejects_limit_above_maximum() {
|
|
let result = crate::PageRequest::new(crate::MAX_PAGE_SIZE + 1, std::option::Option::None);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn page_request_rejects_oversized_cursor() {
|
|
let result = crate::PageRequest::after(10, "x".repeat(crate::MAX_PAGE_CURSOR_LENGTH + 1));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn first_page_uses_default_limit_without_cursor() {
|
|
let request = crate::PageRequest::first_page();
|
|
assert_eq!(request.limit, crate::DEFAULT_PAGE_SIZE);
|
|
assert!(request.cursor.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn page_slice_reports_last_page_from_cursor_presence() {
|
|
let page = crate::PageSlice::new(vec![1_u32], std::option::Option::None);
|
|
assert!(page.is_last_page());
|
|
}
|
|
|
|
#[test]
|
|
fn counted_page_preserves_total_rows_and_cursor_state() {
|
|
let page = crate::CountedPageSlice::new(
|
|
vec![1_u32, 2_u32],
|
|
std::option::Option::Some("cursor".to_string()),
|
|
501,
|
|
);
|
|
assert_eq!(page.total_rows, 501);
|
|
assert!(!page.is_last_page());
|
|
}
|
|
}
|