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 // Get schema for this field
111 let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
112
113 // Remove leading "#" from path for pointer lookup
114 let pointer_path = schema_path.trim_start_matches('#');
115
116 // Try to get schema, if not found, try with /properties/ prefix for standard JSON Schema
117 let field_schema = match self.evaluated_schema.pointer(pointer_path) {
118 Some(s) => s,
119 None => {
120 // Try with /properties/ prefix (for standard JSON Schema format)
121 let alt_path = format!("/properties{}", pointer_path);
122 match self.evaluated_schema.pointer(&alt_path) {
123 Some(s) => s,
124 None => return,
125 }
126 }
127 };
128
129 // Check if field is hidden (skip validation)
130 if let Value::Object(schema_map) = field_schema {
131 if let Some(Value::Object(condition)) = schema_map.get("condition") {
132 if let Some(Value::Bool(true)) = condition.get("hidden") {
133 return;
134 }
135 }
136
137 // Get rules object
138 let rules = match schema_map.get("rules") {
139 Some(Value::Object(r)) => r,
140 _ => return,
141 };
142
143 // Get field data
144 let field_data = self.get_field_data(field_path, data);
145
146 // Validate each rule
147 for (rule_name, rule_value) in rules {
148 self.validate_rule(
149 field_path,
150 rule_name,
151 rule_value,
152 &field_data,
153 schema_map,
154 field_schema,
155 errors,
156 );
157 }
158 }
159 }
160
161 /// Get data value for a field path
162 pub(crate) fn get_field_data(&self, field_path: &str, data: &Value) -> Value {
163 let parts: Vec<&str> = field_path.split('.').collect();
164 let mut current = data;
165
166 for part in parts {
167 match current {
168 Value::Object(map) => {
169 current = map.get(part).unwrap_or(&Value::Null);
170 }
171 _ => return Value::Null,
172 }
173 }
174
175 current.clone()
176 }
177
178 /// Validate a single rule
179 #[allow(clippy::too_many_arguments)]
180 pub(crate) fn validate_rule(
181 &self,
182 field_path: &str,
183 rule_name: &str,
184 rule_value: &Value,
185 field_data: &Value,
186 schema_map: &serde_json::Map<String, Value>,
187 _schema: &Value,
188 errors: &mut IndexMap<String, ValidationError>,
189 ) {
190 // Skip if already has error
191 if errors.contains_key(field_path) {
192 return;
193 }
194
195 let mut disabled_field = false;
196 // Check if disabled
197 if let Some(Value::Object(condition)) = schema_map.get("condition") {
198 if let Some(Value::Bool(true)) = condition.get("disabled") {
199 disabled_field = true;
200 }
201 }
202
203 // Get the evaluated rule from evaluated_schema (which has $evaluation already processed)
204 // Convert field_path to schema path
205 let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
206 let rule_path = format!(
207 "{}/rules/{}",
208 schema_path.trim_start_matches('#'),
209 rule_name
210 );
211
212 // Look up the evaluated rule from evaluated_schema
213 let evaluated_rule = if let Some(eval_rule) = self.evaluated_schema.pointer(&rule_path) {
214 eval_rule.clone()
215 } else {
216 rule_value.clone()
217 };
218
219 // Extract rule active status, message, etc
220 // Logic depends on rule structure (object with value/message or direct value)
221
222 let (rule_active, rule_message, rule_code, rule_data) = match &evaluated_rule {
223 Value::Object(rule_obj) => {
224 let active = rule_obj.get("value").unwrap_or(&Value::Bool(false));
225
226 // Handle message - could be string or object with "value"
227 let message = match rule_obj.get("message") {
228 Some(Value::String(s)) => s.clone(),
229 Some(Value::Object(msg_obj)) if msg_obj.contains_key("value") => msg_obj
230 .get("value")
231 .and_then(|v| v.as_str())
232 .unwrap_or("Validation failed")
233 .to_string(),
234 Some(msg_val) => msg_val.as_str().unwrap_or("Validation failed").to_string(),
235 None => "Validation failed".to_string(),
236 };
237
238 let code = rule_obj
239 .get("code")
240 .and_then(|c| c.as_str())
241 .map(|s| s.to_string());
242
243 // Handle data - extract "value" from objects with $evaluation
244 let data = rule_obj.get("data").map(|d| {
245 if let Value::Object(data_obj) = d {
246 let mut cleaned_data = serde_json::Map::new();
247 for (key, value) in data_obj {
248 // If value is an object with only "value" key, extract it
249 if let Value::Object(val_obj) = value {
250 if val_obj.len() == 1 && val_obj.contains_key("value") {
251 cleaned_data.insert(key.clone(), val_obj["value"].clone());
252 } else {
253 cleaned_data.insert(key.clone(), value.clone());
254 }
255 } else {
256 cleaned_data.insert(key.clone(), value.clone());
257 }
258 }
259 Value::Object(cleaned_data)
260 } else {
261 d.clone()
262 }
263 });
264
265 (active.clone(), message, code, data)
266 }
267 _ => (
268 evaluated_rule.clone(),
269 "Validation failed".to_string(),
270 None,
271 None,
272 ),
273 };
274
275 // Generate default code if not provided
276 let error_code = rule_code.or_else(|| Some(format!("{}.{}", field_path, rule_name)));
277
278 let is_empty = matches!(field_data, Value::Null)
279 || (field_data.is_string() && field_data.as_str().unwrap_or("").is_empty())
280 || (field_data.is_array() && field_data.as_array().unwrap().is_empty());
281
282 match rule_name {
283 "required" => {
284 if !disabled_field && rule_active == Value::Bool(true) {
285 if is_empty {
286 errors.insert(
287 field_path.to_string(),
288 ValidationError {
289 rule_type: "required".to_string(),
290 message: rule_message,
291 code: error_code.clone(),
292 pattern: None,
293 field_value: None,
294 data: None,
295 },
296 );
297 }
298 }
299 }
300 "minLength" => {
301 if !is_empty {
302 if let Some(min) = rule_active.as_u64() {
303 let len = match field_data {
304 Value::String(s) => s.len(),
305 Value::Array(a) => a.len(),
306 _ => 0,
307 };
308 if len < min as usize {
309 errors.insert(
310 field_path.to_string(),
311 ValidationError {
312 rule_type: "minLength".to_string(),
313 message: rule_message,
314 code: error_code.clone(),
315 pattern: None,
316 field_value: None,
317 data: None,
318 },
319 );
320 }
321 }
322 }
323 }
324 "maxLength" => {
325 if !is_empty {
326 if let Some(max) = rule_active.as_u64() {
327 let len = match field_data {
328 Value::String(s) => s.len(),
329 Value::Array(a) => a.len(),
330 _ => 0,
331 };
332 if len > max as usize {
333 errors.insert(
334 field_path.to_string(),
335 ValidationError {
336 rule_type: "maxLength".to_string(),
337 message: rule_message,
338 code: error_code.clone(),
339 pattern: None,
340 field_value: None,
341 data: None,
342 },
343 );
344 }
345 }
346 }
347 }
348 "minValue" => {
349 if !is_empty {
350 if let Some(min) = rule_active.as_f64() {
351 if let Some(val) = field_data.as_f64() {
352 if val < min {
353 errors.insert(
354 field_path.to_string(),
355 ValidationError {
356 rule_type: "minValue".to_string(),
357 message: rule_message,
358 code: error_code.clone(),
359 pattern: None,
360 field_value: None,
361 data: None,
362 },
363 );
364 }
365 }
366 }
367 }
368 }
369 "maxValue" => {
370 if !is_empty {
371 if let Some(max) = rule_active.as_f64() {
372 if let Some(val) = field_data.as_f64() {
373 if val > max {
374 errors.insert(
375 field_path.to_string(),
376 ValidationError {
377 rule_type: "maxValue".to_string(),
378 message: rule_message,
379 code: error_code.clone(),
380 pattern: None,
381 field_value: None,
382 data: None,
383 },
384 );
385 }
386 }
387 }
388 }
389 }
390 "pattern" => {
391 if !is_empty {
392 if let Some(pattern) = rule_active.as_str() {
393 if let Some(text) = field_data.as_str() {
394 if let Ok(regex) = regex::Regex::new(pattern) {
395 if !regex.is_match(text) {
396 errors.insert(
397 field_path.to_string(),
398 ValidationError {
399 rule_type: "pattern".to_string(),
400 message: rule_message,
401 code: error_code.clone(),
402 pattern: Some(pattern.to_string()),
403 field_value: Some(text.to_string()),
404 data: None,
405 },
406 );
407 }
408 }
409 }
410 }
411 }
412 }
413 "evaluation" => {
414 // Handle array of evaluation rules
415 // Format: "evaluation": [{ "code": "...", "message": "...", "$evaluation": {...} }]
416 if let Value::Array(eval_array) = &evaluated_rule {
417 for (idx, eval_item) in eval_array.iter().enumerate() {
418 if let Value::Object(eval_obj) = eval_item {
419 // Get the evaluated value (should be in "value" key after evaluation)
420 let eval_result = eval_obj.get("value").unwrap_or(&Value::Bool(true));
421
422 // Check if result is falsy
423 let is_falsy = match eval_result {
424 Value::Bool(false) => true,
425 Value::Null => true,
426 Value::Number(n) => n.as_f64() == Some(0.0),
427 Value::String(s) => s.is_empty(),
428 Value::Array(a) => a.is_empty(),
429 _ => false,
430 };
431
432 if is_falsy {
433 let eval_code = eval_obj
434 .get("code")
435 .and_then(|c| c.as_str())
436 .map(|s| s.to_string())
437 .or_else(|| Some(format!("{}.evaluation.{}", field_path, idx)));
438
439 let eval_message = eval_obj
440 .get("message")
441 .and_then(|m| m.as_str())
442 .unwrap_or("Validation failed")
443 .to_string();
444
445 let eval_data = eval_obj.get("data").cloned();
446
447 errors.insert(
448 field_path.to_string(),
449 ValidationError {
450 rule_type: "evaluation".to_string(),
451 message: eval_message,
452 code: eval_code,
453 pattern: None,
454 field_value: None,
455 data: eval_data,
456 },
457 );
458
459 // Stop at first failure
460 break;
461 }
462 }
463 }
464 }
465 }
466 _ => {
467 // Custom evaluation rules
468 // In JS: if (!opt.rule.value) then error
469 // This handles rules with $evaluation that return false/falsy values
470 if !is_empty {
471 // Check if rule_active is falsy (false, 0, null, empty string, empty array)
472 let is_falsy = match &rule_active {
473 Value::Bool(false) => true,
474 Value::Null => true,
475 Value::Number(n) => n.as_f64() == Some(0.0),
476 Value::String(s) => s.is_empty(),
477 Value::Array(a) => a.is_empty(),
478 _ => false,
479 };
480
481 if is_falsy {
482 errors.insert(
483 field_path.to_string(),
484 ValidationError {
485 rule_type: "evaluation".to_string(),
486 message: rule_message,
487 code: error_code.clone(),
488 pattern: None,
489 field_value: None,
490 data: rule_data,
491 },
492 );
493 }
494 }
495 }
496 }
497 }
498}