Skip to main content

actix_admin/routes/
query.rs

1//! Unified request-side query parsing for list-like admin routes.
2//!
3//! `ListQuery` merges what used to be `Params`, `SearchParams`, ad-hoc
4//! form-body field parsing (in `delete_many`) and `parse_filters_from_query`
5//! into a single value that:
6//!
7//! * ignores unknown / unparseable keys (never panics on user input),
8//! * normalises defaults against a [`ActixAdminViewModel`],
9//! * knows how to round-trip itself back into a URL query string,
10//! * can produce an [`ActixAdminViewModelParams`] for the ORM layer.
11//!
12//! Both the querystring (list / export / search) and the form body
13//! (delete_many) go through the same parser so the URL-encoded shape of a
14//! filter/pagination state is defined in exactly one place.
15
16use serde_derive::Deserialize;
17
18use crate::view_model::{
19    ActixAdminFilterOperator, ActixAdminViewModelFilter, ActixAdminViewModelParams,
20};
21use crate::{ActixAdminViewModel, SortOrder};
22
23use super::DEFAULT_ENTITIES_PER_PAGE;
24
25/// The subset of a request's query string this crate understands. Extra
26/// unknown keys are ignored.
27#[derive(Debug, Deserialize, Default, Clone)]
28pub struct Params {
29    pub(crate) page: Option<u64>,
30    pub(crate) entities_per_page: Option<u64>,
31    pub(crate) search: Option<String>,
32    pub(crate) sort_by: Option<String>,
33    pub(crate) sort_order: Option<SortOrder>,
34}
35
36impl Params {
37    /// Parse `Params` from a raw querystring, ignoring unknown keys. Never fails.
38    pub fn from_query(qs: &str) -> Self {
39        serde_urlencoded::from_str::<Self>(qs).unwrap_or_default()
40    }
41}
42
43/// Parse `filter_<name>=value` querystring fragments into filters, without
44/// panicking on malformed input.
45///
46/// Also recognises operator selectors of the form `filter_<name>__op=<op>`
47/// (see [`ActixAdminFilterOperator`]'s `FromStr` impl). Operators are merged
48/// onto the corresponding value filter; a bare `__op` without a matching
49/// value is silently ignored.
50pub fn parse_filters_from_query(qs: &str) -> Vec<ActixAdminViewModelFilter> {
51    use std::collections::HashMap;
52
53    let mut values: Vec<(String, Option<String>)> = Vec::new();
54    let mut operators: HashMap<String, ActixAdminFilterOperator> = HashMap::new();
55
56    for (key, value) in form_urlencoded::parse(qs.as_bytes()) {
57        let Some(rest) = key.strip_prefix("filter_") else {
58            continue;
59        };
60        if let Some(name) = rest.strip_suffix("__op") {
61            if let Ok(op) = value.parse::<ActixAdminFilterOperator>() {
62                operators.insert(name.to_string(), op);
63            }
64            continue;
65        }
66        let v = if value.is_empty() {
67            None
68        } else {
69            Some(value.into_owned())
70        };
71        values.push((rest.to_string(), v));
72    }
73
74    values
75        .into_iter()
76        .map(|(name, value)| {
77            let operator = operators.remove(&name);
78            ActixAdminViewModelFilter {
79                name,
80                value,
81                values: None,
82                filter_type: None,
83                foreign_key: None,
84                operators: Vec::new(),
85                operator,
86            }
87        })
88        .collect()
89}
90
91/// Fully-resolved list-page query state: pagination, search, sort and
92/// filters, normalized against the entity's view model.
93#[derive(Debug, Clone)]
94pub struct ListQuery {
95    pub page: u64,
96    pub entities_per_page: u64,
97    pub search: String,
98    pub sort_by: String,
99    pub sort_order: SortOrder,
100    pub filters: Vec<ActixAdminViewModelFilter>,
101}
102
103impl ListQuery {
104    /// Build a `ListQuery` from a raw querystring, filling in defaults
105    /// from `view_model`. Never fails.
106    pub fn from_query(qs: &str, view_model: &ActixAdminViewModel) -> Self {
107        let params = Params::from_query(qs);
108        let filters = parse_filters_from_query(qs);
109        Self::from_params_and_filters(params, filters, view_model)
110    }
111
112    /// Build a `ListQuery` from a parsed `Params` plus a filter list.
113    pub fn from_params_and_filters(
114        params: Params,
115        filters: Vec<ActixAdminViewModelFilter>,
116        view_model: &ActixAdminViewModel,
117    ) -> Self {
118        ListQuery {
119            // Clamp to >= 1: a user-supplied `page=0` would otherwise
120            // underflow `p - 1` in the paginator, and `entities_per_page=0`
121            // would panic SeaORM's paginator (zero page size).
122            page: params.page.unwrap_or(1).max(1),
123            entities_per_page: params
124                .entities_per_page
125                .unwrap_or(DEFAULT_ENTITIES_PER_PAGE)
126                .max(1),
127            search: params.search.unwrap_or_default(),
128            sort_by: params
129                .sort_by
130                .unwrap_or_else(|| view_model.primary_key.clone()),
131            sort_order: params.sort_order.unwrap_or(SortOrder::Asc),
132            filters,
133        }
134    }
135
136    /// Build a `ListQuery` from a form body of `(key, value)` pairs, used by
137    /// `delete_many` where the pagination state travels in the POST body
138    /// rather than the querystring.
139    pub fn from_form(form: &[(String, String)], view_model: &ActixAdminViewModel) -> Self {
140        let mut params = Params::default();
141        for (k, v) in form {
142            match k.as_str() {
143                "page" => params.page = v.parse().ok(),
144                "entities_per_page" => params.entities_per_page = v.parse().ok(),
145                "search" => params.search = Some(v.clone()),
146                "sort_by" => params.sort_by = Some(v.clone()),
147                "sort_order" => {
148                    params.sort_order = match v.as_str() {
149                        "Asc" => Some(SortOrder::Asc),
150                        "Desc" => Some(SortOrder::Desc),
151                        _ => None,
152                    }
153                }
154                _ => {}
155            }
156        }
157        Self::from_params_and_filters(params, Vec::new(), view_model)
158    }
159
160    /// Serialize back into a URL querystring (without a leading `?`).
161    /// Uses `serde_urlencoded` so encoding matches how we parse.
162    pub fn to_query_string(&self) -> String {
163        let pairs: Vec<(&str, String)> = vec![
164            ("page", self.page.to_string()),
165            ("entities_per_page", self.entities_per_page.to_string()),
166            ("search", self.search.clone()),
167            ("sort_by", self.sort_by.clone()),
168            ("sort_order", self.sort_order.to_string()),
169        ];
170        serde_urlencoded::to_string(&pairs).unwrap_or_default()
171    }
172
173    /// Convert into the ORM-facing `ActixAdminViewModelParams`. `paginated`
174    /// controls whether page/entities_per_page are forwarded; `export_csv`
175    /// passes `false` to fetch all rows.
176    pub fn to_view_model_params(
177        &self,
178        tenant_ref: Option<i32>,
179        paginated: bool,
180    ) -> ActixAdminViewModelParams {
181        ActixAdminViewModelParams {
182            page: if paginated { Some(self.page) } else { None },
183            entities_per_page: if paginated {
184                Some(self.entities_per_page)
185            } else {
186                None
187            },
188            viewmodel_filter: self.filters.clone(),
189            search: self.search.clone(),
190            sort_by: self.sort_by.clone(),
191            sort_order: self.sort_order.clone(),
192            tenant_ref,
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn params_from_query_handles_garbage() {
203        let p = Params::from_query("page=nope&entities_per_page=xyz&random=1");
204        assert!(p.page.is_none());
205        assert!(p.entities_per_page.is_none());
206    }
207
208    #[test]
209    fn params_from_query_parses_valid_input() {
210        let p = Params::from_query(
211            "page=3&entities_per_page=20&search=foo&sort_by=name&sort_order=Desc",
212        );
213        assert_eq!(p.page, Some(3));
214        assert_eq!(p.entities_per_page, Some(20));
215        assert_eq!(p.search.as_deref(), Some("foo"));
216        assert_eq!(p.sort_by.as_deref(), Some("name"));
217        assert!(matches!(p.sort_order, Some(SortOrder::Desc)));
218    }
219
220    #[test]
221    fn filter_parser_extracts_filter_prefixed_pairs() {
222        let filters = parse_filters_from_query("page=1&filter_status=active&filter_owner=&other=x");
223        let names: Vec<_> = filters.iter().map(|f| f.name.as_str()).collect();
224        assert_eq!(names, vec!["status", "owner"]);
225        assert_eq!(filters[0].value.as_deref(), Some("active"));
226        assert!(filters[1].value.is_none());
227    }
228
229    #[test]
230    fn filter_parser_survives_odd_percent_encoding() {
231        let _ = parse_filters_from_query("filter_x=%ZZ");
232        let _ = parse_filters_from_query("&&filter_=v&filter_a");
233    }
234
235    #[test]
236    fn filter_parser_handles_equals_in_value() {
237        let filters = parse_filters_from_query("filter_query=a=b");
238        assert_eq!(filters.len(), 1);
239        assert_eq!(filters[0].value.as_deref(), Some("a=b"));
240    }
241
242    #[test]
243    fn filter_parser_decodes_plus_as_space_in_key_and_value() {
244        let filters = parse_filters_from_query("filter_Post+with+Tom+Select=hello+world");
245        assert_eq!(filters.len(), 1);
246        assert_eq!(filters[0].name, "Post with Tom Select");
247        assert_eq!(filters[0].value.as_deref(), Some("hello world"));
248    }
249
250    #[test]
251    fn filter_parser_decodes_percent_encoded_key_and_value() {
252        let filters = parse_filters_from_query("filter_Post%20with%20Tom%20Select=a%2Fb");
253        assert_eq!(filters.len(), 1);
254        assert_eq!(filters[0].name, "Post with Tom Select");
255        assert_eq!(filters[0].value.as_deref(), Some("a/b"));
256    }
257}