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}