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