Skip to main content

helios_persistence/types/
pagination.rs

1//! Pagination types for search results.
2//!
3//! This module defines types for handling pagination in FHIR search results,
4//! supporting both cursor-based and offset-based pagination.
5
6use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::error::SearchError;
11
12/// Pagination configuration for a search request.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Pagination {
15    /// Maximum number of results to return.
16    pub count: u32,
17
18    /// The pagination mode.
19    pub mode: PaginationMode,
20}
21
22/// The pagination mode.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub enum PaginationMode {
25    /// Cursor-based pagination (recommended).
26    Cursor(Option<PageCursor>),
27
28    /// Offset-based pagination (for compatibility).
29    Offset(u32),
30}
31
32impl Default for Pagination {
33    fn default() -> Self {
34        Self {
35            count: 20,
36            mode: PaginationMode::Cursor(None),
37        }
38    }
39}
40
41impl Pagination {
42    /// Creates pagination with cursor mode and the specified count.
43    pub fn new(count: u32) -> Self {
44        Self {
45            count,
46            mode: PaginationMode::Cursor(None),
47        }
48    }
49
50    /// Creates pagination with cursor mode and default count.
51    pub fn cursor() -> Self {
52        Self::default()
53    }
54
55    /// Creates pagination with a cursor string and specified count.
56    pub fn with_cursor(count: u32, cursor: String) -> Self {
57        match PageCursor::decode(&cursor) {
58            Ok(page_cursor) => Self {
59                count,
60                mode: PaginationMode::Cursor(Some(page_cursor)),
61            },
62            Err(_) => Self {
63                count,
64                mode: PaginationMode::Cursor(None),
65            },
66        }
67    }
68
69    /// Creates pagination with offset mode.
70    pub fn offset(offset: u32) -> Self {
71        Self {
72            count: 20,
73            mode: PaginationMode::Offset(offset),
74        }
75    }
76
77    /// Creates pagination from a cursor string.
78    pub fn from_cursor(cursor: &str) -> Result<Self, SearchError> {
79        let page_cursor = PageCursor::decode(cursor)?;
80        Ok(Self {
81            count: 20,
82            mode: PaginationMode::Cursor(Some(page_cursor)),
83        })
84    }
85
86    /// Sets the count limit.
87    pub fn with_count(mut self, count: u32) -> Self {
88        self.count = count;
89        self
90    }
91
92    /// Returns the offset if using offset-based pagination.
93    pub fn offset_value(&self) -> Option<u32> {
94        match &self.mode {
95            PaginationMode::Offset(offset) => Some(*offset),
96            _ => None,
97        }
98    }
99
100    /// Returns the cursor if using cursor-based pagination.
101    pub fn cursor_value(&self) -> Option<&PageCursor> {
102        match &self.mode {
103            PaginationMode::Cursor(Some(cursor)) => Some(cursor),
104            _ => None,
105        }
106    }
107}
108
109/// An opaque cursor for keyset pagination.
110///
111/// The cursor encodes the position in the result set in a way that is:
112/// - Stable across concurrent modifications
113/// - Efficient for the database to seek to
114/// - Opaque to clients (they can't construct or modify it)
115///
116/// # Encoding
117///
118/// Cursors are base64-encoded JSON containing:
119/// - Sort key values for the last returned item
120/// - The resource ID for tie-breaking
121/// - Version information for cursor compatibility
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PageCursor {
124    /// Cursor format version.
125    version: u8,
126
127    /// The sort key values at the cursor position.
128    sort_values: Vec<CursorValue>,
129
130    /// The resource ID at the cursor position (for tie-breaking).
131    resource_id: String,
132
133    /// The direction of pagination.
134    direction: CursorDirection,
135}
136
137/// A value in the cursor for sorting.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(untagged)]
140pub enum CursorValue {
141    /// String value.
142    String(String),
143    /// Numeric value.
144    Number(i64),
145    /// Decimal value.
146    Decimal(f64),
147    /// Boolean value.
148    Boolean(bool),
149    /// Null value.
150    Null,
151}
152
153/// Direction of cursor pagination.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
155pub enum CursorDirection {
156    /// Fetching the next page (forward).
157    #[default]
158    Next,
159    /// Fetching the previous page (backward).
160    Previous,
161}
162
163impl PageCursor {
164    /// Creates a new cursor at the given position.
165    pub fn new(sort_values: Vec<CursorValue>, resource_id: impl Into<String>) -> Self {
166        Self {
167            version: 1,
168            sort_values,
169            resource_id: resource_id.into(),
170            direction: CursorDirection::Next,
171        }
172    }
173
174    /// Creates a cursor for the previous page.
175    pub fn previous(sort_values: Vec<CursorValue>, resource_id: impl Into<String>) -> Self {
176        Self {
177            version: 1,
178            sort_values,
179            resource_id: resource_id.into(),
180            direction: CursorDirection::Previous,
181        }
182    }
183
184    /// Returns the sort values.
185    pub fn sort_values(&self) -> &[CursorValue] {
186        &self.sort_values
187    }
188
189    /// Returns the resource ID.
190    pub fn resource_id(&self) -> &str {
191        &self.resource_id
192    }
193
194    /// Returns the direction.
195    pub fn direction(&self) -> CursorDirection {
196        self.direction
197    }
198
199    /// Encodes the cursor to an opaque string.
200    pub fn encode(&self) -> String {
201        let json = serde_json::to_vec(self).unwrap_or_default();
202        URL_SAFE_NO_PAD.encode(&json)
203    }
204
205    /// Decodes a cursor from an opaque string.
206    pub fn decode(s: &str) -> Result<Self, SearchError> {
207        let bytes = URL_SAFE_NO_PAD
208            .decode(s)
209            .map_err(|_| SearchError::InvalidCursor {
210                cursor: s.to_string(),
211            })?;
212
213        serde_json::from_slice(&bytes).map_err(|_| SearchError::InvalidCursor {
214            cursor: s.to_string(),
215        })
216    }
217}
218
219impl From<&str> for CursorValue {
220    fn from(s: &str) -> Self {
221        CursorValue::String(s.to_string())
222    }
223}
224
225impl From<String> for CursorValue {
226    fn from(s: String) -> Self {
227        CursorValue::String(s)
228    }
229}
230
231impl From<i64> for CursorValue {
232    fn from(n: i64) -> Self {
233        CursorValue::Number(n)
234    }
235}
236
237impl From<f64> for CursorValue {
238    fn from(n: f64) -> Self {
239        CursorValue::Decimal(n)
240    }
241}
242
243impl From<bool> for CursorValue {
244    fn from(b: bool) -> Self {
245        CursorValue::Boolean(b)
246    }
247}
248
249impl From<()> for CursorValue {
250    fn from(_: ()) -> Self {
251        CursorValue::Null
252    }
253}
254
255/// Information about a page of results.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct PageInfo {
258    /// The cursor for the next page, if there is one.
259    pub next_cursor: Option<String>,
260
261    /// The cursor for the previous page, if there is one.
262    pub previous_cursor: Option<String>,
263
264    /// Total count of matching resources (if requested and available).
265    pub total: Option<u64>,
266
267    /// Whether there are more results after this page.
268    pub has_next: bool,
269
270    /// Whether there are results before this page.
271    pub has_previous: bool,
272}
273
274impl PageInfo {
275    /// Creates page info indicating no more pages.
276    pub fn end() -> Self {
277        Self {
278            next_cursor: None,
279            previous_cursor: None,
280            total: None,
281            has_next: false,
282            has_previous: false,
283        }
284    }
285
286    /// Creates page info with a next cursor.
287    pub fn with_next(cursor: PageCursor) -> Self {
288        Self {
289            next_cursor: Some(cursor.encode()),
290            previous_cursor: None,
291            total: None,
292            has_next: true,
293            has_previous: false,
294        }
295    }
296
297    /// Sets the total count.
298    pub fn with_total(mut self, total: u64) -> Self {
299        self.total = Some(total);
300        self
301    }
302
303    /// Sets the previous cursor.
304    pub fn with_previous(mut self, cursor: PageCursor) -> Self {
305        self.previous_cursor = Some(cursor.encode());
306        self.has_previous = true;
307        self
308    }
309}
310
311/// A page of search results.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct Page<T> {
314    /// The items in this page.
315    pub items: Vec<T>,
316
317    /// Pagination information.
318    pub page_info: PageInfo,
319}
320
321impl<T> Page<T> {
322    /// Creates a new page with the given items and page info.
323    pub fn new(items: Vec<T>, page_info: PageInfo) -> Self {
324        Self { items, page_info }
325    }
326
327    /// Creates an empty page.
328    pub fn empty() -> Self {
329        Self {
330            items: Vec::new(),
331            page_info: PageInfo::end(),
332        }
333    }
334
335    /// Returns true if this page has no items.
336    pub fn is_empty(&self) -> bool {
337        self.items.is_empty()
338    }
339
340    /// Returns the number of items in this page.
341    pub fn len(&self) -> usize {
342        self.items.len()
343    }
344
345    /// Maps the items to a different type.
346    pub fn map<U, F>(self, f: F) -> Page<U>
347    where
348        F: FnMut(T) -> U,
349    {
350        Page {
351            items: self.items.into_iter().map(f).collect(),
352            page_info: self.page_info,
353        }
354    }
355}
356
357impl<T> Default for Page<T> {
358    fn default() -> Self {
359        Self::empty()
360    }
361}
362
363/// A FHIR Bundle for search results.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct SearchBundle {
366    /// The bundle type (always "searchset").
367    #[serde(rename = "type")]
368    pub bundle_type: String,
369
370    /// Total count of matching resources.
371    pub total: Option<u64>,
372
373    /// Links for pagination.
374    pub link: Vec<BundleLink>,
375
376    /// The bundle entries.
377    pub entry: Vec<BundleEntry>,
378}
379
380/// A link in a FHIR Bundle.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct BundleLink {
383    /// The relation type (self, next, previous, first, last).
384    pub relation: String,
385
386    /// The URL.
387    pub url: String,
388}
389
390/// An entry in a FHIR Bundle.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct BundleEntry {
393    /// The full URL of the resource.
394    #[serde(rename = "fullUrl", skip_serializing_if = "Option::is_none")]
395    pub full_url: Option<String>,
396
397    /// The resource.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub resource: Option<Value>,
400
401    /// Search information.
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub search: Option<BundleEntrySearch>,
404}
405
406/// Search information for a bundle entry.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct BundleEntrySearch {
409    /// How this entry matched the search (match, include, outcome).
410    pub mode: SearchEntryMode,
411
412    /// Search ranking score.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub score: Option<f64>,
415}
416
417/// How a bundle entry matched the search.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "lowercase")]
420pub enum SearchEntryMode {
421    /// This is a match to the search parameters.
422    Match,
423    /// This is included because of _include/_revinclude.
424    Include,
425    /// This is an OperationOutcome about the search.
426    Outcome,
427}
428
429impl SearchBundle {
430    /// Creates a new search bundle.
431    pub fn new() -> Self {
432        Self {
433            bundle_type: "searchset".to_string(),
434            total: None,
435            link: Vec::new(),
436            entry: Vec::new(),
437        }
438    }
439
440    /// Sets the total count.
441    pub fn with_total(mut self, total: u64) -> Self {
442        self.total = Some(total);
443        self
444    }
445
446    /// Adds a link.
447    pub fn with_link(mut self, relation: impl Into<String>, url: impl Into<String>) -> Self {
448        self.link.push(BundleLink {
449            relation: relation.into(),
450            url: url.into(),
451        });
452        self
453    }
454
455    /// Adds an entry.
456    pub fn with_entry(mut self, entry: BundleEntry) -> Self {
457        self.entry.push(entry);
458        self
459    }
460
461    /// Adds a self link.
462    pub fn with_self_link(self, url: impl Into<String>) -> Self {
463        self.with_link("self", url)
464    }
465
466    /// Adds a next link.
467    pub fn with_next_link(self, url: impl Into<String>) -> Self {
468        self.with_link("next", url)
469    }
470
471    /// Adds a previous link.
472    pub fn with_previous_link(self, url: impl Into<String>) -> Self {
473        self.with_link("previous", url)
474    }
475}
476
477impl Default for SearchBundle {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483impl BundleEntry {
484    /// Creates a new match entry.
485    pub fn match_entry(full_url: impl Into<String>, resource: Value) -> Self {
486        Self {
487            full_url: Some(full_url.into()),
488            resource: Some(resource),
489            search: Some(BundleEntrySearch {
490                mode: SearchEntryMode::Match,
491                score: None,
492            }),
493        }
494    }
495
496    /// Creates a new include entry.
497    pub fn include_entry(full_url: impl Into<String>, resource: Value) -> Self {
498        Self {
499            full_url: Some(full_url.into()),
500            resource: Some(resource),
501            search: Some(BundleEntrySearch {
502                mode: SearchEntryMode::Include,
503                score: None,
504            }),
505        }
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn test_pagination_default() {
515        let pagination = Pagination::default();
516        assert_eq!(pagination.count, 20);
517        assert!(matches!(pagination.mode, PaginationMode::Cursor(None)));
518    }
519
520    #[test]
521    fn test_pagination_offset() {
522        let pagination = Pagination::offset(100);
523        assert_eq!(pagination.offset_value(), Some(100));
524    }
525
526    #[test]
527    fn test_cursor_encode_decode() {
528        let cursor = PageCursor::new(
529            vec![CursorValue::String("2024-01-01".to_string())],
530            "patient-123",
531        );
532
533        let encoded = cursor.encode();
534        let decoded = PageCursor::decode(&encoded).unwrap();
535
536        assert_eq!(decoded.resource_id(), "patient-123");
537        assert_eq!(decoded.direction(), CursorDirection::Next);
538    }
539
540    #[test]
541    fn test_cursor_decode_invalid() {
542        let result = PageCursor::decode("not-valid-base64!!!");
543        assert!(result.is_err());
544    }
545
546    #[test]
547    fn test_cursor_previous() {
548        let cursor = PageCursor::previous(vec![CursorValue::Number(100)], "obs-456");
549        assert_eq!(cursor.direction(), CursorDirection::Previous);
550    }
551
552    #[test]
553    fn test_page_info_with_next() {
554        let cursor = PageCursor::new(vec![], "id");
555        let info = PageInfo::with_next(cursor);
556        assert!(info.has_next);
557        assert!(info.next_cursor.is_some());
558    }
559
560    #[test]
561    fn test_page_map() {
562        let page = Page::new(vec![1, 2, 3], PageInfo::end());
563
564        let mapped = page.map(|x| x * 2);
565        assert_eq!(mapped.items, vec![2, 4, 6]);
566    }
567
568    #[test]
569    fn test_search_bundle_builder() {
570        let bundle = SearchBundle::new()
571            .with_total(100)
572            .with_self_link("https://example.com/Patient?name=Smith")
573            .with_next_link("https://example.com/Patient?name=Smith&_cursor=xxx");
574
575        assert_eq!(bundle.total, Some(100));
576        assert_eq!(bundle.link.len(), 2);
577        assert_eq!(bundle.link[0].relation, "self");
578        assert_eq!(bundle.link[1].relation, "next");
579    }
580
581    #[test]
582    fn test_bundle_entry_match() {
583        let entry = BundleEntry::match_entry(
584            "https://example.com/Patient/123",
585            serde_json::json!({"resourceType": "Patient"}),
586        );
587
588        assert_eq!(entry.search.as_ref().unwrap().mode, SearchEntryMode::Match);
589    }
590
591    #[test]
592    fn test_cursor_value_conversions() {
593        let s: CursorValue = "test".into();
594        assert!(matches!(s, CursorValue::String(_)));
595
596        let n: CursorValue = 42i64.into();
597        assert!(matches!(n, CursorValue::Number(_)));
598
599        let b: CursorValue = true.into();
600        assert!(matches!(b, CursorValue::Boolean(_)));
601    }
602}