Skip to main content

json_eval_rs/jsoneval/
validation.rs

1use super::JSONEval;
2use crate::jsoneval::cancellation::CancellationToken;
3use crate::jsoneval::json_parser;
4use crate::jsoneval::path_utils;
5use crate::jsoneval::types::{ValidationError, ValidationResult};
6
7use crate::time_block;
8
9use indexmap::IndexMap;
10use serde_json::Value;
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        token: Option<&CancellationToken>,
20    ) -> Result<ValidationResult, String> {
21        if let Some(t) = token {
22            if t.is_cancelled() {
23                return Err("Cancelled".to_string());
24            }
25        }
26        time_block!("validate() [total]", {
27            // Acquire lock for synchronous execution
28            let _lock = self.eval_lock.lock().unwrap();
29
30            // Parse and update data
31            let data_value = json_parser::parse_json_str(data)?;
32            let context_value = if let Some(ctx) = context {
33                json_parser::parse_json_str(ctx)?
34            } else {
35                Value::Object(serde_json::Map::new())
36            };
37
38            // Update context
39            self.context = context_value.clone();
40
41            // Update eval_data with new data/context
42            self.eval_data
43                .replace_data_and_context(data_value.clone(), context_value);
44
45            // Drop lock before calling evaluate_others which needs mutable access
46            drop(_lock);
47
48            // Re-evaluate rule evaluations to ensure fresh values
49            // This ensures all rule.$evaluation expressions are re-computed
50            self.evaluate_others(paths, token);
51
52            // Update evaluated_schema with fresh evaluations
53            self.evaluated_schema = self.get_evaluated_schema();
54
55            let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
56
57            // Use pre-parsed fields_with_rules from schema parsing (no runtime collection needed)
58            // This list was collected during schema parse and contains all fields with rules
59            for field_path in self.fields_with_rules.iter() {
60                // Check if we should validate this path (path filtering)
61                if let Some(filter_paths) = paths {
62                    if !filter_paths.is_empty()
63                        && !filter_paths.iter().any(|p| {
64                            field_path.starts_with(p.as_str()) || p.starts_with(field_path.as_str())
65                        })
66                    {
67                        continue;
68                    }
69                }
70
71                self.validate_field(field_path, &data_value, &mut errors);
72
73                if let Some(t) = token {
74                    if t.is_cancelled() {
75                        return Err("Cancelled".to_string());
76                    }
77                }
78            }
79
80            let has_error = !errors.is_empty();
81
82            Ok(ValidationResult { has_error, errors })
83        })
84    }
85
86    /// Validate using the data already present in `eval_data` (set by `with_item_cache_swap`).
87    ///
88    /// Skips JSON parsing and `replace_data_and_context` — use this inside the
89    /// cache-swap closure to avoid redundant work when the subform data is already set.
90    pub(crate) fn validate_pre_set(
91        &mut self,
92        data_value: Value,
93        paths: Option<&[String]>,
94        token: Option<&CancellationToken>,
95    ) -> Result<crate::ValidationResult, String> {
96        // Re-evaluate rule evaluations with the current (already-set) data.
97        self.evaluate_others(paths, token);
98        self.evaluated_schema = self.get_evaluated_schema();
99
100        let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
101
102        let fields: Vec<String> = self.fields_with_rules.iter().cloned().collect();
103        for field_path in &fields {
104            if let Some(filter_paths) = paths {
105                if !filter_paths.is_empty()
106                    && !filter_paths.iter().any(|p| {
107                        field_path.starts_with(p.as_str()) || p.starts_with(field_path.as_str())
108                    })
109                {
110                    continue;
111                }
112            }
113            if let Some(t) = token {
114                if t.is_cancelled() {
115                    return Err("Cancelled".to_string());
116                }
117            }
118            self.validate_field(field_path, &data_value, &mut errors);
119        }
120
121        let has_error = !errors.is_empty();
122        Ok(crate::ValidationResult { has_error, errors })
123    }
124
125    /// Validate a single field that has rules
126    pub(crate) fn validate_field(
127        &self,
128        field_path: &str,
129        data: &Value,
130        errors: &mut IndexMap<String, ValidationError>,
131    ) {
132        // Skip if already has error
133        if errors.contains_key(field_path) {
134            return;
135        }
136
137        // Resolve schema for this field
138        let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
139        let pointer_path = schema_path.trim_start_matches('#');
140
141        // Try to get schema, if not found, try with /properties/ prefix for standard JSON Schema
142        let (field_schema, resolved_path) = match self.evaluated_schema.pointer(pointer_path) {
143            Some(s) => (s, pointer_path.to_string()),
144            None => {
145                let alt_path = format!("/properties{}", pointer_path);
146                match self.evaluated_schema.pointer(&alt_path) {
147                    Some(s) => (s, alt_path),
148                    None => return,
149                }
150            }
151        };
152
153        // Skip hidden fields
154        if self.is_effective_hidden(&resolved_path) {
155            return;
156        }
157
158        if let Value::Object(schema_map) = field_schema {
159            // Get rules object
160            let rules = match schema_map.get("rules") {
161                Some(Value::Object(r)) => r,
162                _ => return,
163            };
164
165            // Get field data
166            let field_data = self.get_field_data(field_path, data);
167
168            // Validate each rule
169            for (rule_name, rule_value) in rules {
170                self.validate_rule(
171                    field_path,
172                    rule_name,
173                    rule_value,
174                    &field_data,
175                    schema_map,
176                    field_schema,
177                    errors,
178                );
179            }
180        }
181    }
182
183    /// Get data value for a field path
184    pub(crate) fn get_field_data(&self, field_path: &str, data: &Value) -> Value {
185        let parts: Vec<&str> = field_path.split('.').collect();
186        let mut current = data;
187
188        for part in parts {
189            match current {
190                Value::Object(map) => {
191                    current = map.get(part).unwrap_or(&Value::Null);
192                }
193                _ => return Value::Null,
194            }
195        }
196
197        current.clone()
198    }
199
200    /// Validate a single rule
201    #[allow(clippy::too_many_arguments)]
202    pub(crate) fn validate_rule(
203        &self,
204        field_path: &str,
205        rule_name: &str,
206        rule_value: &Value,
207        field_data: &Value,
208        schema_map: &serde_json::Map<String, Value>,
209        _schema: &Value,
210        errors: &mut IndexMap<String, ValidationError>,
211    ) {
212        // Skip if already has error
213        if errors.contains_key(field_path) {
214            return;
215        }
216
217        let mut disabled_field = false;
218        // Check if disabled
219        if let Some(Value::Object(condition)) = schema_map.get("condition") {
220            if let Some(Value::Bool(true)) = condition.get("disabled") {
221                disabled_field = true;
222            }
223        }
224
225        let schema_type = schema_map
226            .get("type")
227            .and_then(|t| t.as_str())
228            .unwrap_or("");
229
230        // Get the evaluated rule from evaluated_schema (which has $evaluation already processed)
231        // Convert field_path to schema path
232        let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
233        let rule_path = format!(
234            "{}/rules/{}",
235            schema_path.trim_start_matches('#'),
236            rule_name
237        );
238
239        // Look up the evaluated rule from evaluated_schema
240        let evaluated_rule = if let Some(eval_rule) = self.evaluated_schema.pointer(&rule_path) {
241            eval_rule.clone()
242        } else {
243            rule_value.clone()
244        };
245
246        // Extract rule active status, message, etc
247        // Logic depends on rule structure (object with value/message or direct value)
248
249        let (rule_active, rule_message, rule_code, rule_data) = match &evaluated_rule {
250            Value::Object(rule_obj) => {
251                let active = rule_obj.get("value").unwrap_or(&Value::Bool(false));
252
253                // Handle message - could be string or object with "value"
254                let message = match rule_obj.get("message") {
255                    Some(Value::String(s)) => s.clone(),
256                    Some(Value::Object(msg_obj)) if msg_obj.contains_key("value") => msg_obj
257                        .get("value")
258                        .and_then(|v| v.as_str())
259                        .unwrap_or("Validation failed")
260                        .to_string(),
261                    Some(msg_val) => msg_val.as_str().unwrap_or("Validation failed").to_string(),
262                    None => "Validation failed".to_string(),
263                };
264
265                let code = rule_obj
266                    .get("code")
267                    .and_then(|c| c.as_str())
268                    .map(|s| s.to_string());
269
270                // Handle data - extract "value" from objects with $evaluation
271                let data = rule_obj.get("data").map(|d| {
272                    if let Value::Object(data_obj) = d {
273                        let mut cleaned_data = serde_json::Map::new();
274                        for (key, value) in data_obj {
275                            // If value is an object with only "value" key, extract it
276                            if let Value::Object(val_obj) = value {
277                                if val_obj.len() == 1 && val_obj.contains_key("value") {
278                                    cleaned_data.insert(key.clone(), val_obj["value"].clone());
279                                } else {
280                                    cleaned_data.insert(key.clone(), value.clone());
281                                }
282                            } else {
283                                cleaned_data.insert(key.clone(), value.clone());
284                            }
285                        }
286                        Value::Object(cleaned_data)
287                    } else {
288                        d.clone()
289                    }
290                });
291
292                (active.clone(), message, code, data)
293            }
294            _ => (
295                evaluated_rule.clone(),
296                "Validation failed".to_string(),
297                None,
298                None,
299            ),
300        };
301
302        // Generate default code if not provided
303        let error_code = rule_code.or_else(|| Some(format!("{}.{}", field_path, rule_name)));
304
305        let is_empty = matches!(field_data, Value::Null)
306            || (field_data.is_string() && field_data.as_str().unwrap_or("").is_empty())
307            || (field_data.is_array() && field_data.as_array().unwrap().is_empty());
308
309        match rule_name {
310            "required" => {
311                if !disabled_field && rule_active == Value::Bool(true) {
312                    if is_empty {
313                        errors.insert(
314                            field_path.to_string(),
315                            ValidationError {
316                                rule_type: "required".to_string(),
317                                message: rule_message,
318                                code: error_code.clone(),
319                                pattern: None,
320                                field_value: None,
321                                data: None,
322                            },
323                        );
324                    }
325                }
326            }
327            "minLength" | "maxLength" | "minValue" | "maxValue" => {
328                if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
329                    errors.insert(
330                        field_path.to_string(),
331                        ValidationError {
332                            rule_type: rule_name.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            "pattern" => {
344                if !is_empty {
345                    if let Some(pattern) = rule_active.as_str() {
346                        if let Some(text) = field_data.as_str() {
347                            let mut cache = self.regex_cache.write().unwrap();
348                            let regex = cache.entry(pattern.to_string()).or_insert_with(|| {
349                                regex::Regex::new(pattern)
350                                    .unwrap_or_else(|_| regex::Regex::new("(?:)").unwrap())
351                            });
352                            if !regex.is_match(text) {
353                                errors.insert(
354                                    field_path.to_string(),
355                                    ValidationError {
356                                        rule_type: "pattern".to_string(),
357                                        message: rule_message,
358                                        code: error_code.clone(),
359                                        pattern: Some(pattern.to_string()),
360                                        field_value: Some(text.to_string()),
361                                        data: None,
362                                    },
363                                );
364                            }
365                        }
366                    }
367                }
368            }
369            "evaluation" => {
370                // Handle array of evaluation rules
371                // Format: "evaluation": [{ "code": "...", "message": "...", "$evaluation": {...} }]
372                if let Value::Array(eval_array) = &evaluated_rule {
373                    for (idx, eval_item) in eval_array.iter().enumerate() {
374                        if let Value::Object(eval_obj) = eval_item {
375                            // Get the evaluated value (should be in "value" key after evaluation)
376                            let eval_result = eval_obj.get("value").unwrap_or(&Value::Bool(true));
377
378                            // Check if result is falsy
379                            let is_falsy = match eval_result {
380                                Value::Bool(false) => true,
381                                Value::Null => true,
382                                Value::Number(n) => n.as_f64() == Some(0.0),
383                                Value::String(s) => s.is_empty(),
384                                Value::Array(a) => a.is_empty(),
385                                _ => false,
386                            };
387
388                            if is_falsy {
389                                let eval_code = eval_obj
390                                    .get("code")
391                                    .and_then(|c| c.as_str())
392                                    .map(|s| s.to_string())
393                                    .or_else(|| Some(format!("{}.evaluation.{}", field_path, idx)));
394
395                                let eval_message = eval_obj
396                                    .get("message")
397                                    .and_then(|m| m.as_str())
398                                    .unwrap_or("Validation failed")
399                                    .to_string();
400
401                                let eval_data = eval_obj.get("data").cloned();
402
403                                errors.insert(
404                                    field_path.to_string(),
405                                    ValidationError {
406                                        rule_type: "evaluation".to_string(),
407                                        message: eval_message,
408                                        code: eval_code,
409                                        pattern: None,
410                                        field_value: None,
411                                        data: eval_data,
412                                    },
413                                );
414
415                                // Stop at first failure
416                                break;
417                            }
418                        }
419                    }
420                }
421            }
422            _ => {
423                if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
424                    errors.insert(
425                        field_path.to_string(),
426                        ValidationError {
427                            rule_type: "evaluation".to_string(),
428                            message: rule_message,
429                            code: error_code.clone(),
430                            pattern: None,
431                            field_value: None,
432                            data: rule_data,
433                        },
434                    );
435                }
436            }
437        }
438    }
439
440    /// Returns `true` if `field_data` fails any of the dep field's schema rules.
441    ///
442    /// Rules are evaluated on-demand: compiled `LogicId`s from `self.evaluations` (set at
443    /// construction time) are executed directly against `scope_data`, completely bypassing
444    /// `evaluated_schema`. This avoids stale-cache issues during table dependency checks.
445    /// Unlike `validate_field`, this also evaluates the `required` rule on-demand.
446    pub(crate) fn dep_fails_schema_rules(
447        &self,
448        field_path: &str,
449        field_data: &Value,
450        scope_data: &Value,
451    ) -> bool {
452        let schema_pointer = path_utils::dot_notation_to_schema_pointer(field_path);
453        let pointer = schema_pointer.trim_start_matches('#');
454
455        let field_schema = match self.schema.pointer(pointer) {
456            Some(s) => s,
457            None => {
458                let alt_pointer = format!("/properties{}", pointer);
459                match self.schema.pointer(&alt_pointer) {
460                    Some(s) => s,
461                    None => return false,
462                }
463            }
464        };
465
466        let schema_map = match field_schema.as_object() {
467            Some(m) => m,
468            None => return false,
469        };
470
471        let rules = match schema_map.get("rules") {
472            Some(Value::Object(r)) => r,
473            _ => return false,
474        };
475
476        let schema_type = schema_map
477            .get("type")
478            .and_then(|t| t.as_str())
479            .unwrap_or("");
480
481        let is_empty = matches!(field_data, Value::Null)
482            || field_data.as_str().map_or(false, |s| s.is_empty())
483            || field_data.as_array().map_or(false, |a| a.is_empty());
484
485        for (rule_name, rule_value) in rules {
486            // Resolve the rule's active value on-demand.
487            // If a compiled LogicId exists in self.evaluations for this rule path, run it fresh
488            // against scope_data. Otherwise fall back to the static "value" from the raw schema.
489            let rule_eval_key = format!("#{}/rules/{}", pointer, rule_name);
490            let rule_active: Value = if let Some(logic_id) = self.evaluations.get(&rule_eval_key) {
491                let empty_ctx = Value::Object(serde_json::Map::new());
492                self.engine
493                    .run_with_context(logic_id, scope_data, &empty_ctx)
494                    .unwrap_or(Value::Null)
495            } else {
496                match rule_value {
497                    Value::Object(obj) => obj.get("value").cloned().unwrap_or(Value::Null),
498                    other => other.clone(),
499                }
500            };
501
502            if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
503                return true;
504            }
505        }
506
507        false
508    }
509}
510
511/// Pure rule-check: returns `true` if `rule_active` indicates `field_data` fails the rule.
512///
513/// This is the shared comparison kernel used by both `validate_rule` (full validation path)
514/// and `dep_fails_schema_rules` (on-demand dep checking). It is intentionally free of any
515/// schema/cache lookups — callers are responsible for resolving `rule_active` beforehand.
516///
517/// Handles: `required`, `minLength`, `maxLength`, `minValue`, `maxValue`, and custom/dynamic.
518/// Does NOT handle: `pattern` (needs regex cache), `evaluation` array format (complex structure).
519fn rule_value_fails(
520    rule_name: &str,
521    rule_active: &Value,
522    field_data: &Value,
523    is_empty: bool,
524    schema_type: &str,
525) -> bool {
526    let coerce_num = |v: &Value| -> Option<f64> {
527        if let Some(n) = v.as_f64() {
528            return Some(n);
529        }
530        if matches!(schema_type, "number" | "integer") {
531            if let Some(s) = v.as_str() {
532                return s.trim().parse::<f64>().ok();
533            }
534        }
535        None
536    };
537
538    match rule_name {
539        "required" => is_empty && matches!(rule_active, Value::Bool(true)),
540        "minLength" => {
541            if is_empty {
542                false
543            } else if let Some(min) = rule_active.as_u64() {
544                let len = match field_data {
545                    Value::String(s) => s.len(),
546                    Value::Array(a) => a.len(),
547                    _ => 0,
548                };
549                len < min as usize
550            } else {
551                false
552            }
553        }
554        "maxLength" => {
555            if is_empty {
556                false
557            } else if let Some(max) = rule_active.as_u64() {
558                let len = match field_data {
559                    Value::String(s) => s.len(),
560                    Value::Array(a) => a.len(),
561                    _ => 0,
562                };
563                len > max as usize
564            } else {
565                false
566            }
567        }
568        "minValue" => {
569            if is_empty {
570                false
571            } else if let Some(min) = rule_active.as_f64() {
572                coerce_num(field_data).map_or(false, |v| v < min)
573            } else {
574                false
575            }
576        }
577        "maxValue" => {
578            if is_empty {
579                false
580            } else if let Some(max) = rule_active.as_f64() {
581                coerce_num(field_data).map_or(false, |v| v > max)
582            } else {
583                false
584            }
585        }
586        // pattern and evaluation array are handled by their specific callers
587        "pattern" | "evaluation" => false,
588        _ => {
589            // Custom/dynamic rule: falsy rule_active = constraint not met = field invalid
590            if is_empty {
591                false
592            } else {
593                matches!(rule_active, Value::Bool(false) | Value::Null)
594                    || rule_active.as_f64() == Some(0.0)
595                    || rule_active.as_str().map_or(false, |s| s.is_empty())
596                    || rule_active.as_array().map_or(false, |a| a.is_empty())
597            }
598        }
599    }
600}