821537186e
- Reorganized import statements in adapter tests for better readability - Replaced or_insert_with(Vec::new) with or_default() in test closures - Updated Cargo.lock with new dependency versions and checksums - Added TLS features to tonic dependency configuration - Included sqlx, chrono, and uuid dependencies with specific features - Added jsonwebtoken and arc-swap as project dependencies - Reformatted assertion statements to comply with line length limits - Adjusted base64 import order in engine codec module - Updated protobuf include statement formatting
115 lines
3.2 KiB
Rust
115 lines
3.2 KiB
Rust
//! Cursor-based pagination helpers for repository queries.
|
|
//!
|
|
//! UUID v7 IDs are time-ordered, so `WHERE id < $cursor ORDER BY id DESC`
|
|
//! naturally yields reverse-chronological pages without OFFSET.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
/// Default number of items per page.
|
|
pub const DEFAULT_PAGE_SIZE: i64 = 50;
|
|
/// Hard upper bound on page size to prevent abuse.
|
|
pub const MAX_PAGE_SIZE: i64 = 100;
|
|
|
|
/// Generic cursor-based page response.
|
|
///
|
|
/// Returned by list operations in the repo layer. The `next_cursor`
|
|
/// is the last item's UUID — pass it as `before` in the next request.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CursorPage<T> {
|
|
/// Items in this page (ordered by `id DESC`).
|
|
pub items: Vec<T>,
|
|
/// Opaque cursor for the next page. `None` when no more results exist.
|
|
pub next_cursor: Option<Uuid>,
|
|
/// Whether there are more results beyond this page.
|
|
pub has_more: bool,
|
|
}
|
|
|
|
impl<T> CursorPage<T> {
|
|
/// Build a page from a raw result set that may contain one extra row.
|
|
///
|
|
/// If `raw_items.len() > limit`, the extra row is dropped and
|
|
/// `has_more` is set to `true`.
|
|
pub fn from_raw(mut raw_items: Vec<T>, limit: i64, get_id: impl Fn(&T) -> Uuid) -> Self {
|
|
let has_more = raw_items.len() > limit as usize;
|
|
if has_more {
|
|
raw_items.truncate(limit as usize);
|
|
}
|
|
let next_cursor = if has_more {
|
|
raw_items.last().map(get_id)
|
|
} else {
|
|
None
|
|
};
|
|
Self {
|
|
items: raw_items,
|
|
next_cursor,
|
|
has_more,
|
|
}
|
|
}
|
|
|
|
/// Empty page (no results).
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
items: Vec::new(),
|
|
next_cursor: None,
|
|
has_more: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clamp a caller-requested limit to `[1, MAX_PAGE_SIZE]`, defaulting to `DEFAULT_PAGE_SIZE`.
|
|
pub fn clamp_limit(limit: Option<i64>) -> i64 {
|
|
match limit {
|
|
Some(n) if n < 1 => DEFAULT_PAGE_SIZE,
|
|
Some(n) if n > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
|
|
Some(n) => n,
|
|
None => DEFAULT_PAGE_SIZE,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_clamp_limit_none() {
|
|
assert_eq!(clamp_limit(None), DEFAULT_PAGE_SIZE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clamp_limit_zero() {
|
|
assert_eq!(clamp_limit(Some(0)), DEFAULT_PAGE_SIZE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clamp_limit_negative() {
|
|
assert_eq!(clamp_limit(Some(-5)), DEFAULT_PAGE_SIZE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clamp_limit_over_max() {
|
|
assert_eq!(clamp_limit(Some(200)), MAX_PAGE_SIZE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clamp_limit_valid() {
|
|
assert_eq!(clamp_limit(Some(25)), 25);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_page_empty() {
|
|
let page: CursorPage<String> = CursorPage::empty();
|
|
assert!(page.items.is_empty());
|
|
assert!(!page.has_more);
|
|
assert!(page.next_cursor.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_page_from_raw_no_overflow() {
|
|
let items = vec!["a".to_string(), "b".to_string()];
|
|
let page = CursorPage::from_raw(items, 5, |_| Uuid::nil());
|
|
assert_eq!(page.items.len(), 2);
|
|
assert!(!page.has_more);
|
|
}
|
|
}
|