#[derive(Default, std::fmt::Debug, Clone, Copy)]
pub enum ListDirection {
#[default]
Ascending,
Descending,
}
#[derive(std::fmt::Debug, Clone, Copy)]
pub struct Sort<T> {
pub by: T,
pub direction: ListDirection,
}
#[derive(Debug)]
pub struct PaginatedQueryArgs<T> {
pub first: usize,
pub after: Option<T>,
}
impl<T: Clone> Clone for PaginatedQueryArgs<T> {
fn clone(&self) -> Self {
Self {
first: self.first,
after: self.after.clone(),
}
}
}
impl<T> Default for PaginatedQueryArgs<T> {
fn default() -> Self {
Self {
first: 100,
after: None,
}
}
}
#[derive(Debug)]
pub struct Continuation<C> {
pub after: C,
pub first: usize,
}
impl<C> From<Continuation<C>> for PaginatedQueryArgs<C> {
fn from(continuation: Continuation<C>) -> Self {
Self {
first: continuation.first,
after: Some(continuation.after),
}
}
}
#[must_use = "pagination results may contain another page"]
pub struct PaginatedQueryRet<T, C> {
requested_size: usize,
entities: Vec<T>,
has_next_page: bool,
end_cursor: Option<C>,
}
#[must_use = "the next page must be handled explicitly"]
pub enum Page<T, C> {
Last {
entities: Vec<T>,
},
HasNext {
entities: Vec<T>,
next: Continuation<C>,
},
}
impl<T, C> PaginatedQueryRet<T, C> {
pub fn new(
entities: Vec<T>,
has_next_page: bool,
end_cursor: Option<C>,
requested_size: usize,
) -> Self {
Self {
requested_size,
entities,
has_next_page,
end_cursor,
}
}
pub fn requested_size(&self) -> usize {
self.requested_size
}
pub fn entities(&self) -> &[T] {
&self.entities
}
pub fn has_next_page(&self) -> bool {
self.has_next_page
}
pub fn end_cursor(&self) -> Option<&C> {
self.end_cursor.as_ref()
}
pub fn into_page(self) -> Page<T, C> {
match self.end_cursor {
Some(after) if self.has_next_page && self.requested_size > 0 => Page::HasNext {
entities: self.entities,
next: Continuation {
after,
first: self.requested_size,
},
},
_ => Page::Last {
entities: self.entities,
},
}
}
pub fn into_next_query(self) -> Option<PaginatedQueryArgs<C>> {
match self.into_page() {
Page::Last { .. } => None,
Page::HasNext { next, .. } => Some(next.into()),
}
}
pub fn into_end_cursor(self) -> Option<C> {
self.end_cursor
}
pub fn map_end_cursor<C2>(self, f: impl FnOnce(C) -> C2) -> PaginatedQueryRet<T, C2> {
PaginatedQueryRet {
requested_size: self.requested_size,
entities: self.entities,
has_next_page: self.has_next_page,
end_cursor: self.end_cursor.map(f),
}
}
pub fn map_entities<T2>(self, f: impl FnMut(T) -> T2) -> PaginatedQueryRet<T2, C> {
PaginatedQueryRet {
requested_size: self.requested_size,
entities: self.entities.into_iter().map(f).collect(),
has_next_page: self.has_next_page,
end_cursor: self.end_cursor,
}
}
pub fn try_map_entities<T2, E>(
self,
f: impl FnMut(T) -> Result<T2, E>,
) -> Result<PaginatedQueryRet<T2, C>, E> {
Ok(PaginatedQueryRet {
requested_size: self.requested_size,
entities: self.entities.into_iter().map(f).collect::<Result<_, E>>()?,
has_next_page: self.has_next_page,
end_cursor: self.end_cursor,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_query_preserves_page_size_after_entities_are_moved() {
for page_size in [1, 3, 100] {
let page = PaginatedQueryRet::new(
(0..page_size).collect(),
true,
Some(page_size - 1),
page_size,
);
let Page::HasNext { entities, next } = page.into_page() else {
panic!("another page exists");
};
assert_eq!(next.first, page_size);
assert_eq!(next.after, page_size - 1);
assert_eq!(entities.len(), page_size);
}
}
#[test]
fn next_query_size_is_independent_of_remaining_entities() {
for count in [0, 1, 3, 7, 14] {
let page =
PaginatedQueryRet::new(vec![(); count], true, Some("last-fetched-entity"), 7);
let next = page.into_next_query().expect("another page exists");
assert_eq!(next.first, 7);
assert_eq!(next.after, Some("last-fetched-entity"));
}
}
#[test]
fn last_page_has_no_next_query() {
for count in [0, 1, 7] {
let page = PaginatedQueryRet::new(vec![(); count], false, count.checked_sub(1), 7);
assert!(page.into_next_query().is_none());
}
}
#[test]
fn zero_page_size_does_not_continue_even_when_more_entities_exist() {
let page = PaginatedQueryRet::<(), usize>::new(Vec::new(), true, None, 0);
assert!(page.into_next_query().is_none());
}
#[test]
fn owned_end_cursor_is_not_a_continuation_cursor() {
let page = PaginatedQueryRet::new(vec![()], false, Some(7), 10);
assert!(!page.has_next_page());
assert_eq!(page.into_end_cursor(), Some(7));
}
#[test]
fn into_page_distinguishes_last_page_from_continuation() {
let last = PaginatedQueryRet::new(vec![1], false, Some(1), 10);
assert!(matches!(last.into_page(), Page::Last { entities } if entities == [1]));
let continued = PaginatedQueryRet::new(vec![1, 2], true, Some(2), 2);
assert!(matches!(
continued.into_page(),
Page::HasNext { entities, next }
if entities == [1, 2] && next.first == 2 && next.after == 2
));
}
#[test]
fn a_continuation_without_a_cursor_is_not_a_page() {
let page = PaginatedQueryRet::<(), usize>::new(vec![()], true, None, 10);
assert!(page.into_next_query().is_none());
}
#[test]
fn mapping_entities_keeps_requested_size_and_cursor_on_a_short_last_page() {
let page = PaginatedQueryRet::new(vec![1u32, 2, 3], false, Some("last"), 10);
let mapped = page.map_entities(|n| n.to_string());
assert_eq!(mapped.entities(), ["1", "2", "3"]);
assert_eq!(
mapped.requested_size(),
10,
"requested size must survive an entity type change"
);
assert_eq!(
mapped.end_cursor(),
Some(&"last"),
"end_cursor must survive on a page with no continuation"
);
}
#[test]
fn try_mapping_entities_keeps_metadata_and_propagates_the_first_failure() {
let page = PaginatedQueryRet::new(vec![1u32, 2], true, Some("last"), 10);
let mapped = page
.try_map_entities(|n| u8::try_from(n).map_err(|_| "unconvertible"))
.expect("all entities convert");
assert_eq!(mapped.entities(), [1u8, 2]);
assert_eq!(mapped.requested_size(), 10);
assert_eq!(mapped.end_cursor(), Some(&"last"));
let page = PaginatedQueryRet::new(vec![1u32, u32::MAX], true, Some("last"), 10);
let err = page
.try_map_entities(|n| u8::try_from(n).map_err(|_| "unconvertible"))
.err()
.expect("the out-of-range entity must abort the conversion");
assert_eq!(err, "unconvertible");
}
}