Skip to main content

rskit_database/
query.rs

1//! HTTP query parameter parsing and pagination for database queries.
2//!
3//! Mirrors the gokit `database/query/` package, providing:
4//!
5//! - [`QueryConfig`] — allowed sorts, filters, and page-size limits.
6//! - [`QueryParams`] — parsed page/sort/filter values ready for SQL use.
7//! - [`Pagination`] — response metadata (page, total, total_pages).
8//! - [`PaginatedResult`] — generic wrapper pairing data with pagination.
9//! - [`parse_query_string`] — parses a URL query string into [`QueryParams`].
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::{FindOpts, tenant::validate_identifier_path};
16
17// ---------------------------------------------------------------------------
18// Configuration
19// ---------------------------------------------------------------------------
20
21/// Configuration for query parameter parsing.
22#[derive(Debug, Clone)]
23pub struct QueryConfig {
24    /// Default page size when not specified (default: 20).
25    pub default_page_size: i64,
26    /// Maximum allowed page size (default: 100).
27    pub max_page_size: i64,
28    /// Allowed sort columns. Empty means every syntactically safe identifier is allowed.
29    pub allowed_sorts: Vec<String>,
30    /// Allowed filter columns. Empty means every syntactically safe identifier is allowed.
31    pub allowed_filters: Vec<String>,
32}
33
34impl Default for QueryConfig {
35    fn default() -> Self {
36        Self {
37            default_page_size: 20,
38            max_page_size: 100,
39            allowed_sorts: Vec::new(),
40            allowed_filters: Vec::new(),
41        }
42    }
43}
44
45impl QueryConfig {
46    /// Return a sanitized copy safe for parsing.
47    #[must_use]
48    fn sanitized(&self) -> Self {
49        Self {
50            default_page_size: self.default_page_size.max(1),
51            max_page_size: self.max_page_size.max(1),
52            allowed_sorts: self.allowed_sorts.clone(),
53            allowed_filters: self.allowed_filters.clone(),
54        }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Sort order
60// ---------------------------------------------------------------------------
61
62/// Sort direction for query results.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum SortOrder {
66    /// Ascending (default).
67    #[default]
68    Asc,
69    /// Descending.
70    Desc,
71}
72
73impl SortOrder {
74    /// SQL keyword representation (`"ASC"` or `"DESC"`).
75    pub fn as_sql(&self) -> &'static str {
76        match self {
77            Self::Asc => "ASC",
78            Self::Desc => "DESC",
79        }
80    }
81}
82
83impl std::fmt::Display for SortOrder {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(self.as_sql())
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Parsed query parameters
91// ---------------------------------------------------------------------------
92
93/// Parsed query parameters from an HTTP request.
94#[derive(Debug, Clone)]
95pub struct QueryParams {
96    /// Current page number (1-based).
97    pub page: i64,
98    /// Number of items per page.
99    pub page_size: i64,
100    /// Column to sort by (if any).
101    pub sort_by: Option<String>,
102    /// Sort direction.
103    pub sort_order: SortOrder,
104    /// Column-value filter pairs.
105    pub filters: HashMap<String, String>,
106}
107
108impl QueryParams {
109    /// SQL `LIMIT` value.
110    pub fn limit(&self) -> i64 {
111        self.page_size
112    }
113
114    /// SQL `OFFSET` value.
115    pub fn offset(&self) -> i64 {
116        (self.page - 1) * self.page_size
117    }
118
119    /// Convert to [`FindOpts`] for use with the repository layer.
120    pub fn to_find_opts(&self) -> FindOpts {
121        let mut opts = FindOpts::default()
122            .with_limit(self.limit())
123            .with_offset(self.offset());
124
125        if let Some(ref col) = self.sort_by {
126            opts = opts.order_by(&format!("{col} {}", self.sort_order));
127        }
128
129        for (col, val) in &self.filters {
130            opts = opts.filter(col, val.clone());
131        }
132
133        opts
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Pagination metadata
139// ---------------------------------------------------------------------------
140
141/// Pagination metadata for responses.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Pagination {
144    /// Current page number (1-based).
145    pub page: i64,
146    /// Items per page.
147    pub page_size: i64,
148    /// Total number of items across all pages.
149    pub total: i64,
150    /// Total number of pages.
151    pub total_pages: i64,
152}
153
154impl Pagination {
155    /// Create pagination metadata from page info and a total item count.
156    pub fn new(page: i64, page_size: i64, total: i64) -> Self {
157        let total_pages = if total == 0 || page_size == 0 {
158            0
159        } else {
160            (total + page_size - 1) / page_size
161        };
162        Self {
163            page,
164            page_size,
165            total,
166            total_pages,
167        }
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Paginated result
173// ---------------------------------------------------------------------------
174
175/// Paginated result containing data and pagination metadata.
176#[derive(Debug, Clone, Serialize)]
177pub struct PaginatedResult<T> {
178    /// The page of data items.
179    pub data: Vec<T>,
180    /// Pagination metadata.
181    pub pagination: Pagination,
182}
183
184impl<T> PaginatedResult<T> {
185    /// Build a paginated result from data, current page info, and a total count.
186    pub fn new(data: Vec<T>, page: i64, page_size: i64, total: i64) -> Self {
187        Self {
188            data,
189            pagination: Pagination::new(page, page_size, total),
190        }
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Query-string parsing
196// ---------------------------------------------------------------------------
197
198/// Reserved parameter names that are never treated as filters.
199const RESERVED_PARAMS: &[&str] = &["page", "page_size", "pageSize", "per_page", "sort", "order"];
200
201/// Parse query parameters from a URL query string.
202///
203/// Reads:
204/// - `page` — 1-based page number (default 1, min 1).
205/// - `page_size` / `pageSize` / `per_page` — items per page (default from
206///   config, clamped to `1..=max_page_size`).
207/// - `sort` — column to sort by (must be in `allowed_sorts` when non-empty).
208/// - `order` — `asc` or `desc` (default `asc`).
209/// - All other keys are treated as filters (must be in `allowed_filters` when
210///   non-empty).
211pub fn parse_query_string(query: &str, config: &QueryConfig) -> QueryParams {
212    let config = config.sanitized();
213    let pairs = parse_pairs(query);
214
215    let page = pairs
216        .get("page")
217        .and_then(|v| v.parse::<i64>().ok())
218        .unwrap_or(1)
219        .max(1);
220
221    let raw_page_size = pairs
222        .get("page_size")
223        .or_else(|| pairs.get("pageSize"))
224        .or_else(|| pairs.get("per_page"))
225        .and_then(|v| v.parse::<i64>().ok())
226        .unwrap_or(config.default_page_size);
227    let page_size = raw_page_size.clamp(1, config.max_page_size);
228
229    let sort_by = pairs.get("sort").and_then(|v| {
230        let v = v.trim();
231        if v.is_empty() {
232            return None;
233        }
234        if is_allowed_identifier(v, &config.allowed_sorts) {
235            Some(v.to_owned())
236        } else {
237            None
238        }
239    });
240
241    let sort_order = pairs
242        .get("order")
243        .map(|v| match v.to_ascii_lowercase().as_str() {
244            "desc" => SortOrder::Desc,
245            _ => SortOrder::Asc,
246        })
247        .unwrap_or_default();
248
249    let filters: HashMap<String, String> = pairs
250        .into_iter()
251        .filter(|(k, _)| !RESERVED_PARAMS.contains(&k.as_str()))
252        .filter(|(k, _)| is_allowed_identifier(k, &config.allowed_filters))
253        .collect();
254
255    QueryParams {
256        page,
257        page_size,
258        sort_by,
259        sort_order,
260        filters,
261    }
262}
263
264fn is_allowed_identifier(value: &str, allow_list: &[String]) -> bool {
265    validate_identifier_path(value).is_ok()
266        && (allow_list.is_empty() || allow_list.iter().any(|allowed| allowed == value))
267}
268
269/// Minimal query-string parser: splits on `&`, then on `=`.
270fn parse_pairs(query: &str) -> HashMap<String, String> {
271    query
272        .split('&')
273        .filter(|s| !s.is_empty())
274        .filter_map(|pair| {
275            let mut parts = pair.splitn(2, '=');
276            let key = parts.next()?.trim();
277            let value = parts.next().unwrap_or("").trim();
278            if key.is_empty() {
279                None
280            } else {
281                Some((key.to_owned(), value.to_owned()))
282            }
283        })
284        .collect()
285}
286
287// ---------------------------------------------------------------------------
288// Tests
289// ---------------------------------------------------------------------------
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn default_config() -> QueryConfig {
296        QueryConfig::default()
297    }
298
299    // -- basic parsing -------------------------------------------------------
300
301    #[test]
302    fn parse_basic_page_and_page_size() {
303        let params = parse_query_string("page=2&page_size=10", &default_config());
304        assert_eq!(params.page, 2);
305        assert_eq!(params.page_size, 10);
306    }
307
308    #[test]
309    fn parse_page_size_alias_camel_case() {
310        let params = parse_query_string("pageSize=15", &default_config());
311        assert_eq!(params.page_size, 15);
312    }
313
314    #[test]
315    fn parse_page_size_alias_per_page() {
316        let params = parse_query_string("per_page=25", &default_config());
317        assert_eq!(params.page_size, 25);
318    }
319
320    // -- clamping ------------------------------------------------------------
321
322    #[test]
323    fn clamp_page_size_to_max() {
324        let config = QueryConfig {
325            max_page_size: 50,
326            ..default_config()
327        };
328        let params = parse_query_string("page_size=200", &config);
329        assert_eq!(params.page_size, 50);
330    }
331
332    #[test]
333    fn clamp_page_size_minimum_to_one() {
334        let params = parse_query_string("page_size=0", &default_config());
335        assert_eq!(params.page_size, 1);
336    }
337
338    #[test]
339    fn invalid_max_page_size_does_not_panic() {
340        let config = QueryConfig {
341            default_page_size: 0,
342            max_page_size: 0,
343            ..default_config()
344        };
345        let params = parse_query_string("page_size=10", &config);
346        assert_eq!(params.page_size, 1);
347    }
348
349    #[test]
350    fn clamp_page_min_to_one() {
351        let params = parse_query_string("page=0", &default_config());
352        assert_eq!(params.page, 1);
353    }
354
355    #[test]
356    fn negative_page_clamps_to_one() {
357        let params = parse_query_string("page=-5", &default_config());
358        assert_eq!(params.page, 1);
359    }
360
361    // -- defaults ------------------------------------------------------------
362
363    #[test]
364    fn defaults_when_empty_query() {
365        let params = parse_query_string("", &default_config());
366        assert_eq!(params.page, 1);
367        assert_eq!(params.page_size, 20);
368        assert!(params.sort_by.is_none());
369        assert_eq!(params.sort_order, SortOrder::Asc);
370        assert!(params.filters.is_empty());
371    }
372
373    #[test]
374    fn defaults_when_no_params_provided() {
375        let params = parse_query_string("unrelated=foo", &default_config());
376        assert_eq!(params.page, 1);
377        assert_eq!(params.page_size, 20);
378    }
379
380    // -- sort ----------------------------------------------------------------
381
382    #[test]
383    fn parse_sort_and_order() {
384        let params = parse_query_string("sort=name&order=desc", &default_config());
385        assert_eq!(params.sort_by.as_deref(), Some("name"));
386        assert_eq!(params.sort_order, SortOrder::Desc);
387    }
388
389    #[test]
390    fn parse_sort_defaults_to_asc() {
391        let params = parse_query_string("sort=created_at", &default_config());
392        assert_eq!(params.sort_by.as_deref(), Some("created_at"));
393        assert_eq!(params.sort_order, SortOrder::Asc);
394    }
395
396    #[test]
397    fn sort_rejected_when_not_in_allowed_sorts() {
398        let config = QueryConfig {
399            allowed_sorts: vec!["name".into(), "created_at".into()],
400            ..default_config()
401        };
402        let params = parse_query_string("sort=email", &config);
403        assert!(params.sort_by.is_none());
404    }
405
406    #[test]
407    fn unsafe_sort_identifier_rejected_without_allow_list() {
408        let params = parse_query_string("sort=name;DROP TABLE users&order=desc", &default_config());
409        assert!(params.sort_by.is_none());
410    }
411
412    #[test]
413    fn sort_accepted_when_in_allowed_sorts() {
414        let config = QueryConfig {
415            allowed_sorts: vec!["name".into()],
416            ..default_config()
417        };
418        let params = parse_query_string("sort=name", &config);
419        assert_eq!(params.sort_by.as_deref(), Some("name"));
420    }
421
422    // -- filters -------------------------------------------------------------
423
424    #[test]
425    fn parse_filters() {
426        let params = parse_query_string("status=active&type=premium", &default_config());
427        assert_eq!(params.filters.get("status").unwrap(), "active");
428        assert_eq!(params.filters.get("type").unwrap(), "premium");
429    }
430
431    #[test]
432    fn allowed_filters_enforcement() {
433        let config = QueryConfig {
434            allowed_filters: vec!["status".into()],
435            ..default_config()
436        };
437        let params = parse_query_string("status=active&type=premium", &config);
438        assert_eq!(params.filters.get("status").unwrap(), "active");
439        assert!(!params.filters.contains_key("type"));
440    }
441
442    #[test]
443    fn unsafe_filter_identifier_rejected_without_allow_list() {
444        let params = parse_query_string("status;DELETE=active&safe_filter=yes", &default_config());
445        assert!(!params.filters.contains_key("status;DELETE"));
446        assert_eq!(
447            params.filters.get("safe_filter").map(String::as_str),
448            Some("yes")
449        );
450    }
451
452    #[test]
453    fn reserved_params_not_treated_as_filters() {
454        let params = parse_query_string(
455            "page=1&page_size=10&sort=name&order=asc&status=active",
456            &default_config(),
457        );
458        assert!(!params.filters.contains_key("page"));
459        assert!(!params.filters.contains_key("page_size"));
460        assert!(!params.filters.contains_key("sort"));
461        assert!(!params.filters.contains_key("order"));
462        assert_eq!(params.filters.get("status").unwrap(), "active");
463    }
464
465    // -- limit / offset helpers ----------------------------------------------
466
467    #[test]
468    fn limit_and_offset() {
469        let params = parse_query_string("page=3&page_size=10", &default_config());
470        assert_eq!(params.limit(), 10);
471        assert_eq!(params.offset(), 20);
472    }
473
474    #[test]
475    fn offset_is_zero_for_first_page() {
476        let params = parse_query_string("page=1&page_size=25", &default_config());
477        assert_eq!(params.offset(), 0);
478    }
479
480    // -- to_find_opts --------------------------------------------------------
481
482    #[test]
483    fn to_find_opts_basic() {
484        let params = parse_query_string(
485            "page=2&page_size=10&sort=name&order=desc&status=active",
486            &default_config(),
487        );
488        let opts = params.to_find_opts();
489        assert_eq!(opts.limit, Some(10));
490        assert_eq!(opts.offset, Some(10));
491        assert_eq!(opts.order_by, vec!["name DESC"]);
492        assert!(opts.filters.iter().any(|(k, _)| k == "status"));
493    }
494
495    // -- Pagination ----------------------------------------------------------
496
497    #[test]
498    fn pagination_math() {
499        let p = Pagination::new(1, 10, 95);
500        assert_eq!(p.total_pages, 10);
501    }
502
503    #[test]
504    fn pagination_exact_division() {
505        let p = Pagination::new(1, 10, 100);
506        assert_eq!(p.total_pages, 10);
507    }
508
509    #[test]
510    fn pagination_zero_total() {
511        let p = Pagination::new(1, 10, 0);
512        assert_eq!(p.total_pages, 0);
513    }
514
515    #[test]
516    fn pagination_single_item() {
517        let p = Pagination::new(1, 10, 1);
518        assert_eq!(p.total_pages, 1);
519    }
520
521    // -- PaginatedResult -----------------------------------------------------
522
523    #[test]
524    fn paginated_result_construction() {
525        let result = PaginatedResult::new(vec!["a", "b", "c"], 2, 10, 25);
526        assert_eq!(result.data.len(), 3);
527        assert_eq!(result.pagination.page, 2);
528        assert_eq!(result.pagination.page_size, 10);
529        assert_eq!(result.pagination.total, 25);
530        assert_eq!(result.pagination.total_pages, 3);
531    }
532
533    // -- SortOrder -----------------------------------------------------------
534
535    #[test]
536    fn sort_order_sql_representation() {
537        assert_eq!(SortOrder::Asc.as_sql(), "ASC");
538        assert_eq!(SortOrder::Desc.as_sql(), "DESC");
539    }
540
541    #[test]
542    fn sort_order_display() {
543        assert_eq!(format!("{}", SortOrder::Asc), "ASC");
544        assert_eq!(format!("{}", SortOrder::Desc), "DESC");
545    }
546}