Skip to main content

teaql_core/
dynamic_search.rs

1//! Local UI search input. This is not a permissive federation decoder.
2use crate::{Expr, OrderBy, SelectQuery};
3use serde::Serialize;
4use serde_json::{Map, Value};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, Default)]
8pub struct SearchModel {
9    pub fields: BTreeMap<String, String>,
10    pub relations: BTreeMap<String, String>,
11}
12pub type SearchModels = BTreeMap<String, SearchModel>;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct DynamicSearchWarning {
17    pub code: &'static str,
18    pub entity: String,
19    pub clause: &'static str,
20    pub field_path: String,
21}
22#[derive(Debug, Clone, PartialEq)]
23pub struct DynamicSearchFilter {
24    pub field_path: String,
25    pub operator: String,
26    pub value: Value,
27}
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DynamicSearchOrder {
30    pub field_path: String,
31    pub descending: bool,
32}
33#[derive(Debug, Clone)]
34pub struct NormalizedDynamicSearch {
35    pub filters: Vec<DynamicSearchFilter>,
36    pub orders: Vec<DynamicSearchOrder>,
37    pub warnings: Vec<DynamicSearchWarning>,
38}
39#[derive(Debug, Clone)]
40pub struct DynamicSearchResult {
41    pub query: SelectQuery,
42    pub warnings: Vec<DynamicSearchWarning>,
43}
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DynamicSearchError(pub &'static str);
46impl std::fmt::Display for DynamicSearchError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str(self.0)
49    }
50}
51impl std::error::Error for DynamicSearchError {}
52type Result<T> = std::result::Result<T, DynamicSearchError>;
53
54/// Metadata and bounds must come from trusted application setup, never from source.
55/// Passing no warning sink uses structured stderr logging without submitted values.
56pub fn normalize_dynamic_search(
57    source: &str,
58    entity: &str,
59    models: &SearchModels,
60    max_clauses: usize,
61    warn: Option<&mut dyn FnMut(&DynamicSearchWarning)>,
62) -> Result<NormalizedDynamicSearch> {
63    if max_clauses == 0 || !models.contains_key(entity) {
64        return Err(DynamicSearchError("Invalid trusted search setup"));
65    }
66    let value: Value = serde_json::from_str(source)
67        .map_err(|_| DynamicSearchError("Dynamic search requires valid JSON"))?;
68    let root = value
69        .as_object()
70        .ok_or(DynamicSearchError("Expected search object"))?;
71    if root.keys().any(|k| k != "filter" && k != "orderBy") {
72        return Err(DynamicSearchError("Unsupported dynamic search control"));
73    }
74    let empty_filters = Map::new();
75    let empty_orders = Vec::new();
76    let filters = match root.get("filter") {
77        None => &empty_filters,
78        Some(value) => value
79            .as_object()
80            .ok_or(DynamicSearchError("Invalid search filter"))?,
81    };
82    let orders = match root.get("orderBy") {
83        None => &empty_orders,
84        Some(value) => value
85            .as_array()
86            .ok_or(DynamicSearchError("Invalid search ordering"))?,
87    };
88    if filters.len().saturating_add(orders.len()) > max_clauses {
89        return Err(DynamicSearchError("Dynamic search exceeds clause limit"));
90    }
91    let mut result = NormalizedDynamicSearch {
92        filters: vec![],
93        orders: vec![],
94        warnings: vec![],
95    };
96    for (path, predicate) in filters {
97        let (operator, value) = if let Some(parts) = predicate.as_object() {
98            if parts.len() != 1 {
99                return Err(DynamicSearchError("Malformed dynamic search operator"));
100            }
101            let (op, val) = parts.iter().next().unwrap();
102            if ![
103                "$eq",
104                "$ne",
105                "$gt",
106                "$gte",
107                "$lt",
108                "$lte",
109                "$in",
110                "$notIn",
111                "$contains",
112            ]
113            .contains(&op.as_str())
114            {
115                return Err(DynamicSearchError("Unsupported dynamic search operator"));
116            }
117            (op.as_str(), val)
118        } else {
119            ("$eq", predicate)
120        };
121        if matches!(operator, "$in" | "$notIn")
122            && !value.as_array().is_some_and(|values| values.len() <= 1000)
123        {
124            return Err(DynamicSearchError("Invalid or oversized search value list"));
125        }
126        let Some(kind) = field_type(path, entity, models)? else {
127            result.warnings.push(warning(entity, "FILTER", path));
128            continue;
129        };
130        if operator == "$contains" && kind != "string" {
131            return Err(DynamicSearchError(
132                "String operator requires a string field",
133            ));
134        }
135        if let Some(items) = value.as_array() {
136            if !matches!(operator, "$in" | "$notIn") {
137                return Err(DynamicSearchError("Unexpected search value list"));
138            }
139            for item in items {
140                validate_scalar(item, kind)?;
141            }
142        } else {
143            validate_scalar(value, kind)?;
144        }
145        result.filters.push(DynamicSearchFilter {
146            field_path: path.clone(),
147            operator: operator.into(),
148            value: value.clone(),
149        });
150    }
151    for value in orders {
152        let order = value
153            .as_object()
154            .ok_or(DynamicSearchError("Invalid search ordering"))?;
155        let path = order
156            .get("field")
157            .and_then(Value::as_str)
158            .ok_or(DynamicSearchError("Invalid ordering field"))?;
159        let direction = order
160            .get("direction")
161            .and_then(Value::as_str)
162            .ok_or(DynamicSearchError("Invalid ordering direction"))?;
163        if order.len() != 2 || !matches!(direction, "asc" | "desc") {
164            return Err(DynamicSearchError("Invalid dynamic search ordering"));
165        }
166        if field_type(path, entity, models)?.is_none() {
167            result.warnings.push(warning(entity, "ORDER_BY", path));
168        } else {
169            result.orders.push(DynamicSearchOrder {
170                field_path: path.into(),
171                descending: direction == "desc",
172            });
173        }
174    }
175    emit(&result.warnings, warn);
176    Ok(result)
177}
178
179/// Compile through trusted native bindings and retain the existing scoped query.
180/// Bindings own canonical names and authorization inside related queries.
181pub fn merge_dynamic_search(
182    base: &SelectQuery,
183    source: &str,
184    models: &SearchModels,
185    mut filter_binding: impl FnMut(&DynamicSearchFilter) -> Result<Expr>,
186    mut order_binding: impl FnMut(&DynamicSearchOrder) -> Result<OrderBy>,
187    warn: Option<&mut dyn FnMut(&DynamicSearchWarning)>,
188) -> Result<DynamicSearchResult> {
189    let search = normalize_dynamic_search(source, &base.entity, models, 100, Some(&mut |_| {}))?;
190    let filters = search
191        .filters
192        .iter()
193        .map(&mut filter_binding)
194        .collect::<Result<Vec<_>>>()?;
195    let orders = search
196        .orders
197        .iter()
198        .map(&mut order_binding)
199        .collect::<Result<Vec<_>>>()?;
200    let mut query = base.clone();
201    for filter in filters {
202        query = query.and_filter(filter);
203    }
204    query.order_by.extend(orders);
205    emit(&search.warnings, warn);
206    Ok(DynamicSearchResult {
207        query,
208        warnings: search.warnings,
209    })
210}
211
212fn field_type<'a>(path: &str, entity: &str, models: &'a SearchModels) -> Result<Option<&'a str>> {
213    let parts: Vec<_> = path.split('.').collect();
214    if parts.len() > 16
215        || parts.iter().any(|p| {
216            p.is_empty()
217                || p.starts_with('$')
218                || matches!(*p, "__proto__" | "prototype" | "constructor")
219        })
220    {
221        return Err(DynamicSearchError("Invalid search field path"));
222    }
223    let mut model = &models[entity];
224    for part in &parts[..parts.len() - 1] {
225        let Some(target) = model.relations.get(*part) else {
226            return Ok(None);
227        };
228        model = models.get(target).ok_or(DynamicSearchError(
229            "Invalid trusted search relation metadata",
230        ))?;
231    }
232    Ok(model.fields.get(*parts.last().unwrap()).map(String::as_str))
233}
234
235fn validate_scalar(value: &Value, kind: &str) -> Result<()> {
236    if value.is_null() {
237        return Ok(());
238    }
239    let number = value.as_f64().filter(|n| n.is_finite());
240    let valid = match kind {
241        "integer" | "timestamp" => {
242            number.is_some_and(|n| n.abs() <= 9_007_199_254_740_991.0 && n.fract() == 0.0)
243        }
244        "number" => number.is_some(),
245        "string" => value.is_string(),
246        "boolean" => value.is_boolean(),
247        "decimal" => number.is_some() || value.as_str().is_some_and(decimal_text),
248        "date" => value.as_str().is_some_and(|s| {
249            s.len() == 10
250                && chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok_and(|date| {
251                    date.format("%Y-%m-%d").to_string() == s && !s.starts_with("0000")
252                })
253        }),
254        _ => false,
255    };
256    if valid {
257        Ok(())
258    } else {
259        Err(DynamicSearchError("Invalid value for known search field"))
260    }
261}
262fn decimal_text(text: &str) -> bool {
263    let text = text
264        .strip_prefix('+')
265        .or_else(|| text.strip_prefix('-'))
266        .unwrap_or(text);
267    let parts: Vec<_> = text.split('.').collect();
268    parts.len() <= 2
269        && parts
270            .iter()
271            .all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()))
272}
273fn warning(entity: &str, clause: &'static str, path: &str) -> DynamicSearchWarning {
274    DynamicSearchWarning {
275        code: "DYNAMIC_SEARCH_UNKNOWN_FIELD",
276        entity: entity.into(),
277        clause,
278        field_path: path.into(),
279    }
280}
281fn emit(
282    warnings: &[DynamicSearchWarning],
283    mut warn: Option<&mut dyn FnMut(&DynamicSearchWarning)>,
284) {
285    for warning in warnings {
286        if let Some(ref mut sink) = warn {
287            sink(warning);
288        } else if let Ok(json) = serde_json::to_string(warning) {
289            eprintln!("{json}");
290        }
291    }
292}