Skip to main content

klauthed_data/pagination/
sql.rs

1//! SQL helper utilities for building paginated queries.
2//!
3//! These functions produce SQL fragments and parameter values that callers
4//! compose into their own `sqlx::query` calls. They deliberately do **not**
5//! build complete queries — the surrounding `SELECT`, `FROM`, `WHERE`, and
6//! result-mapping belong to the caller.
7//!
8//! # Examples
9//!
10//! ```
11//! use klauthed_data::pagination::{OffsetPageRequest, SortKey};
12//! use klauthed_data::pagination::sql::{limit_offset, sort_clause};
13//!
14//! let req = OffsetPageRequest::new(2, 20).unwrap();
15//! let (limit, offset) = limit_offset(&req);
16//! assert_eq!(limit, 20);
17//! assert_eq!(offset, 20); // page 2, 20-per-page → offset 20
18//!
19//! let order = sort_clause(
20//!     &[SortKey::asc("created_at"), SortKey::desc("id")],
21//!     &["created_at", "id", "name"],
22//! );
23//! assert_eq!(order, " ORDER BY created_at ASC, id DESC");
24//! ```
25
26use super::{OffsetPageRequest, SortKey, SortOrder};
27
28/// Re-exported so the keyset SQL helpers can be imported from one place
29/// alongside [`keyset_where_clause`].
30pub use super::KeysetDialect;
31
32/// Return `(limit, offset)` as `i64` values ready to bind to a parameterised
33/// query:
34///
35/// ```no_run
36/// # use klauthed_data::pagination::OffsetPageRequest;
37/// # use klauthed_data::pagination::sql::limit_offset;
38/// # async fn run(pool: sqlx::AnyPool, req: OffsetPageRequest) -> Result<(), sqlx::Error> {
39/// let (limit, offset) = limit_offset(&req);
40/// let rows = sqlx::query("SELECT * FROM users LIMIT $1 OFFSET $2")
41///     .bind(limit)
42///     .bind(offset)
43///     .fetch_all(&pool)
44///     .await?;
45/// # Ok(())
46/// # }
47/// ```
48pub fn limit_offset(req: &OffsetPageRequest) -> (i64, i64) {
49    (req.limit() as i64, req.offset() as i64)
50}
51
52/// Build an `ORDER BY` clause from `sort`, filtering to only fields in
53/// `allowed_fields`.
54///
55/// Unknown fields are **silently dropped** to prevent SQL injection — the
56/// sort column names are placed directly into the SQL string (they cannot be
57/// bound as parameters). Always supply a restrictive `allowed_fields` list.
58///
59/// Returns an empty string when `sort` is empty or all fields are filtered
60/// out, so the caller's default ordering applies.
61///
62/// ```
63/// use klauthed_data::pagination::{SortKey, SortOrder};
64/// use klauthed_data::pagination::sql::sort_clause;
65///
66/// let clause = sort_clause(
67///     &[SortKey::asc("name"), SortKey::desc("created_at")],
68///     &["name", "created_at", "id"],
69/// );
70/// assert_eq!(clause, " ORDER BY name ASC, created_at DESC");
71///
72/// // Unknown fields are dropped.
73/// let clause = sort_clause(
74///     &[SortKey::asc("__proto__")],
75///     &["name", "id"],
76/// );
77/// assert_eq!(clause, "");
78///
79/// // Empty sort → empty string.
80/// assert_eq!(sort_clause(&[], &["id"]), "");
81/// ```
82pub fn sort_clause(sort: &[SortKey], allowed_fields: &[&str]) -> String {
83    let parts: Vec<String> = sort
84        .iter()
85        .filter(|k| allowed_fields.contains(&k.field.as_str()))
86        .map(|k| {
87            let dir = match k.order {
88                SortOrder::Asc => "ASC",
89                SortOrder::Desc => "DESC",
90            };
91            format!("{} {}", k.field, dir)
92        })
93        .collect();
94
95    if parts.is_empty() { String::new() } else { format!(" ORDER BY {}", parts.join(", ")) }
96}
97
98/// Build the keyset `WHERE` clause fragment for compound column pagination.
99///
100/// Generates `(col1, col2) > ($1, $2)` (positional dialect) or
101/// `(col1, col2) > (?, ?)` (question-mark dialect) for the given `sort` keys,
102/// starting bind parameters at `start_param`.
103///
104/// **Security:** column names are taken from `sort` without quoting and must
105/// have been validated against an allowed-fields list before calling this.
106///
107/// Returns `None` when `sort` is empty (no keyset condition needed).
108///
109/// ```
110/// use klauthed_data::pagination::SortKey;
111/// use klauthed_data::pagination::sql::{KeysetDialect, keyset_where_clause};
112///
113/// let clause = keyset_where_clause(
114///     &[SortKey::asc("created_at"), SortKey::asc("id")],
115///     1,
116///     KeysetDialect::Positional,
117/// );
118/// assert_eq!(clause, Some("(created_at, id) > ($1, $2)".to_owned()));
119///
120/// let clause = keyset_where_clause(
121///     &[SortKey::desc("score"), SortKey::asc("id")],
122///     1,
123///     KeysetDialect::Question,
124/// );
125/// assert_eq!(clause, Some("(score, id) > (?, ?)".to_owned()));
126///
127/// assert_eq!(keyset_where_clause(&[], 1, KeysetDialect::Positional), None);
128/// ```
129pub fn keyset_where_clause(
130    sort: &[SortKey],
131    start_param: usize,
132    dialect: KeysetDialect,
133) -> Option<String> {
134    if sort.is_empty() {
135        return None;
136    }
137    let cols: Vec<&str> = sort.iter().map(|k| k.field.as_str()).collect();
138    let params: Vec<String> = (0..sort.len())
139        .map(|i| match dialect {
140            KeysetDialect::Positional => format!("${}", start_param + i),
141            KeysetDialect::Question => "?".to_owned(),
142        })
143        .collect();
144    Some(format!("({}) > ({})", cols.join(", "), params.join(", ")))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::pagination::{OffsetPageRequest, SortKey};
151
152    #[test]
153    fn limit_offset_page1() {
154        let req = OffsetPageRequest::new(1, 20).unwrap();
155        assert_eq!(limit_offset(&req), (20, 0));
156    }
157
158    #[test]
159    fn limit_offset_page3() {
160        let req = OffsetPageRequest::new(3, 10).unwrap();
161        assert_eq!(limit_offset(&req), (10, 20));
162    }
163
164    #[test]
165    fn sort_clause_single_asc() {
166        let s = sort_clause(&[SortKey::asc("name")], &["name", "id"]);
167        assert_eq!(s, " ORDER BY name ASC");
168    }
169
170    #[test]
171    fn sort_clause_multi_mixed() {
172        let s =
173            sort_clause(&[SortKey::asc("created_at"), SortKey::desc("id")], &["created_at", "id"]);
174        assert_eq!(s, " ORDER BY created_at ASC, id DESC");
175    }
176
177    #[test]
178    fn sort_clause_filters_unknown_fields() {
179        let s = sort_clause(&[SortKey::asc("evil; DROP TABLE users")], &["name"]);
180        assert_eq!(s, "");
181    }
182
183    #[test]
184    fn sort_clause_empty_returns_empty_string() {
185        assert_eq!(sort_clause(&[], &["id"]), "");
186    }
187
188    #[test]
189    fn keyset_where_positional() {
190        let clause = keyset_where_clause(
191            &[SortKey::asc("created_at"), SortKey::asc("id")],
192            1,
193            KeysetDialect::Positional,
194        );
195        assert_eq!(clause, Some("(created_at, id) > ($1, $2)".to_owned()));
196    }
197
198    #[test]
199    fn keyset_where_question_mark() {
200        let clause = keyset_where_clause(&[SortKey::asc("id")], 1, KeysetDialect::Question);
201        assert_eq!(clause, Some("(id) > (?)".to_owned()));
202    }
203
204    #[test]
205    fn keyset_where_offset_start_param() {
206        let clause = keyset_where_clause(
207            &[SortKey::asc("a"), SortKey::desc("b")],
208            3,
209            KeysetDialect::Positional,
210        );
211        assert_eq!(clause, Some("(a, b) > ($3, $4)".to_owned()));
212    }
213
214    #[test]
215    fn keyset_where_empty_sort_is_none() {
216        assert_eq!(keyset_where_clause(&[], 1, KeysetDialect::Positional), None);
217    }
218}