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    /// Adds a filter programmatically (e.g., for tenant scoping).
42    pub fn add(&mut self, field: String, value: String) {
43        self.filters.push(FilterParam { field, value });
44    }
45
46    /// Returns true if there are no filters.
47    pub fn is_empty(&self) -> bool {
48        self.filters.is_empty()
49    }
50
51    /// Appends WHERE clauses to the given SQL string.
52    ///
53    /// `param_offset` is the starting `$N` parameter index.
54    /// Returns the new parameter offset after appending.
55    pub fn apply_to_sql(&self, sql: &mut String, has_where: bool, param_offset: usize) -> usize {
56        let mut offset = param_offset;
57        for (i, filter) in self.filters.iter().enumerate() {
58            if i == 0 && !has_where {
59                sql.push_str(" WHERE ");
60            } else {
61                sql.push_str(" AND ");
62            }
63            sql.push_str(&format!("\"{}\" = ${}", filter.field, offset));
64            offset += 1;
65        }
66        offset
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn parse_filter_params() {
76        let mut params = HashMap::new();
77        params.insert("filter[role]".to_string(), "admin".to_string());
78        params.insert("filter[org_id]".to_string(), "abc-123".to_string());
79        params.insert("other_param".to_string(), "ignored".to_string());
80        params.insert("filter[secret]".to_string(), "blocked".to_string());
81
82        let allowed = vec!["role".to_string(), "org_id".to_string()];
83        let fs = FilterSet::from_query_params(&params, &allowed);
84
85        assert_eq!(fs.filters.len(), 2);
86        assert!(fs
87            .filters
88            .iter()
89            .any(|f| f.field == "role" && f.value == "admin"));
90        assert!(fs
91            .filters
92            .iter()
93            .any(|f| f.field == "org_id" && f.value == "abc-123"));
94    }
95
96    #[test]
97    fn filter_disallowed_fields_ignored() {
98        let mut params = HashMap::new();
99        params.insert("filter[secret]".to_string(), "value".to_string());
100
101        let allowed: Vec<String> = vec!["role".to_string()];
102        let fs = FilterSet::from_query_params(&params, &allowed);
103
104        assert!(fs.is_empty());
105    }
106
107    #[test]
108    fn apply_to_sql_no_existing_where() {
109        let fs = FilterSet {
110            filters: vec![
111                FilterParam {
112                    field: "role".to_string(),
113                    value: "admin".to_string(),
114                },
115                FilterParam {
116                    field: "org_id".to_string(),
117                    value: "abc".to_string(),
118                },
119            ],
120        };
121
122        let mut sql = "SELECT * FROM users".to_string();
123        let offset = fs.apply_to_sql(&mut sql, false, 1);
124
125        assert_eq!(
126            sql,
127            "SELECT * FROM users WHERE \"role\" = $1 AND \"org_id\" = $2"
128        );
129        assert_eq!(offset, 3);
130    }
131
132    #[test]
133    fn apply_to_sql_with_existing_where() {
134        let fs = FilterSet {
135            filters: vec![FilterParam {
136                field: "role".to_string(),
137                value: "admin".to_string(),
138            }],
139        };
140
141        let mut sql = "SELECT * FROM users WHERE \"deleted_at\" IS NULL".to_string();
142        let offset = fs.apply_to_sql(&mut sql, true, 1);
143
144        assert_eq!(
145            sql,
146            "SELECT * FROM users WHERE \"deleted_at\" IS NULL AND \"role\" = $1"
147        );
148        assert_eq!(offset, 2);
149    }
150
151    #[test]
152    fn empty_filter_set() {
153        let fs = FilterSet::default();
154        assert!(fs.is_empty());
155
156        let mut sql = "SELECT * FROM users".to_string();
157        let offset = fs.apply_to_sql(&mut sql, false, 1);
158        assert_eq!(sql, "SELECT * FROM users");
159        assert_eq!(offset, 1);
160    }
161}