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    /// Invalidate the validation cache
14    pub(crate) fn invalidate_validation_cache(&self) {
15        let mut cache = match self.validation_cache.write() {
16            Ok(c) => c,
17            Err(poisoned) => poisoned.into_inner(),
18        };
19        cache.clear();
20    }
21
22    /// Validate data against schema rules
23    pub fn validate(
24        &mut self,
25        data: &str,
26        context: Option<&str>,
27        paths: Option<&[String]>,
28        token: Option<&CancellationToken>,
29        validate_readonly: Option<bool>,
30    ) -> Result<ValidationResult, String> {
31        let validate_ro = validate_readonly.unwrap_or(false);
32        if let Some(t) = token {
33            if t.is_cancelled() {
34                return Err("Cancelled".to_string());
35            }
36        }
37
38        // Fast path: if no path filtering and data/context are identical to last validation,
39        // return cached full result immediately.
40        if paths.is_none() || paths.is_some_and(|p| p.is_empty()) {
41            if let Ok(cache) = self.validation_cache.read() {
42                if let Some(cached) = cache.get_cached_full_result(data, context, validate_ro) {
43                    return Ok(cached);
44                }
45            }
46        }
47
48        time_block!("validate() [total]", {
49            // Acquire lock for synchronous execution
50            let _lock = self.eval_lock.lock().unwrap();
51
52            // Parse and update data
53            let (data_value, context_value) = time_block!("  parse data & context", {
54                let d = json_parser::parse_json_str(data)?;
55                let c = if let Some(ctx) = context {
56                    json_parser::parse_json_str(ctx)?
57                } else {
58                    Value::Object(serde_json::Map::new())
59                };
60                Ok::<_, String>((d, c))
61            })?;
62
63            // Update context
64            self.context = context_value.clone();
65
66            // Update eval_data with new data/context
67            time_block!("  replace_data_and_context", {
68                self.eval_data
69                    .replace_data_and_context(data_value.clone(), context_value);
70            });
71
72            // Drop lock before calling evaluate_others which needs mutable access
73            drop(_lock);
74
75            // Re-evaluate rule evaluations to ensure fresh values
76            // This ensures all rule.$evaluation expressions are re-computed
77            time_block!("  evaluate_others", {
78                self.evaluate_others(paths, token);
79            });
80
81            time_block!("  ensure_layout_resolved", {
82                self.ensure_layout_resolved();
83            });
84
85            let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
86
87            let layout_state = self.layout_state.read().unwrap();
88            let layout_hidden_refs = &layout_state.layout_hidden_refs;
89            let layout_disabled_refs = &layout_state.layout_disabled_refs;
90            let mut hidden_cache =
91                std::collections::HashMap::with_capacity(self.fields_with_rules.len());
92            let mut readonly_cache =
93                std::collections::HashMap::with_capacity(self.fields_with_rules.len());
94
95            // Use pre-parsed fields_with_rules from schema parsing (no runtime collection needed)
96            // This list was collected during schema parse and contains all fields with rules
97            time_block!("  fields_with_rules loop", {
98                for field_path in self.fields_with_rules.iter() {
99                    // Check if we should validate this path (path filtering)
100                    if let Some(filter_paths) = paths {
101                        if !filter_paths.is_empty()
102                            && !filter_paths.iter().any(|p| {
103                                field_path.starts_with(p.as_str()) || p.starts_with(field_path.as_str())
104                            })
105                        {
106                            continue;
107                        }
108                    }
109
110                    self.validate_field_cached(
111                        field_path,
112                        &data_value,
113                        layout_hidden_refs,
114                        layout_disabled_refs,
115                        &mut hidden_cache,
116                        &mut readonly_cache,
117                        validate_ro,
118                        &mut errors,
119                    );
120
121                    if let Some(t) = token {
122                        if t.is_cancelled() {
123                            return Err("Cancelled".to_string());
124                        }
125                    }
126                }
127            });
128
129            drop(layout_state);
130
131            let has_error = !errors.is_empty();
132            let result = ValidationResult { has_error, errors };
133
134            if paths.is_none() || paths.is_some_and(|p| p.is_empty()) {
135                if let Ok(mut cache) = self.validation_cache.write() {
136                    cache.save_full_result(
137                        data.to_string(),
138                        context.map(|s| s.to_string()),
139                        validate_ro,
140                        result.clone(),
141                    );
142                }
143            } else if let Ok(mut cache) = self.validation_cache.write() {
144                cache.invalidate_full_result();
145            }
146
147            Ok(result)
148        })
149    }
150
151    /// Validate using the data already present in `eval_data` (set by `with_item_cache_swap`).
152    ///
153    /// Skips JSON parsing and `replace_data_and_context` — use this inside the
154    /// cache-swap closure to avoid redundant work when the subform data is already set.
155    pub(crate) fn validate_pre_set(
156        &mut self,
157        data_value: Value,
158        paths: Option<&[String]>,
159        token: Option<&CancellationToken>,
160        validate_readonly: Option<bool>,
161    ) -> Result<crate::ValidationResult, String> {
162        let validate_ro = validate_readonly.unwrap_or(false);
163        // Re-evaluate rule evaluations with the current (already-set) data.
164        self.evaluate_others(paths, token);
165
166        self.ensure_layout_resolved();
167
168        let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
169
170        let layout_state = self.layout_state.read().unwrap();
171        let layout_hidden_refs = &layout_state.layout_hidden_refs;
172        let layout_disabled_refs = &layout_state.layout_disabled_refs;
173        let mut hidden_cache =
174            std::collections::HashMap::with_capacity(self.fields_with_rules.len());
175        let mut readonly_cache =
176            std::collections::HashMap::with_capacity(self.fields_with_rules.len());
177
178        for field_path in self.fields_with_rules.iter() {
179            if let Some(filter_paths) = paths {
180                if !filter_paths.is_empty()
181                    && !filter_paths.iter().any(|p| {
182                        field_path.starts_with(p.as_str()) || p.starts_with(field_path.as_str())
183                    })
184                {
185                    continue;
186                }
187            }
188            if let Some(t) = token {
189                if t.is_cancelled() {
190                    return Err("Cancelled".to_string());
191                }
192            }
193            self.validate_field_cached(
194                field_path,
195                &data_value,
196                layout_hidden_refs,
197                layout_disabled_refs,
198                &mut hidden_cache,
199                &mut readonly_cache,
200                validate_ro,
201                &mut errors,
202            );
203        }
204
205        drop(layout_state);
206
207        let has_error = !errors.is_empty();
208        Ok(crate::ValidationResult { has_error, errors })
209    }
210
211    /// Validate a single field that has rules (convenience wrapper without external cache)
212    #[allow(dead_code)]
213    pub(crate) fn validate_field(
214        &self,
215        field_path: &str,
216        data: &Value,
217        validate_readonly: bool,
218        errors: &mut IndexMap<String, ValidationError>,
219    ) {
220        let layout_state = self.layout_state.read().unwrap();
221        let mut hidden_cache = std::collections::HashMap::new();
222        let mut readonly_cache = std::collections::HashMap::new();
223        self.validate_field_cached(
224            field_path,
225            data,
226            &layout_state.layout_hidden_refs,
227            &layout_state.layout_disabled_refs,
228            &mut hidden_cache,
229            &mut readonly_cache,
230            validate_readonly,
231            errors,
232        );
233    }
234
235    /// Validate a single field that has rules, with pre-acquired layout refs and caches
236    #[allow(clippy::too_many_arguments)]
237    pub(crate) fn validate_field_cached(
238        &self,
239        field_path: &str,
240        data: &Value,
241        layout_hidden_refs: &indexmap::IndexSet<String>,
242        layout_disabled_refs: &indexmap::IndexSet<String>,
243        hidden_cache: &mut std::collections::HashMap<String, bool>,
244        readonly_cache: &mut std::collections::HashMap<String, bool>,
245        validate_readonly: bool,
246        errors: &mut IndexMap<String, ValidationError>,
247    ) {
248        // Skip if already has error
249        if errors.contains_key(field_path) {
250            return;
251        }
252
253        // Resolve schema for this field
254        let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
255        let pointer_path = schema_path.trim_start_matches('#');
256
257        // Try to get schema, if not found, try with /properties/ prefix for standard JSON Schema
258        let (field_schema, resolved_path) = match self.evaluated_schema.pointer(pointer_path) {
259            Some(s) => (s, pointer_path.to_string()),
260            None => {
261                let alt_path = format!("/properties{}", pointer_path);
262                match self.evaluated_schema.pointer(&alt_path) {
263                    Some(s) => (s, alt_path),
264                    None => return,
265                }
266            }
267        };
268
269        // Skip hidden fields using cached layout & schema lookup
270        let is_hidden = self.is_effective_hidden_with_cache(
271            &resolved_path,
272            layout_hidden_refs,
273            hidden_cache,
274        );
275        if is_hidden {
276            if let Ok(mut cache) = self.validation_cache.write() {
277                cache.update_field(
278                    field_path.to_string(),
279                    Value::Null,
280                    true,
281                    validate_readonly,
282                    Value::Null,
283                    None,
284                );
285            }
286            return;
287        }
288
289        // Skip readonly / disabled fields unless validate_readonly is true
290        if !validate_readonly {
291            let is_readonly = self.is_effective_readonly_with_cache(
292                &resolved_path,
293                layout_disabled_refs,
294                readonly_cache,
295            );
296            if is_readonly {
297                if let Ok(mut cache) = self.validation_cache.write() {
298                    cache.update_field(
299                        field_path.to_string(),
300                        Value::Null,
301                        false,
302                        false,
303                        Value::Null,
304                        None,
305                    );
306                }
307                return;
308            }
309        }
310
311        if let Value::Object(schema_map) = field_schema {
312            // Get rules object
313            let rules_val = match schema_map.get("rules") {
314                Some(r @ Value::Object(_)) => r,
315                _ => return,
316            };
317            let rules = rules_val.as_object().unwrap();
318
319            // Get field data
320            let field_data = self.get_field_data(field_path, data);
321
322            // Check field cache
323            let cached_lookup = if let Ok(cache) = self.validation_cache.read() {
324                cache.check_field_cache(field_path, &field_data, false, validate_readonly, rules_val)
325            } else {
326                None
327            };
328
329            if let Some(cached_error) = cached_lookup {
330                if let Some(err) = cached_error {
331                    errors.insert(field_path.to_string(), err);
332                }
333                return;
334            }
335
336            // Validate each rule
337            let mut field_error: Option<ValidationError> = None;
338            time_block!("    validate_rules loop", {
339                for (rule_name, rule_value) in rules {
340                    self.validate_rule(
341                        field_path,
342                        rule_name,
343                        rule_value,
344                        &field_data,
345                        schema_map,
346                        field_schema,
347                        errors,
348                    );
349                    if let Some(err) = errors.get(field_path) {
350                        field_error = Some(err.clone());
351                        break;
352                    }
353                }
354            });
355
356            if let Ok(mut cache) = self.validation_cache.write() {
357                cache.update_field(
358                    field_path.to_string(),
359                    field_data,
360                    false,
361                    validate_readonly,
362                    rules_val.clone(),
363                    field_error,
364                );
365            }
366        }
367    }
368
369    /// Get data value for a field path
370    pub(crate) fn get_field_data(&self, field_path: &str, data: &Value) -> Value {
371        let mut current = data;
372
373        for part in field_path.split('.') {
374            match current {
375                Value::Object(map) => {
376                    current = map.get(part).unwrap_or(&Value::Null);
377                }
378                _ => return Value::Null,
379            }
380        }
381
382        current.clone()
383    }
384
385    /// Validate a single rule
386    #[allow(clippy::too_many_arguments)]
387    pub(crate) fn validate_rule(
388        &self,
389        field_path: &str,
390        rule_name: &str,
391        rule_value: &Value,
392        field_data: &Value,
393        schema_map: &serde_json::Map<String, Value>,
394        _schema: &Value,
395        errors: &mut IndexMap<String, ValidationError>,
396    ) {
397        // Skip if already has error
398        if errors.contains_key(field_path) {
399            return;
400        }
401
402        let schema_type = schema_map
403            .get("type")
404            .and_then(|t| t.as_str())
405            .unwrap_or("");
406
407        // The rule_value passed in already reflects the evaluated rule from evaluated_schema
408        let evaluated_rule = rule_value;
409
410        // Extract rule active status, message, etc
411        // Logic depends on rule structure (object with value/message or direct value)
412        let (rule_active, rule_message, rule_code, rule_data) = match evaluated_rule {
413            Value::Object(rule_obj) => {
414                let active = rule_obj.get("value").unwrap_or(&Value::Bool(false));
415
416                // Handle message - could be string or object with "value"
417                let message = match rule_obj.get("message") {
418                    Some(Value::String(s)) => s.clone(),
419                    Some(Value::Object(msg_obj)) if msg_obj.contains_key("value") => msg_obj
420                        .get("value")
421                        .and_then(|v| v.as_str())
422                        .unwrap_or("Validation failed")
423                        .to_string(),
424                    Some(msg_val) => msg_val.as_str().unwrap_or("Validation failed").to_string(),
425                    None => "Validation failed".to_string(),
426                };
427
428                let code = rule_obj
429                    .get("code")
430                    .and_then(|c| c.as_str())
431                    .map(|s| s.to_string());
432
433                // Handle data - extract "value" from objects with $evaluation
434                let data = rule_obj.get("data").map(|d| {
435                    if let Value::Object(data_obj) = d {
436                        let mut cleaned_data = serde_json::Map::new();
437                        for (key, value) in data_obj {
438                            // If value is an object with only "value" key, extract it
439                            if let Value::Object(val_obj) = value {
440                                if val_obj.len() == 1 && val_obj.contains_key("value") {
441                                    cleaned_data.insert(key.clone(), val_obj["value"].clone());
442                                } else {
443                                    cleaned_data.insert(key.clone(), value.clone());
444                                }
445                            } else {
446                                cleaned_data.insert(key.clone(), value.clone());
447                            }
448                        }
449                        Value::Object(cleaned_data)
450                    } else {
451                        d.clone()
452                    }
453                });
454
455                (active.clone(), message, code, data)
456            }
457            _ => (
458                evaluated_rule.clone(),
459                "Validation failed".to_string(),
460                None,
461                None,
462            ),
463        };
464
465        // Generate default code if not provided
466        let error_code = rule_code.or_else(|| Some(format!("{}.{}", field_path, rule_name)));
467
468        let is_empty = matches!(field_data, Value::Null)
469            || (field_data.is_string() && field_data.as_str().unwrap_or("").is_empty())
470            || (field_data.is_array() && field_data.as_array().unwrap().is_empty());
471
472        match rule_name {
473            "required" => {
474                if rule_active == Value::Bool(true) {
475                    if is_empty {
476                        errors.insert(
477                            field_path.to_string(),
478                            ValidationError {
479                                rule_type: "required".to_string(),
480                                message: rule_message,
481                                code: error_code,
482                                pattern: None,
483                                field_value: None,
484                                data: None,
485                            },
486                        );
487                    }
488                }
489            }
490            "minLength" | "maxLength" | "minValue" | "maxValue" => {
491                if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
492                    errors.insert(
493                        field_path.to_string(),
494                        ValidationError {
495                            rule_type: rule_name.to_string(),
496                            message: rule_message,
497                            code: error_code,
498                            pattern: None,
499                            field_value: None,
500                            data: None,
501                        },
502                    );
503                }
504            }
505
506            "pattern" => {
507                if !is_empty {
508                    if let Some(pattern) = rule_active.as_str() {
509                        if let Some(text) = field_data.as_str() {
510                            let cached_regex = if let Ok(cache) = self.regex_cache.read() {
511                                cache.get(pattern).cloned()
512                            } else {
513                                None
514                            };
515                            let regex = match cached_regex {
516                                Some(r) => r,
517                                None => {
518                                    let mut cache = self.regex_cache.write().unwrap();
519                                    cache.entry(pattern.to_string()).or_insert_with(|| {
520                                        regex::Regex::new(pattern)
521                                            .unwrap_or_else(|_| regex::Regex::new("(?:)").unwrap())
522                                    }).clone()
523                                }
524                            };
525                            if !regex.is_match(text) {
526                                errors.insert(
527                                    field_path.to_string(),
528                                    ValidationError {
529                                        rule_type: "pattern".to_string(),
530                                        message: rule_message,
531                                        code: error_code,
532                                        pattern: Some(pattern.to_string()),
533                                        field_value: Some(text.to_string()),
534                                        data: None,
535                                    },
536                                );
537                            }
538                        }
539                    }
540                }
541            }
542            "evaluation" => {
543                // Handle array of evaluation rules
544                // Format: "evaluation": [{ "code": "...", "message": "...", "$evaluation": {...} }]
545                if let Value::Array(eval_array) = evaluated_rule {
546                    for (idx, eval_item) in eval_array.iter().enumerate() {
547                        if let Value::Object(eval_obj) = eval_item {
548                            // Get the evaluated value (should be in "value" key after evaluation)
549                            let eval_result = eval_obj.get("value").unwrap_or(&Value::Bool(true));
550
551                            // Check if result is falsy
552                            let is_falsy = match eval_result {
553                                Value::Bool(false) => true,
554                                Value::Null => true,
555                                Value::Number(n) => n.as_f64() == Some(0.0),
556                                Value::String(s) => s.is_empty(),
557                                Value::Array(a) => a.is_empty(),
558                                _ => false,
559                            };
560
561                            if is_falsy {
562                                let eval_code = eval_obj
563                                    .get("code")
564                                    .and_then(|c| c.as_str())
565                                    .map(|s| s.to_string())
566                                    .or_else(|| Some(format!("{}.evaluation.{}", field_path, idx)));
567
568                                let eval_message = eval_obj
569                                    .get("message")
570                                    .and_then(|m| m.as_str())
571                                    .unwrap_or("Validation failed")
572                                    .to_string();
573
574                                let eval_data = eval_obj.get("data").cloned();
575
576                                errors.insert(
577                                    field_path.to_string(),
578                                    ValidationError {
579                                        rule_type: "evaluation".to_string(),
580                                        message: eval_message,
581                                        code: eval_code,
582                                        pattern: None,
583                                        field_value: None,
584                                        data: eval_data,
585                                    },
586                                );
587
588                                // Stop at first failure
589                                break;
590                            }
591                        }
592                    }
593                }
594            }
595            _ => {
596                if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
597                    errors.insert(
598                        field_path.to_string(),
599                        ValidationError {
600                            rule_type: "evaluation".to_string(),
601                            message: rule_message,
602                            code: error_code,
603                            pattern: None,
604                            field_value: None,
605                            data: rule_data,
606                        },
607                    );
608                }
609            }
610        }
611    }
612
613    /// Returns `true` if `field_data` fails any of the dep field's schema rules.
614    ///
615    /// Rules are evaluated on-demand: compiled `LogicId`s from `self.evaluations` (set at
616    /// construction time) are executed directly against `scope_data`, completely bypassing
617    /// `evaluated_schema`. This avoids stale-cache issues during table dependency checks.
618    /// Unlike `validate_field`, this also evaluates the `required` rule on-demand.
619    pub(crate) fn dep_fails_schema_rules(
620        &self,
621        field_path: &str,
622        field_data: &Value,
623        scope_data: &Value,
624    ) -> bool {
625        let schema_pointer = path_utils::dot_notation_to_schema_pointer(field_path);
626        let pointer = schema_pointer.trim_start_matches('#');
627
628        let field_schema = match self.schema.pointer(pointer) {
629            Some(s) => s,
630            None => {
631                let alt_pointer = format!("/properties{}", pointer);
632                match self.schema.pointer(&alt_pointer) {
633                    Some(s) => s,
634                    None => return false,
635                }
636            }
637        };
638
639        let schema_map = match field_schema.as_object() {
640            Some(m) => m,
641            None => return false,
642        };
643
644        let rules = match schema_map.get("rules") {
645            Some(Value::Object(r)) => r,
646            _ => return false,
647        };
648
649        let schema_type = schema_map
650            .get("type")
651            .and_then(|t| t.as_str())
652            .unwrap_or("");
653
654        let is_empty = matches!(field_data, Value::Null)
655            || field_data.as_str().map_or(false, |s| s.is_empty())
656            || field_data.as_array().map_or(false, |a| a.is_empty());
657
658        for (rule_name, rule_value) in rules {
659            // Resolve the rule's active value on-demand.
660            // If a compiled LogicId exists in self.evaluations for this rule path, run it fresh
661            // against scope_data. Otherwise fall back to the static "value" from the raw schema.
662            let rule_eval_key = format!("#{}/rules/{}", pointer, rule_name);
663            let rule_active: Value = if let Some(logic_id) = self.evaluations.get(&rule_eval_key) {
664                let empty_ctx = Value::Object(serde_json::Map::new());
665                self.engine
666                    .run_with_context(logic_id, scope_data, &empty_ctx)
667                    .unwrap_or(Value::Null)
668            } else {
669                match rule_value {
670                    Value::Object(obj) => obj.get("value").cloned().unwrap_or(Value::Null),
671                    other => other.clone(),
672                }
673            };
674
675            if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
676                return true;
677            }
678        }
679
680        false
681    }
682}
683
684/// Pure rule-check: returns `true` if `rule_active` indicates `field_data` fails the rule.
685///
686/// This is the shared comparison kernel used by both `validate_rule` (full validation path)
687/// and `dep_fails_schema_rules` (on-demand dep checking). It is intentionally free of any
688/// schema/cache lookups — callers are responsible for resolving `rule_active` beforehand.
689///
690/// Handles: `required`, `minLength`, `maxLength`, `minValue`, `maxValue`, and custom/dynamic.
691/// Does NOT handle: `pattern` (needs regex cache), `evaluation` array format (complex structure).
692fn rule_value_fails(
693    rule_name: &str,
694    rule_active: &Value,
695    field_data: &Value,
696    is_empty: bool,
697    schema_type: &str,
698) -> bool {
699    let coerce_num = |v: &Value| -> Option<f64> {
700        if let Some(n) = v.as_f64() {
701            return Some(n);
702        }
703        if matches!(schema_type, "number" | "integer") {
704            if let Some(s) = v.as_str() {
705                return s.trim().parse::<f64>().ok();
706            }
707        }
708        None
709    };
710
711    match rule_name {
712        "required" => is_empty && matches!(rule_active, Value::Bool(true)),
713        "minLength" => {
714            if is_empty {
715                false
716            } else if let Some(min) = rule_active.as_u64() {
717                let len = match field_data {
718                    Value::String(s) => s.len(),
719                    Value::Array(a) => a.len(),
720                    _ => 0,
721                };
722                len < min as usize
723            } else {
724                false
725            }
726        }
727        "maxLength" => {
728            if is_empty {
729                false
730            } else if let Some(max) = rule_active.as_u64() {
731                let len = match field_data {
732                    Value::String(s) => s.len(),
733                    Value::Array(a) => a.len(),
734                    _ => 0,
735                };
736                len > max as usize
737            } else {
738                false
739            }
740        }
741        "minValue" => {
742            if is_empty {
743                false
744            } else if let Some(min) = rule_active.as_f64() {
745                coerce_num(field_data).map_or(false, |v| v < min)
746            } else {
747                false
748            }
749        }
750        "maxValue" => {
751            if is_empty {
752                false
753            } else if let Some(max) = rule_active.as_f64() {
754                coerce_num(field_data).map_or(false, |v| v > max)
755            } else {
756                false
757            }
758        }
759        // pattern and evaluation array are handled by their specific callers
760        "pattern" | "evaluation" => false,
761        _ => {
762            // Custom/dynamic rule: falsy rule_active = constraint not met = field invalid
763            if is_empty {
764                false
765            } else {
766                matches!(rule_active, Value::Bool(false) | Value::Null)
767                    || rule_active.as_f64() == Some(0.0)
768                    || rule_active.as_str().map_or(false, |s| s.is_empty())
769                    || rule_active.as_array().map_or(false, |a| a.is_empty())
770            }
771        }
772    }
773}