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