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 result.has_next_page {
86/// let next_query_args = PaginatedQueryArgs {
87/// first: 10,
88/// after: result.end_cursor, // Use cursor from previous result to update `after`
89/// };
90/// let next_result = users.list_by_id(next_query_args, ListDirection::Ascending).await?;
91/// }
92/// ```
93#[derive(Debug)]
94pub struct PaginatedQueryArgs<T: std::fmt::Debug> {
95 /// Specifies the number of entities to fetch per query
96 pub first: usize,
97 /// Specifies the cursor/marker to start from for current query
98 pub after: Option<T>,
99}
100
101impl<T: std::fmt::Debug> Clone for PaginatedQueryArgs<T>
102where
103 T: Clone,
104{
105 fn clone(&self) -> Self {
106 Self {
107 first: self.first,
108 after: self.after.clone(),
109 }
110 }
111}
112
113impl<T: std::fmt::Debug> Default for PaginatedQueryArgs<T> {
114 /// Default value fetches first 100 entities
115 fn default() -> Self {
116 Self {
117 first: 100,
118 after: None,
119 }
120 }
121}
122
123/// Return type for paginated queries containing entities and pagination metadata
124///
125/// `PaginatedQueryRet` contains the fetched entities and utilities for continuing pagination.
126/// Returned by the [`EsRepo`][crate::EsRepo] functions like `list_by`, `list_for` and `list_for_filters`.
127/// Used with [`PaginatedQueryArgs`] to perform consistent and efficient pagination
128///
129/// # Examples
130///
131/// ```ignore
132/// let mut query = PaginatedQueryArgs { first: 10, after: None };
133/// let mut all_users = Vec::new();
134///
135/// loop {
136/// let result = users.list_by_id(query, ListDirection::Ascending).await?;
137/// let (chunk, next) = result.into_parts();
138/// all_users.extend(chunk);
139///
140/// match next {
141/// Some(next_query) => query = next_query,
142/// None => break,
143/// }
144/// }
145/// ```
146pub struct PaginatedQueryRet<T, C> {
147 requested_size: usize,
148 entities: Vec<T>,
149 /// [bool] for indicating if the list has been exhausted or more entities can be fetched
150 pub has_next_page: bool,
151 /// cursor on the last entity fetched to continue paginated queries.
152 pub end_cursor: Option<C>,
153}
154
155impl<T, C> PaginatedQueryRet<T, C> {
156 /// `requested_size` must be the `first` that produced this page, not `entities.len()`.
157 pub fn new(
158 entities: Vec<T>,
159 has_next_page: bool,
160 end_cursor: Option<C>,
161 requested_size: usize,
162 ) -> Self {
163 Self {
164 requested_size,
165 entities,
166 has_next_page,
167 end_cursor,
168 }
169 }
170
171 pub fn requested_size(&self) -> usize {
172 self.requested_size
173 }
174
175 pub fn entities(&self) -> &[T] {
176 &self.entities
177 }
178
179 pub fn into_parts(self) -> (Vec<T>, Option<PaginatedQueryArgs<C>>)
180 where
181 C: std::fmt::Debug,
182 {
183 let continuation = if self.has_next_page && self.requested_size > 0 {
184 Some(PaginatedQueryArgs {
185 first: self.requested_size,
186 after: self.end_cursor,
187 })
188 } else {
189 None
190 };
191 (self.entities, continuation)
192 }
193
194 pub fn into_next_query(self) -> Option<PaginatedQueryArgs<C>>
195 where
196 C: std::fmt::Debug,
197 {
198 self.into_parts().1
199 }
200
201 pub fn map_end_cursor<C2>(self, f: impl FnOnce(C) -> C2) -> PaginatedQueryRet<T, C2> {
202 PaginatedQueryRet {
203 requested_size: self.requested_size,
204 entities: self.entities,
205 has_next_page: self.has_next_page,
206 end_cursor: self.end_cursor.map(f),
207 }
208 }
209
210 pub fn map_entities<T2>(self, f: impl FnMut(T) -> T2) -> PaginatedQueryRet<T2, C> {
211 PaginatedQueryRet {
212 requested_size: self.requested_size,
213 entities: self.entities.into_iter().map(f).collect(),
214 has_next_page: self.has_next_page,
215 end_cursor: self.end_cursor,
216 }
217 }
218
219 pub fn try_map_entities<T2, E>(
220 self,
221 f: impl FnMut(T) -> Result<T2, E>,
222 ) -> Result<PaginatedQueryRet<T2, C>, E> {
223 Ok(PaginatedQueryRet {
224 requested_size: self.requested_size,
225 entities: self.entities.into_iter().map(f).collect::<Result<_, E>>()?,
226 has_next_page: self.has_next_page,
227 end_cursor: self.end_cursor,
228 })
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn next_query_preserves_page_size_after_entities_are_moved() {
238 for page_size in [1, 3, 100] {
239 let page = PaginatedQueryRet::new(
240 (0..page_size).collect(),
241 true,
242 Some(page_size - 1),
243 page_size,
244 );
245 let (collected, next) = page.into_parts();
246 let next = next.expect("another page exists");
247
248 assert_eq!(next.first, page_size);
249 assert_eq!(next.after, Some(page_size - 1));
250 assert_eq!(collected.len(), page_size);
251 }
252 }
253
254 #[test]
255 fn next_query_size_is_independent_of_remaining_entities() {
256 for count in [0, 1, 3, 7, 14] {
257 let page =
258 PaginatedQueryRet::new(vec![(); count], true, Some("last-fetched-entity"), 7);
259
260 let next = page.into_next_query().expect("another page exists");
261 assert_eq!(next.first, 7);
262 assert_eq!(next.after, Some("last-fetched-entity"));
263 }
264 }
265
266 #[test]
267 fn last_page_has_no_next_query() {
268 for count in [0, 1, 7] {
269 let page = PaginatedQueryRet::new(vec![(); count], false, count.checked_sub(1), 7);
270
271 assert!(page.into_next_query().is_none());
272 }
273 }
274
275 #[test]
276 fn zero_page_size_does_not_continue_even_when_more_entities_exist() {
277 let page = PaginatedQueryRet::<(), usize>::new(Vec::new(), true, None, 0);
278
279 assert!(page.into_next_query().is_none());
280 }
281
282 #[test]
283 fn into_parts_returns_entities_together_with_the_next_query() {
284 let page = PaginatedQueryRet::new(vec![(); 7], true, Some(6), 10);
285 assert_eq!(page.requested_size(), 10);
286
287 let (entities, next) = page.into_parts();
288 assert_eq!(entities.len(), 7);
289 let next = next.expect("another page exists");
290 assert_eq!(next.first, 10);
291 assert_eq!(next.after, Some(6));
292 }
293
294 #[test]
295 fn mapping_entities_keeps_requested_size_and_cursor_on_a_short_last_page() {
296 let page = PaginatedQueryRet::new(vec![1u32, 2, 3], false, Some("last"), 10);
297
298 let mapped = page.map_entities(|n| n.to_string());
299
300 assert_eq!(mapped.entities(), ["1", "2", "3"]);
301 assert_eq!(
302 mapped.requested_size(),
303 10,
304 "requested size must survive an entity type change"
305 );
306 assert_eq!(
307 mapped.end_cursor,
308 Some("last"),
309 "end_cursor must survive on a page with no continuation"
310 );
311 }
312
313 #[test]
314 fn try_mapping_entities_keeps_metadata_and_propagates_the_first_failure() {
315 let page = PaginatedQueryRet::new(vec![1u32, 2], true, Some("last"), 10);
316 let mapped = page
317 .try_map_entities(|n| u8::try_from(n).map_err(|_| "unconvertible"))
318 .expect("all entities convert");
319 assert_eq!(mapped.entities(), [1u8, 2]);
320 assert_eq!(mapped.requested_size(), 10);
321 assert_eq!(mapped.end_cursor, Some("last"));
322
323 let page = PaginatedQueryRet::new(vec![1u32, u32::MAX], true, Some("last"), 10);
324 let err = page
325 .try_map_entities(|n| u8::try_from(n).map_err(|_| "unconvertible"))
326 .err()
327 .expect("the out-of-range entity must abort the conversion");
328 assert_eq!(err, "unconvertible");
329 }
330}