Skip to main content

aster_forge_api/
cursor.rs

1//! Cursor queries, response shapes, completeness checks and overfetch trimming.
2use crate::{ApiError, ApiSchema, Result};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5#[cfg(all(debug_assertions, feature = "openapi"))]
6use utoipa::{IntoParams, ToSchema};
7/// Cursor query for resources ordered by creation time and numeric id.
8#[derive(Debug, Clone, Copy, Default, Deserialize)]
9#[cfg_attr(
10    all(debug_assertions, feature = "openapi"),
11    derive(IntoParams, ToSchema)
12)]
13pub struct CreatedAtCursorQuery {
14    /// Cursor creation timestamp.
15    pub after_created_at: Option<DateTime<Utc>>,
16    /// Cursor numeric id used as a stable tie breaker.
17    pub after_id: Option<i64>,
18}
19
20/// Cursor query for resources ordered by update time and numeric id.
21#[derive(Debug, Clone, Copy, Default, Deserialize)]
22#[cfg_attr(
23    all(debug_assertions, feature = "openapi"),
24    derive(IntoParams, ToSchema)
25)]
26pub struct UpdatedAtCursorQuery {
27    /// Cursor update timestamp.
28    pub after_updated_at: Option<DateTime<Utc>>,
29    /// Cursor numeric id used as a stable tie breaker.
30    pub after_id: Option<i64>,
31}
32
33/// Serialized cursor page response.
34#[derive(Debug, Clone, Serialize)]
35#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
36pub struct CursorPage<T: Serialize + ApiSchema, C: Serialize + ApiSchema> {
37    /// Items in the current page.
38    pub items: Vec<T>,
39    /// Total number of items matching the query.
40    pub total: u64,
41    /// Effective page size.
42    pub limit: u64,
43    /// Cursor that can be sent back to fetch the next page.
44    pub next_cursor: Option<C>,
45}
46
47impl<T: Serialize + ApiSchema, C: Serialize + ApiSchema> CursorPage<T, C> {
48    /// Creates a new cursor page.
49    pub fn new(items: Vec<T>, total: u64, limit: u64, next_cursor: Option<C>) -> Self {
50        Self {
51            items,
52            total,
53            limit,
54            next_cursor,
55        }
56    }
57}
58
59/// Numeric id cursor for resources sorted by id.
60#[derive(Debug, Clone, Serialize)]
61#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
62pub struct IdCursor {
63    /// Cursor id.
64    pub id: i64,
65}
66
67/// String value plus numeric id cursor for resources sorted by text and then id.
68#[derive(Debug, Clone, Serialize)]
69#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
70pub struct StringIdCursor {
71    /// Cursor string value.
72    pub value: String,
73    /// Cursor numeric id used as a stable tie breaker.
74    pub id: i64,
75}
76
77/// Sort-order, name, and numeric id cursor for manually ordered named resources.
78#[derive(Debug, Clone, Serialize)]
79#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
80pub struct SortOrderNameIdCursor {
81    /// Cursor sort order value.
82    pub sort_order: i32,
83    /// Cursor display or storage name.
84    pub name: String,
85    /// Cursor numeric id used as a stable tie breaker.
86    pub id: i64,
87}
88
89/// Enabled flag, priority, and numeric id cursor for prioritized toggle-like resources.
90#[derive(Debug, Clone, Serialize)]
91#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
92pub struct EnabledPriorityIdCursor {
93    /// Cursor enabled flag.
94    pub enabled: bool,
95    /// Cursor priority value.
96    pub priority: i32,
97    /// Cursor numeric id used as a stable tie breaker.
98    pub id: i64,
99}
100
101/// Timestamp plus numeric id cursor for resources sorted by time and then id.
102#[derive(Debug, Clone, Serialize)]
103#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
104pub struct DateTimeIdCursor {
105    /// Cursor timestamp.
106    #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
107    pub value: DateTime<Utc>,
108    /// Cursor numeric id used as a stable tie breaker.
109    pub id: i64,
110}
111
112/// Timestamp plus string id cursor for resources sorted by time and then string id.
113#[derive(Debug, Clone, Serialize)]
114#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
115pub struct DateTimeStringCursor {
116    /// Cursor timestamp.
117    #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
118    pub value: DateTime<Utc>,
119    /// Cursor string id used as a stable tie breaker.
120    pub id: String,
121}
122
123/// Validates a timestamp plus numeric id cursor pair.
124///
125/// # Errors
126///
127/// Returns an error when only one cursor component is present or the id is not positive.
128pub fn parse_datetime_id_cursor(
129    value: Option<DateTime<Utc>>,
130    id: Option<i64>,
131    value_name: &str,
132) -> Result<Option<(DateTime<Utc>, i64)>> {
133    match (value, id) {
134        (None, None) => Ok(None),
135        (Some(value), Some(id)) if id > 0 => Ok(Some((value, id))),
136        (Some(_), Some(_)) => Err(ApiError::new(format!(
137            "{value_name} cursor id must be positive",
138        ))),
139        _ => Err(ApiError::new(format!(
140            "{value_name} cursor requires both value and id",
141        ))),
142    }
143}
144
145/// Validates a timestamp plus string id cursor pair.
146///
147/// # Errors
148///
149/// Returns an error when only one cursor component is present or the string id is blank.
150pub fn parse_datetime_string_cursor(
151    value: Option<DateTime<Utc>>,
152    id: Option<String>,
153    value_name: &str,
154) -> Result<Option<(DateTime<Utc>, String)>> {
155    match (value, id) {
156        (None, None) => Ok(None),
157        (Some(value), Some(id)) if !id.trim().is_empty() => Ok(Some((value, id))),
158        (Some(_), Some(_)) => Err(ApiError::new(format!(
159            "{value_name} cursor id must not be empty",
160        ))),
161        _ => Err(ApiError::new(format!(
162            "{value_name} cursor requires both value and id",
163        ))),
164    }
165}
166
167/// Validates an optional positive numeric id cursor.
168///
169/// # Errors
170///
171/// Returns an error when the supplied id is zero or negative.
172pub fn parse_id_cursor(id: Option<i64>, value_name: &str) -> Result<Option<i64>> {
173    match id {
174        None => Ok(None),
175        Some(id) if id > 0 => Ok(Some(id)),
176        Some(_) => Err(ApiError::new(format!(
177            "{value_name} cursor id must be positive",
178        ))),
179    }
180}
181
182/// Validates a string value plus numeric id cursor pair.
183///
184/// # Errors
185///
186/// Returns an error when the tuple is incomplete, the string value is blank, or the id is not
187/// positive.
188pub fn parse_string_id_cursor(
189    value: Option<String>,
190    id: Option<i64>,
191    value_name: &str,
192) -> Result<Option<(String, i64)>> {
193    match (value, id) {
194        (None, None) => Ok(None),
195        (Some(value), Some(id)) if !value.trim().is_empty() && id > 0 => Ok(Some((value, id))),
196        (Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
197            "{value_name} cursor id must be positive",
198        ))),
199        (Some(_), Some(_)) => Err(ApiError::new(format!(
200            "{value_name} cursor value must not be empty",
201        ))),
202        _ => Err(ApiError::new(format!(
203            "{value_name} cursor requires both value and id",
204        ))),
205    }
206}
207
208/// Validates a sort-order, name, and numeric id cursor tuple.
209///
210/// # Errors
211///
212/// Returns an error when the tuple is incomplete, the name is blank, or the id is not positive.
213pub fn parse_sort_order_name_id_cursor(
214    sort_order: Option<i32>,
215    name: Option<String>,
216    id: Option<i64>,
217    value_name: &str,
218) -> Result<Option<(i32, String, i64)>> {
219    match (sort_order, name, id) {
220        (None, None, None) => Ok(None),
221        (Some(sort_order), Some(name), Some(id)) if !name.trim().is_empty() && id > 0 => {
222            Ok(Some((sort_order, name, id)))
223        }
224        (Some(_), Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
225            "{value_name} cursor id must be positive",
226        ))),
227        (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
228            "{value_name} cursor name must not be empty",
229        ))),
230        _ => Err(ApiError::new(format!(
231            "{value_name} cursor requires sort_order, name, and id",
232        ))),
233    }
234}
235
236/// Validates an enabled flag, priority, and numeric id cursor tuple.
237///
238/// # Errors
239///
240/// Returns an error when the tuple is incomplete or the id is not positive.
241pub fn parse_enabled_priority_id_cursor(
242    enabled: Option<bool>,
243    priority: Option<i32>,
244    id: Option<i64>,
245    value_name: &str,
246) -> Result<Option<(bool, i32, i64)>> {
247    match (enabled, priority, id) {
248        (None, None, None) => Ok(None),
249        (Some(enabled), Some(priority), Some(id)) if id > 0 => Ok(Some((enabled, priority, id))),
250        (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
251            "{value_name} cursor id must be positive",
252        ))),
253        _ => Err(ApiError::new(format!(
254            "{value_name} cursor requires enabled, priority, and id",
255        ))),
256    }
257}
258
259/// Repository page slice returned after fetching one extra row to detect a next page.
260#[derive(Debug, Clone)]
261pub struct CursorSlice<T> {
262    /// Items to expose to the caller after overfetch trimming.
263    pub items: Vec<T>,
264    /// Total number of items matching the query.
265    pub total: u64,
266    /// Whether the repository found at least one item beyond the requested limit.
267    pub has_more: bool,
268}
269
270impl<T> CursorSlice<T> {
271    /// Creates an empty slice with a known total count.
272    #[must_use]
273    pub fn empty(total: u64) -> Self {
274        Self {
275            items: Vec::new(),
276            total,
277            has_more: false,
278        }
279    }
280
281    /// Builds a cursor slice from a repository result that fetched `limit + 1` rows.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error when the item count or truncation limit cannot be represented by the
286    /// required integer type on the current platform.
287    pub fn from_overfetch(mut items: Vec<T>, total: u64, limit: u64) -> Result<Self> {
288        let item_count = u64::try_from(items.len())
289            .map_err(|_| ApiError::new("cursor slice item count is too large"))?;
290        let has_more = item_count > limit;
291        if has_more {
292            let limit =
293                usize::try_from(limit).map_err(|_| ApiError::new("cursor limit is too large"))?;
294            items.truncate(limit);
295        }
296        Ok(Self {
297            items,
298            total,
299            has_more,
300        })
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serde_json::json;
308
309    #[test]
310    fn cursor_tuple_presence_combinations_and_integer_extremes_are_checked() {
311        for mask in 0..8 {
312            let first = (mask & 1 != 0).then_some(i32::MIN);
313            let name = (mask & 2 != 0).then(|| "名字".to_string());
314            let id = (mask & 4 != 0).then_some(i64::MAX);
315            assert_eq!(
316                parse_sort_order_name_id_cursor(first, name, id, "resource").is_ok(),
317                mask == 0 || mask == 7
318            );
319            let enabled = (mask & 1 != 0).then_some(false);
320            let priority = (mask & 2 != 0).then_some(i32::MAX);
321            assert_eq!(
322                parse_enabled_priority_id_cursor(enabled, priority, id, "resource").is_ok(),
323                mask == 0 || mask == 7
324            );
325        }
326        for id in [i64::MIN, -1, 0] {
327            assert!(parse_id_cursor(Some(id), "resource").is_err());
328            assert!(parse_string_id_cursor(Some("value".into()), Some(id), "resource").is_err());
329            assert!(
330                parse_sort_order_name_id_cursor(
331                    Some(0),
332                    Some("value".into()),
333                    Some(id),
334                    "resource"
335                )
336                .is_err()
337            );
338            assert!(
339                parse_enabled_priority_id_cursor(Some(false), Some(0), Some(id), "resource")
340                    .is_err()
341            );
342        }
343        assert_eq!(
344            parse_id_cursor(Some(i64::MAX), "resource").unwrap(),
345            Some(i64::MAX)
346        );
347        for blank in ["", " ", "\t\n", "\u{2003}"] {
348            assert!(parse_string_id_cursor(Some(blank.into()), Some(1), "resource").is_err());
349            assert!(
350                parse_datetime_string_cursor(
351                    Some(DateTime::UNIX_EPOCH),
352                    Some(blank.into()),
353                    "resource"
354                )
355                .is_err()
356            );
357        }
358        let value = " 名字 ".to_string();
359        assert_eq!(
360            parse_string_id_cursor(Some(value.clone()), Some(1), "resource").unwrap(),
361            Some((value, 1))
362        );
363    }
364
365    #[test]
366    fn overfetch_handles_zero_limit_empty_items_and_large_limits_without_truncation() {
367        for (items, limit, expected, has_more) in [
368            (vec![], 0, vec![], false),
369            (vec![1], 0, vec![], true),
370            (vec![1, 2], 2, vec![1, 2], false),
371            (vec![1, 2], 1, vec![1], true),
372            (vec![1, 2], u64::MAX, vec![1, 2], false),
373        ] {
374            let slice = CursorSlice::from_overfetch(items, u64::MAX, limit).unwrap();
375            assert_eq!(
376                (slice.items, slice.total, slice.has_more),
377                (expected, u64::MAX, has_more)
378            );
379        }
380    }
381    #[test]
382    fn cursor_page_serializes_expected_shape() {
383        let page = CursorPage::new(vec!["a", "b"], 10, 2, Some(IdCursor { id: 42 }));
384        let value = serde_json::to_value(page).unwrap();
385
386        assert_eq!(
387            value,
388            json!({
389                "items": ["a", "b"],
390                "total": 10,
391                "limit": 2,
392                "next_cursor": { "id": 42 }
393            })
394        );
395    }
396
397    #[test]
398    fn parse_id_cursor_accepts_absent_or_positive_id() {
399        assert_eq!(parse_id_cursor(None, "profile").unwrap(), None);
400        assert_eq!(parse_id_cursor(Some(7), "profile").unwrap(), Some(7));
401
402        let error = parse_id_cursor(Some(0), "profile").unwrap_err();
403        assert_eq!(error.message(), "profile cursor id must be positive");
404    }
405
406    #[test]
407    fn parse_datetime_id_cursor_requires_both_parts() {
408        let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
409            .unwrap()
410            .with_timezone(&Utc);
411
412        assert_eq!(
413            parse_datetime_id_cursor(Some(timestamp), Some(9), "audit")
414                .unwrap()
415                .unwrap(),
416            (timestamp, 9)
417        );
418        assert_eq!(parse_datetime_id_cursor(None, None, "audit").unwrap(), None);
419
420        let error = parse_datetime_id_cursor(Some(timestamp), None, "audit").unwrap_err();
421        assert_eq!(error.message(), "audit cursor requires both value and id");
422
423        let error = parse_datetime_id_cursor(Some(timestamp), Some(-1), "audit").unwrap_err();
424        assert_eq!(error.message(), "audit cursor id must be positive");
425    }
426
427    #[test]
428    fn parse_datetime_string_cursor_rejects_empty_id() {
429        let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
430            .unwrap()
431            .with_timezone(&Utc);
432
433        assert_eq!(
434            parse_datetime_string_cursor(Some(timestamp), Some("abc".to_string()), "session")
435                .unwrap()
436                .unwrap(),
437            (timestamp, "abc".to_string())
438        );
439
440        let error = parse_datetime_string_cursor(Some(timestamp), Some(" ".to_string()), "session")
441            .unwrap_err();
442        assert_eq!(error.message(), "session cursor id must not be empty");
443    }
444
445    #[test]
446    fn parse_string_id_cursor_rejects_incomplete_or_empty_values() {
447        assert_eq!(
448            parse_string_id_cursor(Some("oauth".to_string()), Some(3), "provider")
449                .unwrap()
450                .unwrap(),
451            ("oauth".to_string(), 3)
452        );
453
454        let error = parse_string_id_cursor(Some(" ".to_string()), Some(3), "provider").unwrap_err();
455        assert_eq!(error.message(), "provider cursor value must not be empty");
456
457        let error =
458            parse_string_id_cursor(Some("oauth".to_string()), Some(0), "provider").unwrap_err();
459        assert_eq!(error.message(), "provider cursor id must be positive");
460
461        let error =
462            parse_string_id_cursor(Some("oauth".to_string()), None, "provider").unwrap_err();
463        assert_eq!(
464            error.message(),
465            "provider cursor requires both value and id"
466        );
467    }
468
469    #[test]
470    fn parse_sort_order_name_id_cursor_validates_tuple() {
471        assert_eq!(
472            parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), Some(2), "tag")
473                .unwrap()
474                .unwrap(),
475            (10, "cape".to_string(), 2)
476        );
477        assert_eq!(
478            parse_sort_order_name_id_cursor(None, None, None, "tag").unwrap(),
479            None
480        );
481
482        let error =
483            parse_sort_order_name_id_cursor(Some(10), Some(" ".to_string()), Some(2), "tag")
484                .unwrap_err();
485        assert_eq!(error.message(), "tag cursor name must not be empty");
486
487        let error =
488            parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), None, "tag")
489                .unwrap_err();
490        assert_eq!(
491            error.message(),
492            "tag cursor requires sort_order, name, and id"
493        );
494    }
495
496    #[test]
497    fn parse_enabled_priority_id_cursor_validates_tuple() {
498        assert_eq!(
499            parse_enabled_priority_id_cursor(Some(true), Some(10), Some(2), "server")
500                .unwrap()
501                .unwrap(),
502            (true, 10, 2)
503        );
504        assert_eq!(
505            parse_enabled_priority_id_cursor(None, None, None, "server").unwrap(),
506            None
507        );
508
509        let error =
510            parse_enabled_priority_id_cursor(Some(true), Some(10), Some(0), "server").unwrap_err();
511        assert_eq!(error.message(), "server cursor id must be positive");
512
513        let error =
514            parse_enabled_priority_id_cursor(Some(true), Some(10), None, "server").unwrap_err();
515        assert_eq!(
516            error.message(),
517            "server cursor requires enabled, priority, and id"
518        );
519    }
520
521    #[test]
522    fn cursor_slice_trims_overfetch_and_reports_has_more() {
523        let slice = CursorSlice::from_overfetch(vec![1, 2, 3], 10, 2).unwrap();
524        assert_eq!(slice.items, vec![1, 2]);
525        assert_eq!(slice.total, 10);
526        assert!(slice.has_more);
527
528        let slice = CursorSlice::from_overfetch(vec![1, 2], 2, 2).unwrap();
529        assert_eq!(slice.items, vec![1, 2]);
530        assert_eq!(slice.total, 2);
531        assert!(!slice.has_more);
532
533        let slice = CursorSlice::<u8>::empty(7);
534        assert_eq!(slice.items, [] as [u8; 0]);
535        assert_eq!(slice.total, 7);
536        assert!(!slice.has_more);
537    }
538}