Skip to main content

nautilus_core/
cursor.rs

1//! Cursor predicate builder for stable pagination.
2
3use std::collections::HashMap;
4
5use crate::error::{Error, Result};
6use crate::{Expr, Value};
7
8/// Build an inclusive cursor predicate for stable forward or backward pagination.
9///
10/// `pk_fields` is an ordered slice of `(cursor_map_key, "table__db_col")` pairs
11/// matching the primary-key field order of the model:
12///
13/// - `cursor_map_key` is the key the caller uses in the cursor `HashMap`
14///   (typically the snake_case logical field name, e.g. `"id"`, `"user_id"`).
15/// - `"table__db_col"` is the `table__column` string rendered by the dialect
16///   into `"table"."column"` in the generated SQL.
17///
18/// # Semantics
19///
20/// | `backward` | predicate style |
21/// |---|---|
22/// | `false` (forward) | `pk >= cursor_val` (single) / row-value expansion with `>=` on last field |
23/// | `true` (backward) | `pk <= cursor_val` (single) / row-value expansion with `<=` on last field |
24///
25/// The cursor record is always included in the result set.
26///
27/// ## Composite PK expansion
28///
29/// For a 2-field PK `(a, b)` in forward direction, the generated predicate is:
30/// ```text
31/// (a > v1) OR (a = v1 AND b >= v2)
32/// ```
33/// This avoids tuple syntax `(a, b) >= (v1, v2)`, which is not universally
34/// supported.
35///
36/// # Errors
37///
38/// Returns [`Error::InvalidQuery`] if any required key is absent from `cursor`.
39pub fn build_cursor_predicate(
40    pk_fields: &[(&str, &str)],
41    cursor: &HashMap<String, Value>,
42    backward: bool,
43) -> Result<Expr> {
44    if pk_fields.is_empty() {
45        return Err(Error::InvalidQuery(
46            "build_cursor_predicate: pk_fields must not be empty".to_string(),
47        ));
48    }
49    build_cursor_recursive(pk_fields, cursor, backward, 0)
50}
51
52fn build_cursor_recursive(
53    pk_fields: &[(&str, &str)],
54    cursor: &HashMap<String, Value>,
55    backward: bool,
56    index: usize,
57) -> Result<Expr> {
58    let (map_key, col_ref) = pk_fields[index];
59
60    let val = cursor
61        .get(map_key)
62        .ok_or_else(|| {
63            Error::InvalidQuery(format!(
64                "cursor missing required primary-key field '{}'",
65                map_key
66            ))
67        })?
68        .clone();
69
70    let col = Expr::column(col_ref.to_string());
71    let param = Expr::param(val);
72
73    if index == pk_fields.len() - 1 {
74        Ok(if backward {
75            col.le(param)
76        } else {
77            col.ge(param)
78        })
79    } else {
80        let strict = if backward {
81            col.clone().lt(param.clone())
82        } else {
83            col.clone().gt(param.clone())
84        };
85        let eq_and_rest = col.eq(param).and(build_cursor_recursive(
86            pk_fields,
87            cursor,
88            backward,
89            index + 1,
90        )?);
91        Ok(strict.or(eq_and_rest))
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::Value;
99
100    fn map(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
101        pairs
102            .iter()
103            .map(|(k, v)| (k.to_string(), v.clone()))
104            .collect()
105    }
106
107    #[test]
108    fn single_field_forward() {
109        let cursor = map(&[("id", Value::I32(5))]);
110        let pred = build_cursor_predicate(&[("id", "users__id")], &cursor, false).unwrap();
111        assert!(matches!(
112            pred,
113            crate::Expr::Binary {
114                op: crate::BinaryOp::Ge,
115                ..
116            }
117        ));
118    }
119
120    #[test]
121    fn single_field_backward() {
122        let cursor = map(&[("id", Value::I32(5))]);
123        let pred = build_cursor_predicate(&[("id", "users__id")], &cursor, true).unwrap();
124        assert!(matches!(
125            pred,
126            crate::Expr::Binary {
127                op: crate::BinaryOp::Le,
128                ..
129            }
130        ));
131    }
132
133    #[test]
134    fn composite_forward() {
135        let cursor = map(&[("user_id", Value::I32(2)), ("post_id", Value::I32(10))]);
136        let pred = build_cursor_predicate(
137            &[("user_id", "posts__user_id"), ("post_id", "posts__post_id")],
138            &cursor,
139            false,
140        )
141        .unwrap();
142        assert!(matches!(
143            pred,
144            crate::Expr::Binary {
145                op: crate::BinaryOp::Or,
146                ..
147            }
148        ));
149    }
150
151    #[test]
152    fn missing_key_returns_error() {
153        let cursor = map(&[("id", Value::I32(5))]);
154        let result = build_cursor_predicate(&[("missing_field", "t__c")], &cursor, false);
155        assert!(result.is_err());
156    }
157
158    #[test]
159    fn empty_pk_fields_returns_error() {
160        let cursor = map(&[("id", Value::I32(5))]);
161        let result = build_cursor_predicate(&[], &cursor, false);
162        assert!(result.is_err());
163    }
164}