Skip to main content

es_entity/
pagination.rs

1//! Control and customize the query execution and its response.
2
3/// Controls the sorting order when listing the entities from the database
4///
5/// `ListDirection` enum is used to specify order when listing entities from the database using [`EsRepo`][crate::EsRepo]
6/// generated functions like `list_by`, `list_for` and `list_for_filters`. Has two variants: `Ascending` and `Descending`
7///
8/// # Examples
9///
10/// ```ignore
11/// // List users by ID in ascending order (oldest first)
12/// let paginated_users_asc = users.list_by_id(
13///     PaginatedQueryArgs { first: 5, after: None },
14///     ListDirection::Ascending, // or just Default::default()
15/// ).await?
16///
17/// // List users by name in descending order (Z to A)
18/// let paginated_users_desc = users.list_by_name(
19///     PaginatedQueryArgs { first: 5, after: None },
20///     ListDirection::Descending,
21/// ).await?
22/// ```
23#[derive(Default, std::fmt::Debug, Clone, Copy)]
24pub enum ListDirection {
25    /// Sets the default direction variant to `Ascending`
26    #[default]
27    Ascending,
28    Descending,
29}
30
31/// Structure to sort entities on a specific field when listing from database
32///
33/// Sort enum is used to specify the sorting order and the field to sort the entities by when listing them using `list_for_filters`
34/// generated by [`EsRepo`][crate::EsRepo]. It is generated automatically for all fields which have `list_by` option. It encapsulates two fields,
35/// first is `by` to specify the field name, second is `direction` which takes [`ListDirection`]
36///
37/// # Example
38///
39/// ```ignore
40/// let result = users.list_for_filters(
41///     UserFilters {
42///         name: Some("Murphy".to_string()),
43///     },
44///     Sort {
45///         // `UserSortBy::Id` and `UserSortBy::CreatedAt` are created by default,
46///         // other columns need `list_by` to sort by
47///         by: UserSortBy::Id,
48///         direction: ListDirection::Descending,
49///     },
50///     PaginatedQueryArgs {
51///         first: 10,
52///         after: Default::default(),
53///     },
54/// )
55/// .await?;
56/// ```
57#[derive(std::fmt::Debug, Clone, Copy)]
58pub struct Sort<T> {
59    // T parameter represents the field
60    pub by: T,
61    pub direction: ListDirection,
62}
63
64/// A cursor-based pagination structure for efficiently paginating through large datasets
65///
66/// The `PaginatedQueryArgs<T>` encapsulates a `first` field that specifies the count of entities to fetch per query, and an optional `after` field
67/// that specifies the cursor to start from for the current query. The `<T>` parameter represents cursor type, which depends on the sorting field.
68/// A field's cursor is generated by [`EsRepo`][crate::EsRepo] if it has `list_by` option.
69///
70/// # Examples
71///
72/// ```ignore
73/// // Initial query - fetch first 10 users after an existing user id
74/// let query_args = PaginatedQueryArgs {
75///     first: 10,
76///     after: Some(user_cursor::UserByIdCursor {
77///         id: some_existing_user_id // assume this variable exists
78///     }),
79/// };
80///
81/// // Execute query using `query_args` argument of `PaginatedQueryArgs` type
82/// let result = users.list_by_id(query_args, ListDirection::Ascending).await?;
83///
84/// // Continue pagination using the updated `next_query_args` of `PaginatedQueryArgs` type
85/// if let Some(next_query_args) = result.into_next_query() {
86///     let next_result = users.list_by_id(next_query_args, ListDirection::Ascending).await?;
87/// }
88/// ```
89#[derive(Debug)]
90pub struct PaginatedQueryArgs<T> {
91    /// Specifies the number of entities to fetch per query
92    pub first: usize,
93    /// Specifies the cursor/marker to start from for current query
94    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    /// Default value fetches first 100 entities
108    fn default() -> Self {
109        Self {
110            first: 100,
111            after: None,
112        }
113    }
114}
115
116/// A continuation to the next page, carrying the cursor it resumes from.
117///
118/// Unlike [`PaginatedQueryArgs`], whose cursor is optional because a query may
119/// start from the beginning, a continuation always has one.
120#[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/// Return type for paginated queries containing entities and pagination metadata
136///
137/// `PaginatedQueryRet` contains the fetched entities and utilities for continuing pagination.
138/// Returned by the [`EsRepo`][crate::EsRepo] functions like `list_by`, `list_for` and `list_for_filters`.
139/// Used with [`PaginatedQueryArgs`] to perform consistent and efficient pagination
140///
141/// # Examples
142///
143/// ```ignore
144/// let mut query = PaginatedQueryArgs { first: 10, after: None };
145/// let mut all_users = Vec::new();
146///
147/// loop {
148///     let result = users.list_by_id(query, ListDirection::Ascending).await?;
149///     match result.into_page() {
150///         Page::Last { entities } => {
151///             all_users.extend(entities);
152///             break;
153///         }
154///         Page::HasNext { entities, next } => {
155///             all_users.extend(entities);
156///             query = next;
157///         }
158///     }
159/// }
160/// ```
161#[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    /// `requested_size` must be the `first` that produced this page, not `entities.len()`.
182    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}