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` – the key the caller uses in the cursor `HashMap` (typically
14///   the snake_case logical field name, e.g. `"id"`, `"user_id"`).
15/// - `"table__db_col"` – the `table__column` string rendered by the dialect into
16///   `"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 (portable across all three dialects)
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 supported.
34///
35/// # Errors
36///
37/// Returns [`Error::InvalidQuery`] if any required key is absent from `cursor`.
38pub fn build_cursor_predicate(
39    pk_fields: &[(&str, &str)],
40    cursor: &HashMap<String, Value>,
41    backward: bool,
42) -> Result<Expr> {
43    if pk_fields.is_empty() {
44        return Err(Error::InvalidQuery(
45            "build_cursor_predicate: pk_fields must not be empty".to_string(),
46        ));
47    }
48    build_cursor_recursive(pk_fields, cursor, backward, 0)
49}
50
51fn build_cursor_recursive(
52    pk_fields: &[(&str, &str)],
53    cursor: &HashMap<String, Value>,
54    backward: bool,
55    index: usize,
56) -> Result<Expr> {
57    let (map_key, col_ref) = pk_fields[index];
58
59    let val = cursor
60        .get(map_key)
61        .ok_or_else(|| {
62            Error::InvalidQuery(format!(
63                "cursor missing required primary-key field '{}'",
64                map_key
65            ))
66        })?
67        .clone();
68
69    let col = Expr::column(col_ref.to_string());
70    let param = Expr::param(val);
71
72    if index == pk_fields.len() - 1 {
73        // Last (or only) field → inclusive comparison.
74        Ok(if backward {
75            col.le(param)
76        } else {
77            col.ge(param)
78        })
79    } else {
80        // Not the last field: strict comparison OR (equal AND recurse on the rest).
81        let strict = if backward {
82            col.clone().lt(param.clone())
83        } else {
84            col.clone().gt(param.clone())
85        };
86        let eq_and_rest = col.eq(param).and(build_cursor_recursive(
87            pk_fields,
88            cursor,
89            backward,
90            index + 1,
91        )?);
92        Ok(strict.or(eq_and_rest))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::Value;
100
101    fn map(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
102        pairs
103            .iter()
104            .map(|(k, v)| (k.to_string(), v.clone()))
105            .collect()
106    }
107
108    #[test]
109    fn single_field_forward() {
110        let cursor = map(&[("id", Value::I32(5))]);
111        let pred = build_cursor_predicate(&[("id", "users__id")], &cursor, false).unwrap();
112        // Should be: Expr::Column("users__id") >= Expr::Param(5)
113        assert!(matches!(
114            pred,
115            crate::Expr::Binary {
116                op: crate::BinaryOp::Ge,
117                ..
118            }
119        ));
120    }
121
122    #[test]
123    fn single_field_backward() {
124        let cursor = map(&[("id", Value::I32(5))]);
125        let pred = build_cursor_predicate(&[("id", "users__id")], &cursor, true).unwrap();
126        assert!(matches!(
127            pred,
128            crate::Expr::Binary {
129                op: crate::BinaryOp::Le,
130                ..
131            }
132        ));
133    }
134
135    #[test]
136    fn composite_forward() {
137        // Two-field PK: (user_id, post_id)
138        let cursor = map(&[("user_id", Value::I32(2)), ("post_id", Value::I32(10))]);
139        let pred = build_cursor_predicate(
140            &[("user_id", "posts__user_id"), ("post_id", "posts__post_id")],
141            &cursor,
142            false,
143        )
144        .unwrap();
145        // Top-level should be: (user_id > 2) OR (user_id = 2 AND post_id >= 10)
146        assert!(matches!(
147            pred,
148            crate::Expr::Binary {
149                op: crate::BinaryOp::Or,
150                ..
151            }
152        ));
153    }
154
155    #[test]
156    fn missing_key_returns_error() {
157        let cursor = map(&[("id", Value::I32(5))]);
158        let result = build_cursor_predicate(&[("missing_field", "t__c")], &cursor, false);
159        assert!(result.is_err());
160    }
161
162    #[test]
163    fn empty_pk_fields_returns_error() {
164        let cursor = map(&[("id", Value::I32(5))]);
165        let result = build_cursor_predicate(&[], &cursor, false);
166        assert!(result.is_err());
167    }
168}