1#[derive(Default, std::fmt::Debug, Clone, Copy)]
24pub enum ListDirection {
25 #[default]
27 Ascending,
28 Descending,
29}
30
31#[derive(std::fmt::Debug, Clone, Copy)]
58pub struct Sort<T> {
59 pub by: T,
61 pub direction: ListDirection,
62}
63
64#[derive(Debug)]
90pub struct PaginatedQueryArgs<T> {
91 pub first: usize,
93 pub after: Option<T>,
95}
96
97impl<T: Clone> Clone for PaginatedQueryArgs<T> {
98 fn clone(&self) -> Self {
99 Self {
100 first: self.first,
101 after: self.after.clone(),
102 }
103 }
104}
105
106impl<T> Default for PaginatedQueryArgs<T> {
107 fn default() -> Self {
109 Self {
110 first: 100,
111 after: None,
112 }
113 }
114}
115
116#[derive(Debug)]
121pub struct Continuation<C> {
122 pub after: C,
123 pub first: usize,
124}
125
126impl<C> From<Continuation<C>> for PaginatedQueryArgs<C> {
127 fn from(continuation: Continuation<C>) -> Self {
128 Self {
129 first: continuation.first,
130 after: Some(continuation.after),
131 }
132 }
133}
134
135#[must_use = "pagination results may contain another page"]
162pub struct PaginatedQueryRet<T, C> {
163 requested_size: usize,
164 entities: Vec<T>,
165 has_next_page: bool,
166 end_cursor: Option<C>,
167}
168
169#[must_use = "the next page must be handled explicitly"]
170pub enum Page<T, C> {
171 Last {
172 entities: Vec<T>,
173 },
174 HasNext {
175 entities: Vec<T>,
176 next: Continuation<C>,
177 },
178}
179
180impl<T, C> PaginatedQueryRet<T, C> {
181 pub fn new(
183 entities: Vec<T>,
184 has_next_page: bool,
185 end_cursor: Option<C>,
186 requested_size: usize,
187 ) -> Self {
188 Self {
189 requested_size,
190 entities,
191 has_next_page,
192 end_cursor,
193 }
194 }
195
196 pub fn requested_size(&self) -> usize {
197 self.requested_size
198 }
199
200 pub fn entities(&self) -> &[T] {
201 &self.entities
202 }
203
204 pub fn has_next_page(&self) -> bool {
205 self.has_next_page
206 }
207
208 pub fn end_cursor(&self) -> Option<&C> {
209 self.end_cursor.as_ref()
210 }
211
212 pub fn into_page(self) -> Page<T, C> {
213 match self.end_cursor {
214 Some(after) if self.has_next_page && self.requested_size > 0 => Page::HasNext {
215 entities: self.entities,
216 next: Continuation {
217 after,
218 first: self.requested_size,
219 },
220 },
221 _ => Page::Last {
222 entities: self.entities,
223 },
224 }
225 }
226
227 pub fn into_next_query(self) -> Option<PaginatedQueryArgs<C>> {
228 match self.into_page() {
229 Page::Last { .. } => None,
230 Page::HasNext { next, .. } => Some(next.into()),
231 }
232 }
233
234 pub fn into_end_cursor(self) -> Option<C> {
235 self.end_cursor
236 }
237
238 pub fn map_end_cursor<C2>(self, f: impl FnOnce(C) -> C2) -> PaginatedQueryRet<T, C2> {
239 PaginatedQueryRet {
240 requested_size: self.requested_size,
241 entities: self.entities,
242 has_next_page: self.has_next_page,
243 end_cursor: self.end_cursor.map(f),
244 }
245 }
246
247 pub fn map_entities<T2>(self, f: impl FnMut(T) -> T2) -> PaginatedQueryRet<T2, C> {
248 PaginatedQueryRet {
249 requested_size: self.requested_size,
250 entities: self.entities.into_iter().map(f).collect(),
251 has_next_page: self.has_next_page,
252 end_cursor: self.end_cursor,
253 }
254 }
255
256 pub fn try_map_entities<T2, E>(
257 self,
258 f: impl FnMut(T) -> Result<T2, E>,
259 ) -> Result<PaginatedQueryRet<T2, C>, E> {
260 Ok(PaginatedQueryRet {
261 requested_size: self.requested_size,
262 entities: self.entities.into_iter().map(f).collect::<Result<_, E>>()?,
263 has_next_page: self.has_next_page,
264 end_cursor: self.end_cursor,
265 })
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn next_query_preserves_page_size_after_entities_are_moved() {
275 for page_size in [1, 3, 100] {
276 let page = PaginatedQueryRet::new(
277 (0..page_size).collect(),
278 true,
279 Some(page_size - 1),
280 page_size,
281 );
282 let Page::HasNext { entities, next } = page.into_page() else {
283 panic!("another page exists");
284 };
285
286 assert_eq!(next.first, page_size);
287 assert_eq!(next.after, page_size - 1);
288 assert_eq!(entities.len(), page_size);
289 }
290 }
291
292 #[test]
293 fn next_query_size_is_independent_of_remaining_entities() {
294 for count in [0, 1, 3, 7, 14] {
295 let page =
296 PaginatedQueryRet::new(vec![(); count], true, Some("last-fetched-entity"), 7);
297
298 let next = page.into_next_query().expect("another page exists");
299 assert_eq!(next.first, 7);
300 assert_eq!(next.after, Some("last-fetched-entity"));
301 }
302 }
303
304 #[test]
305 fn last_page_has_no_next_query() {
306 for count in [0, 1, 7] {
307 let page = PaginatedQueryRet::new(vec![(); count], false, count.checked_sub(1), 7);
308
309 assert!(page.into_next_query().is_none());
310 }
311 }
312
313 #[test]
314 fn zero_page_size_does_not_continue_even_when_more_entities_exist() {
315 let page = PaginatedQueryRet::<(), usize>::new(Vec::new(), true, None, 0);
316
317 assert!(page.into_next_query().is_none());
318 }
319
320 #[test]
321 fn owned_end_cursor_is_not_a_continuation_cursor() {
322 let page = PaginatedQueryRet::new(vec![()], false, Some(7), 10);
323
324 assert!(!page.has_next_page());
325 assert_eq!(page.into_end_cursor(), Some(7));
326 }
327
328 #[test]
329 fn into_page_distinguishes_last_page_from_continuation() {
330 let last = PaginatedQueryRet::new(vec![1], false, Some(1), 10);
331 assert!(matches!(last.into_page(), Page::Last { entities } if entities == [1]));
332
333 let continued = PaginatedQueryRet::new(vec![1, 2], true, Some(2), 2);
334 assert!(matches!(
335 continued.into_page(),
336 Page::HasNext { entities, next }
337 if entities == [1, 2] && next.first == 2 && next.after == 2
338 ));
339 }
340
341 #[test]
342 fn a_continuation_without_a_cursor_is_not_a_page() {
343 let page = PaginatedQueryRet::<(), usize>::new(vec![()], true, None, 10);
344
345 assert!(page.into_next_query().is_none());
346 }
347
348 #[test]
349 fn mapping_entities_keeps_requested_size_and_cursor_on_a_short_last_page() {
350 let page = PaginatedQueryRet::new(vec![1u32, 2, 3], false, Some("last"), 10);
351
352 let mapped = page.map_entities(|n| n.to_string());
353
354 assert_eq!(mapped.entities(), ["1", "2", "3"]);
355 assert_eq!(
356 mapped.requested_size(),
357 10,
358 "requested size must survive an entity type change"
359 );
360 assert_eq!(
361 mapped.end_cursor(),
362 Some(&"last"),
363 "end_cursor must survive on a page with no continuation"
364 );
365 }
366
367 #[test]
368 fn try_mapping_entities_keeps_metadata_and_propagates_the_first_failure() {
369 let page = PaginatedQueryRet::new(vec![1u32, 2], true, Some("last"), 10);
370 let mapped = page
371 .try_map_entities(|n| u8::try_from(n).map_err(|_| "unconvertible"))
372 .expect("all entities convert");
373 assert_eq!(mapped.entities(), [1u8, 2]);
374 assert_eq!(mapped.requested_size(), 10);
375 assert_eq!(mapped.end_cursor(), Some(&"last"));
376
377 let page = PaginatedQueryRet::new(vec![1u32, u32::MAX], true, Some("last"), 10);
378 let err = page
379 .try_map_entities(|n| u8::try_from(n).map_err(|_| "unconvertible"))
380 .err()
381 .expect("the out-of-range entity must abort the conversion");
382 assert_eq!(err, "unconvertible");
383 }
384}