Skip to main content

hyle/
query.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4
5// ── Mutation input ─────────────────────────────────────────────────────────────
6
7/// The input passed to a mutation (create, update, or delete).
8///
9/// `model` is the model name (mirrors `Query.model`), allowing generic adapters
10/// to route to the correct endpoint without knowing the model at compile time.
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12pub struct MutateInput {
13    /// The model name — e.g. `"user"`.
14    pub model: String,
15    /// The row id when editing or deleting an existing record; `None` for creates.
16    pub id: Option<JsonValue>,
17    /// The form field values to submit.
18    pub data: IndexMap<String, String>,
19}
20
21// ── URL query string parsing ───────────────────────────────────────────────────
22
23/// Parse a URL query string into `(page, per_page, filters)`.
24///
25/// - `page` defaults to `1`; `per_page` defaults to `default_per_page`.
26/// - Keys `"page"` and `"per_page"` are consumed; all others go into `filters`.
27/// - Empty-value keys are omitted from `filters`.
28/// - Percent-encoding and `+`-as-space are decoded.
29///
30/// # Example
31/// ```
32/// let (page, per_page, filters) = hyle::parse_query_params("page=2&name=Ali", 5);
33/// assert_eq!(page, 2);
34/// assert_eq!(per_page, 5);
35/// assert_eq!(filters["name"], "Ali");
36/// ```
37pub fn parse_query_params(
38    query_str: &str,
39    default_per_page: usize,
40) -> (usize, usize, IndexMap<String, String>) {
41    let mut page = 1usize;
42    let mut per_page = default_per_page;
43    let mut filters: IndexMap<String, String> = IndexMap::new();
44
45    for part in query_str.split('&').filter(|s| !s.is_empty()) {
46        let mut kv = part.splitn(2, '=');
47        let k = match kv.next() {
48            Some(k) => k,
49            None => continue,
50        };
51        let v = percent_decode(kv.next().unwrap_or(""));
52        match k {
53            "page"     => { if let Ok(n) = v.parse::<usize>() { page     = n.max(1); } }
54            "per_page" => { if let Ok(n) = v.parse::<usize>() { per_page = n.max(1); } }
55            _ => {
56                if !v.is_empty() {
57                    // Repeated keys (e.g. tags=rust&tags=web) are joined with commas
58                    // so that filter_rows can match against the comma-joined value.
59                    filters
60                        .entry(k.to_owned())
61                        .and_modify(|existing| {
62                            existing.push(',');
63                            existing.push_str(&v);
64                        })
65                        .or_insert(v);
66                }
67            }
68        }
69    }
70
71    (page, per_page, filters)
72}
73
74fn percent_decode(s: &str) -> String {
75    let s = s.replace('+', " ");
76    let bytes = s.as_bytes();
77    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
78    let mut i = 0;
79    while i < bytes.len() {
80        if bytes[i] == b'%' && i + 2 < bytes.len() {
81            if let Ok(byte) = u8::from_str_radix(
82                std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
83                16,
84            ) {
85                out.push(byte);
86                i += 3;
87                continue;
88            }
89        }
90        out.push(bytes[i]);
91        i += 1;
92    }
93    String::from_utf8_lossy(&out).into_owned()
94}
95
96#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct Sort {
99    pub field: String,
100    #[serde(default)]
101    pub ascending: bool,
102}
103
104#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct Query {
107    pub model: String,
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub select: Vec<String>,
110    #[serde(rename = "where", default, skip_serializing_if = "IndexMap::is_empty")]
111    pub where_: IndexMap<String, JsonValue>,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub filters: Vec<Vec<String>>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub page: Option<usize>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub per_page: Option<usize>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub sort: Option<Sort>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub method: Option<String>,
122}
123
124impl Query {
125    pub fn new(model: impl Into<String>) -> Self {
126        Self {
127            model: model.into(),
128            ..Self::default()
129        }
130    }
131
132    pub fn select(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
133        self.select = fields.into_iter().map(Into::into).collect();
134        self
135    }
136
137    pub fn filter_layout<I, J, S>(mut self, rows: I) -> Self
138    where
139        I: IntoIterator<Item = J>,
140        J: IntoIterator<Item = S>,
141        S: Into<String>,
142    {
143        self.filters = rows
144            .into_iter()
145            .map(|row| row.into_iter().map(Into::into).collect())
146            .collect();
147        self
148    }
149
150    pub fn where_eq(mut self, field: impl Into<String>, value: JsonValue) -> Self {
151        self.where_.insert(field.into(), value);
152        self
153    }
154
155    pub fn page(mut self, page: usize, per_page: usize) -> Self {
156        self.page = Some(page);
157        self.per_page = Some(per_page);
158        self
159    }
160
161    pub fn sort_by(mut self, field: impl Into<String>, ascending: bool) -> Self {
162        self.sort = Some(Sort {
163            field: field.into(),
164            ascending,
165        });
166        self
167    }
168}
169
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct Manifest {
173    pub base: String,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub id: Option<JsonValue>,
176    pub fields: Vec<String>,
177    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
178    pub filter: IndexMap<String, JsonValue>,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub lookups: Vec<String>,
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub inlines: Vec<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub page: Option<usize>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub per_page: Option<usize>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub sort: Option<Sort>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub method: Option<String>,
191    /// 2-D filter layout carried from `query.filters` for UI layout derivation.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub filter_fields: Vec<Vec<String>>,
194}
195
196// ── Tests ─────────────────────────────────────────────────────────────────────
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn parse_empty_string() {
204        let (page, per_page, filters) = parse_query_params("", 5);
205        assert_eq!(page, 1);
206        assert_eq!(per_page, 5);
207        assert!(filters.is_empty());
208    }
209
210    #[test]
211    fn parse_page_and_per_page() {
212        let (page, per_page, filters) = parse_query_params("page=3&per_page=10", 5);
213        assert_eq!(page, 3);
214        assert_eq!(per_page, 10);
215        assert!(filters.is_empty());
216    }
217
218    #[test]
219    fn parse_page_zero_clamps_to_one() {
220        let (page, _, _) = parse_query_params("page=0", 5);
221        assert_eq!(page, 1);
222    }
223
224    #[test]
225    fn parse_filters() {
226        let (page, per_page, filters) = parse_query_params("name=Ali&role=admin", 5);
227        assert_eq!(page, 1);
228        assert_eq!(per_page, 5);
229        assert_eq!(filters["name"], "Ali");
230        assert_eq!(filters["role"], "admin");
231    }
232
233    #[test]
234    fn parse_empty_value_omitted_from_filters() {
235        let (_, _, filters) = parse_query_params("name=", 5);
236        assert!(!filters.contains_key("name"));
237    }
238
239    #[test]
240    fn parse_percent_encoding() {
241        let (_, _, filters) = parse_query_params("name=Ali%20Smith", 5);
242        assert_eq!(filters["name"], "Ali Smith");
243    }
244
245    #[test]
246    fn parse_plus_as_space() {
247        let (_, _, filters) = parse_query_params("name=Ali+Smith", 5);
248        assert_eq!(filters["name"], "Ali Smith");
249    }
250
251    #[test]
252    fn parse_default_per_page_respected() {
253        let (_, per_page, _) = parse_query_params("page=2", 20);
254        assert_eq!(per_page, 20);
255    }
256
257    #[test]
258    fn parse_percent_encoding_multibyte_utf8() {
259        // "héllo" encoded: é = %C3%A9
260        let (_, _, filters) = parse_query_params("name=h%C3%A9llo", 5);
261        assert_eq!(filters["name"], "héllo");
262    }
263
264    #[test]
265    fn parse_repeated_key_joins_with_comma() {
266        // Repeated URL params (e.g. ?tags=rust&tags=web from checkboxes) must be
267        // joined with a comma so filter_rows can split them back.
268        let (_, _, filters) = parse_query_params("tags=rust&tags=web", 5);
269        assert_eq!(filters["tags"], "rust,web");
270    }
271
272    #[test]
273    fn parse_single_array_key_not_joined() {
274        // A single occurrence of a key is stored as-is (no trailing comma).
275        let (_, _, filters) = parse_query_params("tags=rust", 5);
276        assert_eq!(filters["tags"], "rust");
277    }
278
279    #[test]
280    fn where_serialises_as_where_not_where_underscore() {
281        use serde_json::json;
282        let q = Query::new("user").where_eq("name", json!("Alice"));
283        let serialised = serde_json::to_value(&q).unwrap();
284        assert!(serialised.get("where").is_some(), "expected 'where' key");
285        assert!(serialised.get("where_").is_none(), "unexpected 'where_' key");
286        let round_trip: Query = serde_json::from_value(serialised).unwrap();
287        assert_eq!(round_trip.where_.get("name"), Some(&json!("Alice")));
288    }
289}