1use crate::{ApiError, ApiSchema, Result};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5#[cfg(all(debug_assertions, feature = "openapi"))]
6use utoipa::{IntoParams, ToSchema};
7#[derive(Debug, Clone, Copy, Default, Deserialize)]
9#[cfg_attr(
10 all(debug_assertions, feature = "openapi"),
11 derive(IntoParams, ToSchema)
12)]
13pub struct CreatedAtCursorQuery {
14 pub after_created_at: Option<DateTime<Utc>>,
16 pub after_id: Option<i64>,
18}
19
20#[derive(Debug, Clone, Copy, Default, Deserialize)]
22#[cfg_attr(
23 all(debug_assertions, feature = "openapi"),
24 derive(IntoParams, ToSchema)
25)]
26pub struct UpdatedAtCursorQuery {
27 pub after_updated_at: Option<DateTime<Utc>>,
29 pub after_id: Option<i64>,
31}
32
33#[derive(Debug, Clone, Serialize)]
35#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
36pub struct CursorPage<T: Serialize + ApiSchema, C: Serialize + ApiSchema> {
37 pub items: Vec<T>,
39 pub total: u64,
41 pub limit: u64,
43 pub next_cursor: Option<C>,
45}
46
47impl<T: Serialize + ApiSchema, C: Serialize + ApiSchema> CursorPage<T, C> {
48 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#[derive(Debug, Clone, Serialize)]
61#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
62pub struct IdCursor {
63 pub id: i64,
65}
66
67#[derive(Debug, Clone, Serialize)]
69#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
70pub struct StringIdCursor {
71 pub value: String,
73 pub id: i64,
75}
76
77#[derive(Debug, Clone, Serialize)]
79#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
80pub struct SortOrderNameIdCursor {
81 pub sort_order: i32,
83 pub name: String,
85 pub id: i64,
87}
88
89#[derive(Debug, Clone, Serialize)]
91#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
92pub struct EnabledPriorityIdCursor {
93 pub enabled: bool,
95 pub priority: i32,
97 pub id: i64,
99}
100
101#[derive(Debug, Clone, Serialize)]
103#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
104pub struct DateTimeIdCursor {
105 #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
107 pub value: DateTime<Utc>,
108 pub id: i64,
110}
111
112#[derive(Debug, Clone, Serialize)]
114#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
115pub struct DateTimeStringCursor {
116 #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
118 pub value: DateTime<Utc>,
119 pub id: String,
121}
122
123pub 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
145pub 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
167pub 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
182pub 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
208pub 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
236pub 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#[derive(Debug, Clone)]
261pub struct CursorSlice<T> {
262 pub items: Vec<T>,
264 pub total: u64,
266 pub has_more: bool,
268}
269
270impl<T> CursorSlice<T> {
271 #[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 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}