1use 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
54pub 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
179pub 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}