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` —
206///   items per page (default from 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 non-empty).
210pub fn parse_query_string(query: &str, config: &QueryConfig) -> QueryParams {
211    let config = config.sanitized();
212    let pairs = parse_pairs(query);
213
214    let page = pairs
215        .get("page")
216        .and_then(|v| v.parse::<i64>().ok())
217        .unwrap_or(1)
218        .max(1);
219
220    let raw_page_size = pairs
221        .get("page_size")
222        .or_else(|| pairs.get("pageSize"))
223        .or_else(|| pairs.get("per_page"))
224        .and_then(|v| v.parse::<i64>().ok())
225        .unwrap_or(config.default_page_size);
226    let page_size = raw_page_size.clamp(1, config.max_page_size);
227
228    let sort_by = pairs.get("sort").and_then(|v| {
229        let v = v.trim();
230        if v.is_empty() {
231            return None;
232        }
233        if is_allowed_identifier(v, &config.allowed_sorts) {
234            Some(v.to_owned())
235        } else {
236            None
237        }
238    });
239
240    let sort_order = pairs
241        .get("order")
242        .map(|v| match v.to_ascii_lowercase().as_str() {
243            "desc" => SortOrder::Desc,
244            _ => SortOrder::Asc,
245        })
246        .unwrap_or_default();
247
248    let filters: HashMap<String, String> = pairs
249        .into_iter()
250        .filter(|(k, _)| !RESERVED_PARAMS.contains(&k.as_str()))
251        .filter(|(k, _)| is_allowed_identifier(k, &config.allowed_filters))
252        .collect();
253
254    QueryParams {
255        page,
256        page_size,
257        sort_by,
258        sort_order,
259        filters,
260    }
261}
262
263fn is_allowed_identifier(value: &str, allow_list: &[String]) -> bool {
264    validate_identifier_path(value).is_ok()
265        && (allow_list.is_empty() || allow_list.iter().any(|allowed| allowed == value))
266}
267
268/// Minimal query-string parser: splits on `&`, then on `=`.
269fn parse_pairs(query: &str) -> HashMap<String, String> {
270    query
271        .split('&')
272        .filter(|s| !s.is_empty())
273        .filter_map(|pair| {
274            let mut parts = pair.splitn(2, '=');
275            let key = parts.next()?.trim();
276            let value = parts.next().unwrap_or("").trim();
277            if key.is_empty() {
278                None
279            } else {
280                Some((key.to_owned(), value.to_owned()))
281            }
282        })
283        .collect()
284}
285
286#[cfg(test)]
287mod coverage_tests {
288    use super::*;
289
290    #[test]
291    fn query_params_ignore_blank_sort_and_blank_pair_keys() {
292        let params = parse_query_string(
293            "sort= &order=desc&&=ignored&status=open",
294            &QueryConfig::default(),
295        );
296
297        assert_eq!(params.sort_by, None);
298        assert_eq!(params.sort_order, SortOrder::Desc);
299        assert_eq!(
300            params.filters.get("status").map(String::as_str),
301            Some("open")
302        );
303        assert!(!params.filters.contains_key(""));
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Tests
309// ---------------------------------------------------------------------------
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn default_config() -> QueryConfig {
316        QueryConfig::default()
317    }
318
319    // -- basic parsing -------------------------------------------------------
320
321    #[test]
322    fn parse_basic_page_and_page_size() {
323        let params = parse_query_string("page=2&page_size=10", &default_config());
324        assert_eq!(params.page, 2);
325        assert_eq!(params.page_size, 10);
326    }
327
328    #[test]
329    fn parse_page_size_alias_camel_case() {
330        let params = parse_query_string("pageSize=15", &default_config());
331        assert_eq!(params.page_size, 15);
332    }
333
334    #[test]
335    fn parse_page_size_alias_per_page() {
336        let params = parse_query_string("per_page=25", &default_config());
337        assert_eq!(params.page_size, 25);
338    }
339
340    // -- clamping ------------------------------------------------------------
341
342    #[test]
343    fn clamp_page_size_to_max() {
344        let config = QueryConfig {
345            max_page_size: 50,
346            ..default_config()
347        };
348        let params = parse_query_string("page_size=200", &config);
349        assert_eq!(params.page_size, 50);
350    }
351
352    #[test]
353    fn clamp_page_size_minimum_to_one() {
354        let params = parse_query_string("page_size=0", &default_config());
355        assert_eq!(params.page_size, 1);
356    }
357
358    #[test]
359    fn invalid_max_page_size_does_not_panic() {
360        let config = QueryConfig {
361            default_page_size: 0,
362            max_page_size: 0,
363            ..default_config()
364        };
365        let params = parse_query_string("page_size=10", &config);
366        assert_eq!(params.page_size, 1);
367    }
368
369    #[test]
370    fn clamp_page_min_to_one() {
371        let params = parse_query_string("page=0", &default_config());
372        assert_eq!(params.page, 1);
373    }
374
375    #[test]
376    fn negative_page_clamps_to_one() {
377        let params = parse_query_string("page=-5", &default_config());
378        assert_eq!(params.page, 1);
379    }
380
381    // -- defaults ------------------------------------------------------------
382
383    #[test]
384    fn defaults_when_empty_query() {
385        let params = parse_query_string("", &default_config());
386        assert_eq!(params.page, 1);
387        assert_eq!(params.page_size, 20);
388        assert!(params.sort_by.is_none());
389        assert_eq!(params.sort_order, SortOrder::Asc);
390        assert!(params.filters.is_empty());
391    }
392
393    #[test]
394    fn defaults_when_no_params_provided() {
395        let params = parse_query_string("unrelated=foo", &default_config());
396        assert_eq!(params.page, 1);
397        assert_eq!(params.page_size, 20);
398    }
399
400    // -- sort ----------------------------------------------------------------
401
402    #[test]
403    fn parse_sort_and_order() {
404        let params = parse_query_string("sort=name&order=desc", &default_config());
405        assert_eq!(params.sort_by.as_deref(), Some("name"));
406        assert_eq!(params.sort_order, SortOrder::Desc);
407    }
408
409    #[test]
410    fn parse_sort_defaults_to_asc() {
411        let params = parse_query_string("sort=created_at", &default_config());
412        assert_eq!(params.sort_by.as_deref(), Some("created_at"));
413        assert_eq!(params.sort_order, SortOrder::Asc);
414    }
415
416    #[test]
417    fn sort_rejected_when_not_in_allowed_sorts() {
418        let config = QueryConfig {
419            allowed_sorts: vec!["name".into(), "created_at".into()],
420            ..default_config()
421        };
422        let params = parse_query_string("sort=email", &config);
423        assert!(params.sort_by.is_none());
424    }
425
426    #[test]
427    fn unsafe_sort_identifier_rejected_without_allow_list() {
428        let params = parse_query_string("sort=name;DROP TABLE users&order=desc", &default_config());
429        assert!(params.sort_by.is_none());
430    }
431
432    #[test]
433    fn sort_accepted_when_in_allowed_sorts() {
434        let config = QueryConfig {
435            allowed_sorts: vec!["name".into()],
436            ..default_config()
437        };
438        let params = parse_query_string("sort=name", &config);
439        assert_eq!(params.sort_by.as_deref(), Some("name"));
440    }
441
442    // -- filters -------------------------------------------------------------
443
444    #[test]
445    fn parse_filters() {
446        let params = parse_query_string("status=active&type=premium", &default_config());
447        assert_eq!(params.filters.get("status").unwrap(), "active");
448        assert_eq!(params.filters.get("type").unwrap(), "premium");
449    }
450
451    #[test]
452    fn allowed_filters_enforcement() {
453        let config = QueryConfig {
454            allowed_filters: vec!["status".into()],
455            ..default_config()
456        };
457        let params = parse_query_string("status=active&type=premium", &config);
458        assert_eq!(params.filters.get("status").unwrap(), "active");
459        assert!(!params.filters.contains_key("type"));
460    }
461
462    #[test]
463    fn unsafe_filter_identifier_rejected_without_allow_list() {
464        let params = parse_query_string("status;DELETE=active&safe_filter=yes", &default_config());
465        assert!(!params.filters.contains_key("status;DELETE"));
466        assert_eq!(
467            params.filters.get("safe_filter").map(String::as_str),
468            Some("yes")
469        );
470    }
471
472    #[test]
473    fn reserved_params_not_treated_as_filters() {
474        let params = parse_query_string(
475            "page=1&page_size=10&sort=name&order=asc&status=active",
476            &default_config(),
477        );
478        assert!(!params.filters.contains_key("page"));
479        assert!(!params.filters.contains_key("page_size"));
480        assert!(!params.filters.contains_key("sort"));
481        assert!(!params.filters.contains_key("order"));
482        assert_eq!(params.filters.get("status").unwrap(), "active");
483    }
484
485    // -- limit / offset helpers ----------------------------------------------
486
487    #[test]
488    fn limit_and_offset() {
489        let params = parse_query_string("page=3&page_size=10", &default_config());
490        assert_eq!(params.limit(), 10);
491        assert_eq!(params.offset(), 20);
492    }
493
494    #[test]
495    fn offset_is_zero_for_first_page() {
496        let params = parse_query_string("page=1&page_size=25", &default_config());
497        assert_eq!(params.offset(), 0);
498    }
499
500    // -- to_find_opts --------------------------------------------------------
501
502    #[test]
503    fn to_find_opts_basic() {
504        let params = parse_query_string(
505            "page=2&page_size=10&sort=name&order=desc&status=active",
506            &default_config(),
507        );
508        let opts = params.to_find_opts();
509        assert_eq!(opts.limit, Some(10));
510        assert_eq!(opts.offset, Some(10));
511        assert_eq!(opts.order_by, vec!["name DESC"]);
512        assert!(opts.filters.iter().any(|(k, _)| k == "status"));
513    }
514
515    // -- Pagination ----------------------------------------------------------
516
517    #[test]
518    fn pagination_math() {
519        let p = Pagination::new(1, 10, 95);
520        assert_eq!(p.total_pages, 10);
521    }
522
523    #[test]
524    fn pagination_exact_division() {
525        let p = Pagination::new(1, 10, 100);
526        assert_eq!(p.total_pages, 10);
527    }
528
529    #[test]
530    fn pagination_zero_total() {
531        let p = Pagination::new(1, 10, 0);
532        assert_eq!(p.total_pages, 0);
533    }
534
535    #[test]
536    fn pagination_single_item() {
537        let p = Pagination::new(1, 10, 1);
538        assert_eq!(p.total_pages, 1);
539    }
540
541    // -- PaginatedResult -----------------------------------------------------
542
543    #[test]
544    fn paginated_result_construction() {
545        let result = PaginatedResult::new(vec!["a", "b", "c"], 2, 10, 25);
546        assert_eq!(result.data.len(), 3);
547        assert_eq!(result.pagination.page, 2);
548        assert_eq!(result.pagination.page_size, 10);
549        assert_eq!(result.pagination.total, 25);
550        assert_eq!(result.pagination.total_pages, 3);
551    }
552
553    // -- SortOrder -----------------------------------------------------------
554
555    #[test]
556    fn sort_order_sql_representation() {
557        assert_eq!(SortOrder::Asc.as_sql(), "ASC");
558        assert_eq!(SortOrder::Desc.as_sql(), "DESC");
559    }
560
561    #[test]
562    fn sort_order_display() {
563        assert_eq!(format!("{}", SortOrder::Asc), "ASC");
564        assert_eq!(format!("{}", SortOrder::Desc), "DESC");
565    }
566}