Skip to main content

modkit/api/
select.rs

1//! Field projection support for `$select` `OData` queries.
2//!
3//! This module provides utilities for projecting DTOs based on selected fields.
4//! It allows handlers to filter response objects to only include requested fields.
5
6use serde_json::{Map, Value, json};
7use std::collections::HashSet;
8
9/// Project a JSON value to only include selected fields.
10///
11/// Supports dot notation for nested field selection (e.g., `access_control.read`).
12/// For objects, recursively includes only the specified fields.
13/// For arrays, projects each element.
14/// For other types, returns the value unchanged.
15///
16/// # Arguments
17///
18/// * `value` - The JSON value to project
19/// * `selected_fields` - Set of field names to include (case-insensitive, supports dot notation)
20///
21/// # Returns
22///
23/// A new JSON value containing only the selected fields
24///
25/// # Examples
26///
27/// ```ignore
28/// // Select top-level field
29/// $select=id,name
30///
31/// // Select nested field (includes entire nested object)
32/// $select=access_control
33///
34/// // Select specific nested field
35/// $select=access_control.read,access_control.write
36/// ```
37#[allow(clippy::implicit_hasher)] // we don't care for now about the hasher of the hashset
38#[must_use]
39pub fn project_json(value: &Value, selected_fields: &HashSet<String>) -> Value {
40    match value {
41        Value::Object(map) => {
42            let mut projected = Map::new();
43            for (key, val) in map {
44                let key_lower = key.to_lowercase();
45
46                // Check if this exact field is selected
47                if selected_fields.contains(&key_lower) {
48                    // Include entire field (no further filtering)
49                    projected.insert(key.clone(), val.clone());
50                } else {
51                    // Check if any nested fields are selected (dot notation)
52                    let nested_fields = extract_nested_fields(&key_lower, selected_fields);
53                    if !nested_fields.is_empty() {
54                        // Recursively project nested fields
55                        projected.insert(key.clone(), project_json(val, &nested_fields));
56                    }
57                }
58            }
59            Value::Object(projected)
60        }
61        Value::Array(arr) => Value::Array(
62            arr.iter()
63                .map(|v| project_json(v, selected_fields))
64                .collect(),
65        ),
66        other => other.clone(),
67    }
68}
69
70/// Extract nested field selectors for a given parent field.
71///
72/// For example, if `selected_fields` contains `access_control.read` and `access_control.write`,
73/// this function returns a set containing `read` and `write` when called with `access_control`.
74fn extract_nested_fields(parent_key: &str, selected_fields: &HashSet<String>) -> HashSet<String> {
75    let prefix = format!("{parent_key}.");
76    selected_fields
77        .iter()
78        .filter(|field| field.starts_with(&prefix))
79        .map(|field| field[prefix.len()..].to_string())
80        .collect()
81}
82
83/// Helper function to apply field projection to a serializable value.
84///
85/// # Arguments
86///
87/// * `value` - The value to project
88/// * `selected_fields` - Optional slice of field names to include
89///
90/// # Returns
91///
92/// The projected JSON value, or the original value if no fields are selected
93pub fn apply_select<T: serde::Serialize>(value: T, selected_fields: Option<&[String]>) -> Value {
94    match selected_fields {
95        Some(fields) if !fields.is_empty() => {
96            let fields_set: HashSet<String> = fields.iter().map(|f| f.to_lowercase()).collect();
97            match serde_json::to_value(value) {
98                Ok(v) => project_json(&v, &fields_set),
99                Err(_) => json!({}),
100            }
101        }
102        _ => serde_json::to_value(value).unwrap_or_else(|_| json!({})),
103    }
104}
105
106/// Convert a page of items to a page of projected JSON values.
107///
108/// This is a convenience function that combines serialization and projection
109/// for paginated responses. It automatically applies `$select` projection if specified.
110///
111/// # Arguments
112///
113/// * `page` - The page containing items to project
114/// * `selected_fields` - Optional slice of field names to include
115///
116/// # Returns
117///
118/// A `modkit_odata::Page<Value>` with projected items
119#[must_use]
120pub fn page_to_projected_json<T: serde::Serialize>(
121    page: &modkit_odata::Page<T>,
122    selected_fields: Option<&[String]>,
123) -> modkit_odata::Page<Value> {
124    let projected_items: Vec<Value> = page
125        .items
126        .iter()
127        .map(|item| apply_select(item, selected_fields))
128        .collect();
129
130    modkit_odata::Page {
131        items: projected_items,
132        page_info: page.page_info.clone(),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_project_json_object() {
142        let value = json!({
143            "id": "123",
144            "name": "John",
145            "email": "john@example.com",
146            "age": 30
147        });
148
149        let selected = ["id".to_owned(), "name".to_owned()];
150        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
151
152        let projected = project_json(&value, &fields_set);
153
154        assert_eq!(projected.get("id").and_then(|v| v.as_str()), Some("123"));
155        assert_eq!(projected.get("name").and_then(|v| v.as_str()), Some("John"));
156        assert!(projected.get("email").is_none());
157        assert!(projected.get("age").is_none());
158    }
159
160    #[test]
161    fn test_project_json_case_insensitive() {
162        let value = json!({
163            "Id": "123",
164            "Name": "John"
165        });
166
167        let selected = ["id".to_owned(), "name".to_owned()];
168        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
169
170        let projected = project_json(&value, &fields_set);
171
172        assert_eq!(projected.get("Id").and_then(|v| v.as_str()), Some("123"));
173        assert_eq!(projected.get("Name").and_then(|v| v.as_str()), Some("John"));
174    }
175
176    #[test]
177    fn test_project_json_array() {
178        let value = json!([
179            {"id": "1", "name": "John"},
180            {"id": "2", "name": "Jane"}
181        ]);
182
183        let selected = ["id".to_owned()];
184        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
185
186        let projected = project_json(&value, &fields_set);
187
188        let arr = projected.as_array().unwrap();
189        assert_eq!(arr.len(), 2);
190        assert_eq!(arr[0].get("id").and_then(|v| v.as_str()), Some("1"));
191        assert!(arr[0].get("name").is_none());
192    }
193
194    #[test]
195    fn test_project_json_nested() {
196        let value = json!({
197            "id": "123",
198            "user": {
199                "name": "John",
200                "email": "john@example.com"
201            }
202        });
203
204        let selected = ["id".to_owned(), "user".to_owned()];
205        let fields_set: HashSet<String> = selected.iter().map(|f| f.to_lowercase()).collect();
206
207        let projected = project_json(&value, &fields_set);
208
209        assert_eq!(projected.get("id").and_then(|v| v.as_str()), Some("123"));
210        assert!(projected.get("user").is_some());
211    }
212
213    #[test]
214    fn test_apply_select_with_fields() {
215        #[derive(serde::Serialize)]
216        struct User {
217            id: String,
218            name: String,
219            email: String,
220        }
221
222        let user = User {
223            id: "123".to_owned(),
224            name: "John".to_owned(),
225            email: "john@example.com".to_owned(),
226        };
227
228        let selected = vec!["id".to_owned(), "name".to_owned()];
229        let result = apply_select(&user, Some(&selected));
230
231        assert_eq!(result.get("id").and_then(|v| v.as_str()), Some("123"));
232        assert_eq!(result.get("name").and_then(|v| v.as_str()), Some("John"));
233        assert!(result.get("email").is_none());
234    }
235
236    #[test]
237    fn test_apply_select_without_fields() {
238        #[derive(serde::Serialize)]
239        struct User {
240            id: String,
241            name: String,
242        }
243
244        let user = User {
245            id: "123".to_owned(),
246            name: "John".to_owned(),
247        };
248
249        let result = apply_select(&user, None);
250
251        assert_eq!(result.get("id").and_then(|v| v.as_str()), Some("123"));
252        assert_eq!(result.get("name").and_then(|v| v.as_str()), Some("John"));
253    }
254}