json_eval_rs/jsoneval/validation.rs
1use super::JSONEval;
2use crate::jsoneval::json_parser;
3use crate::jsoneval::path_utils;
4use crate::jsoneval::types::{ValidationError, ValidationResult};
5
6use crate::time_block;
7
8use indexmap::IndexMap;
9use serde_json::Value;
10
11
12impl JSONEval {
13 /// Validate data against schema rules
14 pub fn validate(
15 &mut self,
16 data: &str,
17 context: Option<&str>,
18 paths: Option<&[String]>,
19 ) -> Result<ValidationResult, String> {
20 time_block!("validate() [total]", {
21 // Acquire lock for synchronous execution
22 let _lock = self.eval_lock.lock().unwrap();
23
24 // Save old data for comparison
25 let old_data = self.eval_data.clone_data_without(&["$params"]);
26
27 // Parse and update data
28 let data_value = json_parser::parse_json_str(data)?;
29 let context_value = if let Some(ctx) = context {
30 json_parser::parse_json_str(ctx)?
31 } else {
32 Value::Object(serde_json::Map::new())
33 };
34
35 // Update eval_data with new data/context
36 self.eval_data.replace_data_and_context(data_value.clone(), context_value);
37
38 // Calculate changed paths for cache purging (root changed)
39 // Selectively purge cache entries that depend on root data
40 // Convert changed_paths to data pointer format for cache purging
41 let changed_data_paths = vec!["/".to_string()];
42
43 // Selectively purge cache entries
44 self.purge_cache_for_changed_data_with_comparison(&changed_data_paths, &old_data, &data_value);
45
46 if context.is_some() {
47 self.purge_cache_for_context_change();
48 }
49
50 // Drop lock before calling evaluate_others which needs mutable access
51 drop(_lock);
52
53 // Re-evaluate rule evaluations to ensure fresh values
54 // This ensures all rule.$evaluation expressions are re-computed
55 self.evaluate_others(paths);
56
57 // Update evaluated_schema with fresh evaluations
58 self.evaluated_schema = self.get_evaluated_schema(false);
59
60 let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
61
62 // Use pre-parsed fields_with_rules from schema parsing (no runtime collection needed)
63 // This list was collected during schema parse and contains all fields with rules
64 for field_path in self.fields_with_rules.iter() {
65 // Check if we should validate this path (path filtering)
66 if let Some(filter_paths) = paths {
67 if !filter_paths.is_empty()
68 && !filter_paths.iter().any(|p| {
69 field_path.starts_with(p.as_str()) || p.starts_with(field_path.as_str())
70 })
71 {
72 continue;
73 }
74 }
75
76 self.validate_field(field_path, &data_value, &mut errors);
77 }
78
79 let has_error = !errors.is_empty();
80
81 Ok(ValidationResult { has_error, errors })
82 })
83 }
84
85 /// Validate a single field that has rules
86 pub(crate) fn validate_field(
87 &self,
88 field_path: &str,
89 data: &Value,
90 errors: &mut IndexMap<String, ValidationError>,
91 ) {
92 // Skip if already has error
93 if errors.contains_key(field_path) {
94 return;
95 }
96
97 // Get schema for this field
98 let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
99
100 // Remove leading "#" from path for pointer lookup
101 let pointer_path = schema_path.trim_start_matches('#');
102
103 // Try to get schema, if not found, try with /properties/ prefix for standard JSON Schema
104 let field_schema = match self.evaluated_schema.pointer(pointer_path) {
105 Some(s) => s,
106 None => {
107 // Try with /properties/ prefix (for standard JSON Schema format)
108 let alt_path = format!("/properties{}", pointer_path);
109 match self.evaluated_schema.pointer(&alt_path) {
110 Some(s) => s,
111 None => return,
112 }
113 }
114 };
115
116 // Check if field is hidden (skip validation)
117 if let Value::Object(schema_map) = field_schema {
118 if let Some(Value::Object(condition)) = schema_map.get("condition") {
119 if let Some(Value::Bool(true)) = condition.get("hidden") {
120 return;
121 }
122 }
123
124 // Get rules object
125 let rules = match schema_map.get("rules") {
126 Some(Value::Object(r)) => r,
127 _ => return,
128 };
129
130 // Get field data
131 let field_data = self.get_field_data(field_path, data);
132
133 // Validate each rule
134 for (rule_name, rule_value) in rules {
135 self.validate_rule(
136 field_path,
137 rule_name,
138 rule_value,
139 &field_data,
140 schema_map,
141 field_schema,
142 errors,
143 );
144 }
145 }
146 }
147
148 /// Get data value for a field path
149 pub(crate) fn get_field_data(&self, field_path: &str, data: &Value) -> Value {
150 let parts: Vec<&str> = field_path.split('.').collect();
151 let mut current = data;
152
153 for part in parts {
154 match current {
155 Value::Object(map) => {
156 current = map.get(part).unwrap_or(&Value::Null);
157 }
158 _ => return Value::Null,
159 }
160 }
161
162 current.clone()
163 }
164
165 /// Validate a single rule
166 #[allow(clippy::too_many_arguments)]
167 pub(crate) fn validate_rule(
168 &self,
169 field_path: &str,
170 rule_name: &str,
171 rule_value: &Value,
172 field_data: &Value,
173 schema_map: &serde_json::Map<String, Value>,
174 _schema: &Value,
175 errors: &mut IndexMap<String, ValidationError>,
176 ) {
177 // Skip if already has error
178 if errors.contains_key(field_path) {
179 return;
180 }
181
182 let mut disabled_field = false;
183 // Check if disabled
184 if let Some(Value::Object(condition)) = schema_map.get("condition") {
185 if let Some(Value::Bool(true)) = condition.get("disabled") {
186 disabled_field = true;
187 }
188 }
189
190 // Get the evaluated rule from evaluated_schema (which has $evaluation already processed)
191 // Convert field_path to schema path
192 let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
193 let rule_path = format!(
194 "{}/rules/{}",
195 schema_path.trim_start_matches('#'),
196 rule_name
197 );
198
199 // Look up the evaluated rule from evaluated_schema
200 let evaluated_rule = if let Some(eval_rule) = self.evaluated_schema.pointer(&rule_path) {
201 eval_rule.clone()
202 } else {
203 rule_value.clone()
204 };
205
206 // Extract rule active status, message, etc
207 // Logic depends on rule structure (object with value/message or direct value)
208
209 let (rule_active, rule_message, rule_code, rule_data) = match &evaluated_rule {
210 Value::Object(rule_obj) => {
211 let active = rule_obj.get("value").unwrap_or(&Value::Bool(false));
212
213 // Handle message - could be string or object with "value"
214 let message = match rule_obj.get("message") {
215 Some(Value::String(s)) => s.clone(),
216 Some(Value::Object(msg_obj)) if msg_obj.contains_key("value") => msg_obj
217 .get("value")
218 .and_then(|v| v.as_str())
219 .unwrap_or("Validation failed")
220 .to_string(),
221 Some(msg_val) => msg_val.as_str().unwrap_or("Validation failed").to_string(),
222 None => "Validation failed".to_string(),
223 };
224
225 let code = rule_obj
226 .get("code")
227 .and_then(|c| c.as_str())
228 .map(|s| s.to_string());
229
230 // Handle data - extract "value" from objects with $evaluation
231 let data = rule_obj.get("data").map(|d| {
232 if let Value::Object(data_obj) = d {
233 let mut cleaned_data = serde_json::Map::new();
234 for (key, value) in data_obj {
235 // If value is an object with only "value" key, extract it
236 if let Value::Object(val_obj) = value {
237 if val_obj.len() == 1 && val_obj.contains_key("value") {
238 cleaned_data.insert(key.clone(), val_obj["value"].clone());
239 } else {
240 cleaned_data.insert(key.clone(), value.clone());
241 }
242 } else {
243 cleaned_data.insert(key.clone(), value.clone());
244 }
245 }
246 Value::Object(cleaned_data)
247 } else {
248 d.clone()
249 }
250 });
251
252 (active.clone(), message, code, data)
253 }
254 _ => (
255 evaluated_rule.clone(),
256 "Validation failed".to_string(),
257 None,
258 None,
259 ),
260 };
261
262 // Generate default code if not provided
263 let error_code = rule_code.or_else(|| Some(format!("{}.{}", field_path, rule_name)));
264
265 let is_empty = matches!(field_data, Value::Null)
266 || (field_data.is_string() && field_data.as_str().unwrap_or("").is_empty())
267 || (field_data.is_array() && field_data.as_array().unwrap().is_empty());
268
269 match rule_name {
270 "required" => {
271 if !disabled_field && rule_active == Value::Bool(true) {
272 if is_empty {
273 errors.insert(
274 field_path.to_string(),
275 ValidationError {
276 rule_type: "required".to_string(),
277 message: rule_message,
278 code: error_code.clone(),
279 pattern: None,
280 field_value: None,
281 data: None,
282 },
283 );
284 }
285 }
286 }
287 "minLength" => {
288 if !is_empty {
289 if let Some(min) = rule_active.as_u64() {
290 let len = match field_data {
291 Value::String(s) => s.len(),
292 Value::Array(a) => a.len(),
293 _ => 0,
294 };
295 if len < min as usize {
296 errors.insert(
297 field_path.to_string(),
298 ValidationError {
299 rule_type: "minLength".to_string(),
300 message: rule_message,
301 code: error_code.clone(),
302 pattern: None,
303 field_value: None,
304 data: None,
305 },
306 );
307 }
308 }
309 }
310 }
311 "maxLength" => {
312 if !is_empty {
313 if let Some(max) = rule_active.as_u64() {
314 let len = match field_data {
315 Value::String(s) => s.len(),
316 Value::Array(a) => a.len(),
317 _ => 0,
318 };
319 if len > max as usize {
320 errors.insert(
321 field_path.to_string(),
322 ValidationError {
323 rule_type: "maxLength".to_string(),
324 message: rule_message,
325 code: error_code.clone(),
326 pattern: None,
327 field_value: None,
328 data: None,
329 },
330 );
331 }
332 }
333 }
334 }
335 "minValue" => {
336 if !is_empty {
337 if let Some(min) = rule_active.as_f64() {
338 if let Some(val) = field_data.as_f64() {
339 if val < min {
340 errors.insert(
341 field_path.to_string(),
342 ValidationError {
343 rule_type: "minValue".to_string(),
344 message: rule_message,
345 code: error_code.clone(),
346 pattern: None,
347 field_value: None,
348 data: None,
349 },
350 );
351 }
352 }
353 }
354 }
355 }
356 "maxValue" => {
357 if !is_empty {
358 if let Some(max) = rule_active.as_f64() {
359 if let Some(val) = field_data.as_f64() {
360 if val > max {
361 errors.insert(
362 field_path.to_string(),
363 ValidationError {
364 rule_type: "maxValue".to_string(),
365 message: rule_message,
366 code: error_code.clone(),
367 pattern: None,
368 field_value: None,
369 data: None,
370 },
371 );
372 }
373 }
374 }
375 }
376 }
377 "pattern" => {
378 if !is_empty {
379 if let Some(pattern) = rule_active.as_str() {
380 if let Some(text) = field_data.as_str() {
381 if let Ok(regex) = regex::Regex::new(pattern) {
382 if !regex.is_match(text) {
383 errors.insert(
384 field_path.to_string(),
385 ValidationError {
386 rule_type: "pattern".to_string(),
387 message: rule_message,
388 code: error_code.clone(),
389 pattern: Some(pattern.to_string()),
390 field_value: Some(text.to_string()),
391 data: None,
392 },
393 );
394 }
395 }
396 }
397 }
398 }
399 }
400 "evaluation" => {
401 // Handle array of evaluation rules
402 // Format: "evaluation": [{ "code": "...", "message": "...", "$evaluation": {...} }]
403 if let Value::Array(eval_array) = &evaluated_rule {
404 for (idx, eval_item) in eval_array.iter().enumerate() {
405 if let Value::Object(eval_obj) = eval_item {
406 // Get the evaluated value (should be in "value" key after evaluation)
407 let eval_result = eval_obj.get("value").unwrap_or(&Value::Bool(true));
408
409 // Check if result is falsy
410 let is_falsy = match eval_result {
411 Value::Bool(false) => true,
412 Value::Null => true,
413 Value::Number(n) => n.as_f64() == Some(0.0),
414 Value::String(s) => s.is_empty(),
415 Value::Array(a) => a.is_empty(),
416 _ => false,
417 };
418
419 if is_falsy {
420 let eval_code = eval_obj
421 .get("code")
422 .and_then(|c| c.as_str())
423 .map(|s| s.to_string())
424 .or_else(|| Some(format!("{}.evaluation.{}", field_path, idx)));
425
426 let eval_message = eval_obj
427 .get("message")
428 .and_then(|m| m.as_str())
429 .unwrap_or("Validation failed")
430 .to_string();
431
432 let eval_data = eval_obj.get("data").cloned();
433
434 errors.insert(
435 field_path.to_string(),
436 ValidationError {
437 rule_type: "evaluation".to_string(),
438 message: eval_message,
439 code: eval_code,
440 pattern: None,
441 field_value: None,
442 data: eval_data,
443 },
444 );
445
446 // Stop at first failure
447 break;
448 }
449 }
450 }
451 }
452 }
453 _ => {
454 // Custom evaluation rules
455 // In JS: if (!opt.rule.value) then error
456 // This handles rules with $evaluation that return false/falsy values
457 if !is_empty {
458 // Check if rule_active is falsy (false, 0, null, empty string, empty array)
459 let is_falsy = match &rule_active {
460 Value::Bool(false) => true,
461 Value::Null => true,
462 Value::Number(n) => n.as_f64() == Some(0.0),
463 Value::String(s) => s.is_empty(),
464 Value::Array(a) => a.is_empty(),
465 _ => false,
466 };
467
468 if is_falsy {
469 errors.insert(
470 field_path.to_string(),
471 ValidationError {
472 rule_type: "evaluation".to_string(),
473 message: rule_message,
474 code: error_code.clone(),
475 pattern: None,
476 field_value: None,
477 data: rule_data,
478 },
479 );
480 }
481 }
482 }
483 }
484 }
485}