Skip to main content

shaperail_runtime/db/
filter.rs

1use std::collections::HashMap;
2
3/// A single filter parameter parsed from `?filter[field]=value`.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct FilterParam {
6    /// The field name to filter on.
7    pub field: String,
8    /// The value to match against.
9    pub value: String,
10}
11
12/// A set of filter parameters for a query.
13#[derive(Debug, Clone, Default)]
14pub struct FilterSet {
15    pub filters: Vec<FilterParam>,
16}
17
18impl FilterSet {
19    /// Parses filter parameters from a query string map.
20    ///
21    /// Expects keys in the format `filter[field_name]`.
22    /// Only includes filters for fields that are in the `allowed_fields` list.
23    pub fn from_query_params(params: &HashMap<String, String>, allowed_fields: &[String]) -> Self {
24        let mut filters = Vec::new();
25        for (key, value) in params {
26            if let Some(field) = key
27                .strip_prefix("filter[")
28                .and_then(|s| s.strip_suffix(']'))
29            {
30                if allowed_fields.iter().any(|f| f == field) {
31                    filters.push(FilterParam {
32                        field: field.to_string(),
33                        value: value.clone(),
34                    });
35                }
36            }
37        }
38        FilterSet { filters }
39    }
40
41    /// Returns true if there are no filters.
42    pub fn is_empty(&self) -> bool {
43        self.filters.is_empty()
44    }
45
46    /// Appends WHERE clauses to the given SQL string.
47    ///
48    /// `param_offset` is the starting `$N` parameter index.
49    /// Returns the new parameter offset after appending.
50    pub fn apply_to_sql(&self, sql: &mut String, has_where: bool, param_offset: usize) -> usize {
51        let mut offset = param_offset;
52        for (i, filter) in self.filters.iter().enumerate() {
53            if i == 0 && !has_where {
54                sql.push_str(" WHERE ");
55            } else {
56                sql.push_str(" AND ");
57            }
58            sql.push_str(&format!("\"{}\" = ${}", filter.field, offset));
59            offset += 1;
60        }
61        offset
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn parse_filter_params() {
71        let mut params = HashMap::new();
72        params.insert("filter[role]".to_string(), "admin".to_string());
73        params.insert("filter[org_id]".to_string(), "abc-123".to_string());
74        params.insert("other_param".to_string(), "ignored".to_string());
75        params.insert("filter[secret]".to_string(), "blocked".to_string());
76
77        let allowed = vec!["role".to_string(), "org_id".to_string()];
78        let fs = FilterSet::from_query_params(&params, &allowed);
79
80        assert_eq!(fs.filters.len(), 2);
81        assert!(fs
82            .filters
83            .iter()
84            .any(|f| f.field == "role" && f.value == "admin"));
85        assert!(fs
86            .filters
87            .iter()
88            .any(|f| f.field == "org_id" && f.value == "abc-123"));
89    }
90
91    #[test]
92    fn filter_disallowed_fields_ignored() {
93        let mut params = HashMap::new();
94        params.insert("filter[secret]".to_string(), "value".to_string());
95
96        let allowed: Vec<String> = vec!["role".to_string()];
97        let fs = FilterSet::from_query_params(&params, &allowed);
98
99        assert!(fs.is_empty());
100    }
101
102    #[test]
103    fn apply_to_sql_no_existing_where() {
104        let fs = FilterSet {
105            filters: vec![
106                FilterParam {
107                    field: "role".to_string(),
108                    value: "admin".to_string(),
109                },
110                FilterParam {
111                    field: "org_id".to_string(),
112                    value: "abc".to_string(),
113                },
114            ],
115        };
116
117        let mut sql = "SELECT * FROM users".to_string();
118        let offset = fs.apply_to_sql(&mut sql, false, 1);
119
120        assert_eq!(
121            sql,
122            "SELECT * FROM users WHERE \"role\" = $1 AND \"org_id\" = $2"
123        );
124        assert_eq!(offset, 3);
125    }
126
127    #[test]
128    fn apply_to_sql_with_existing_where() {
129        let fs = FilterSet {
130            filters: vec![FilterParam {
131                field: "role".to_string(),
132                value: "admin".to_string(),
133            }],
134        };
135
136        let mut sql = "SELECT * FROM users WHERE \"deleted_at\" IS NULL".to_string();
137        let offset = fs.apply_to_sql(&mut sql, true, 1);
138
139        assert_eq!(
140            sql,
141            "SELECT * FROM users WHERE \"deleted_at\" IS NULL AND \"role\" = $1"
142        );
143        assert_eq!(offset, 2);
144    }
145
146    #[test]
147    fn empty_filter_set() {
148        let fs = FilterSet::default();
149        assert!(fs.is_empty());
150
151        let mut sql = "SELECT * FROM users".to_string();
152        let offset = fs.apply_to_sql(&mut sql, false, 1);
153        assert_eq!(sql, "SELECT * FROM users");
154        assert_eq!(offset, 1);
155    }
156}