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