Skip to main content

json_eval_rs/jsoneval/
dependents.rs

1use super::JSONEval;
2use crate::jsoneval::json_parser;
3use crate::jsoneval::path_utils;
4use crate::jsoneval::path_utils::get_value_by_pointer_without_properties;
5use crate::jsoneval::path_utils::normalize_to_json_pointer;
6use crate::rlogic::{LogicId, RLogic};
7use crate::jsoneval::types::DependentItem;
8use crate::jsoneval::cancellation::CancellationToken;
9use crate::utils::clean_float_noise_scalar;
10use crate::EvalData;
11
12use indexmap::{IndexMap, IndexSet};
13use serde_json::Value;
14
15
16impl JSONEval {
17    /// Evaluate fields that depend on a changed path
18    /// This processes all dependent fields transitively when a source field changes
19    pub fn evaluate_dependents(
20        &mut self,
21        changed_paths: &[String],
22        data: Option<&str>,
23        context: Option<&str>,
24        re_evaluate: bool,
25        token: Option<&CancellationToken>,
26        mut canceled_paths: Option<&mut Vec<String>>,
27        include_subforms: bool,
28    ) -> Result<Value, String> {
29        // Check cancellation
30        if let Some(t) = token {
31            if t.is_cancelled() {
32                return Err("Cancelled".to_string());
33            }
34        }
35        // Acquire lock for synchronous execution
36        let _lock = self.eval_lock.lock().unwrap();
37
38        // Update data if provided
39        if let Some(data_str) = data {
40            let data_value = json_parser::parse_json_str(data_str)?;
41            let context_value = if let Some(ctx) = context {
42                json_parser::parse_json_str(ctx)?
43            } else {
44                Value::Object(serde_json::Map::new())
45            };
46            self.eval_data
47                .replace_data_and_context(data_value.clone(), context_value);
48        }
49
50        let mut result = Vec::new();
51        let mut processed = IndexSet::new();
52
53        let mut to_process: Vec<(String, bool)> = changed_paths
54            .iter()
55            .map(|path| (path_utils::dot_notation_to_schema_pointer(path), false))
56            .collect();
57
58        Self::process_dependents_queue(
59            &self.engine,
60            &self.evaluations,
61            &mut self.eval_data,
62            &self.dependents_evaluations,
63            &self.evaluated_schema,
64            &mut to_process,
65            &mut processed,
66            &mut result,
67            token,
68            canceled_paths.as_mut().map(|v| &mut **v),
69        )?;
70
71
72        // If re_evaluate is true, perform full evaluation with the mutated eval_data
73        // Then perform post-evaluation checks (ReadOnly, Hidden)
74        if re_evaluate {
75            // Drop lock for evaluate_internal
76            drop(_lock);
77
78            self.evaluate_internal(None, token)?;
79
80            
81            // Re-acquire lock for ReadOnly/Hidden processing
82            let _lock = self.eval_lock.lock().unwrap();
83
84            // 1. Read-Only Pass
85            // Collect read-only fields - include ALL readonly values in the result
86            let mut readonly_changes = Vec::new();
87            let mut readonly_values = Vec::new();  // Track all readonly values (including unchanged)
88            
89            // OPTIMIZATION: Use conditional_readonly_fields cache instead of recursing whole schema
90            // self.collect_readonly_fixes(&self.evaluated_schema, "#", &mut readonly_changes);
91            for path in self.conditional_readonly_fields.iter() {
92                let normalized = path_utils::normalize_to_json_pointer(path);
93                if let Some(schema_element) = self.evaluated_schema.pointer(&normalized) {
94                    self.check_readonly_for_dependents(schema_element, path, &mut readonly_changes, &mut readonly_values);
95                }
96            }
97
98            // Apply fixes for changed values and add to queue
99            for (path, schema_value) in readonly_changes {
100                // Set data to match schema value
101                let data_path = path_utils::normalize_to_json_pointer(&path)
102                    .replace("/properties/", "/")
103                    .trim_start_matches('#')
104                    .to_string();
105                
106                self.eval_data.set(&data_path, schema_value.clone());
107                
108                // Add to process queue for changed values
109                to_process.push((path, true));
110            }
111            
112            // Add ALL readonly values to result (both changed and unchanged)
113            for (path, schema_value) in readonly_values {
114                let data_path = path_utils::normalize_to_json_pointer(&path)
115                    .replace("/properties/", "/")
116                    .trim_start_matches('#')
117                    .to_string();
118                
119                let mut change_obj = serde_json::Map::new();
120                change_obj.insert("$ref".to_string(), Value::String(path_utils::pointer_to_dot_notation(&data_path)));
121                change_obj.insert("$readonly".to_string(), Value::Bool(true));
122                change_obj.insert("value".to_string(), schema_value);
123                
124                result.push(Value::Object(change_obj));
125            }
126            
127            if !to_process.is_empty() {
128                 Self::process_dependents_queue(
129                    &self.engine,
130                    &self.evaluations,
131                    &mut self.eval_data,
132                    &self.dependents_evaluations,
133                    &self.evaluated_schema,
134                    &mut to_process,
135                    &mut processed,
136                    &mut result,
137                    token,
138                    canceled_paths.as_mut().map(|v| &mut **v),
139                )?;
140            }
141
142
143            // 2. Recursive Hide Pass
144            // Collect hidden fields that have values
145            let mut hidden_fields = Vec::new();
146            // OPTIMIZATION: Use conditional_hidden_fields cache instead of recursing whole schema
147            // self.collect_hidden_fields(&self.evaluated_schema, "#", &mut hidden_fields);
148            for path in self.conditional_hidden_fields.iter() {
149                let normalized = path_utils::normalize_to_json_pointer(path);
150                 if let Some(schema_element) = self.evaluated_schema.pointer(&normalized) {
151                    self.check_hidden_field(schema_element, path, &mut hidden_fields);
152                }
153            }
154            
155            if !hidden_fields.is_empty() {
156                Self::recursive_hide_effect(
157                    &self.engine,
158                    &self.evaluations,
159                    &self.reffed_by,
160                    &mut self.eval_data,
161                    hidden_fields,
162                    &mut to_process,
163                    &mut result,
164                );
165            }
166
167            
168             if !to_process.is_empty() {
169                 Self::process_dependents_queue(
170                    &self.engine,
171                    &self.evaluations,
172                    &mut self.eval_data,
173                    &self.dependents_evaluations,
174                    &self.evaluated_schema,
175                    &mut to_process,
176                    &mut processed,
177                    &mut result,
178                    token,
179                    canceled_paths.as_mut().map(|v| &mut **v),
180                )?;
181            }
182
183        }
184
185        if include_subforms {
186            let subform_paths: Vec<String> = self.subforms.keys().cloned().collect();
187            for subform_path in subform_paths {
188                let field_key = subform_field_key(&subform_path);
189                let subform_dot_path = crate::jsoneval::path_utils::pointer_to_dot_notation(&subform_path).replace(".properties.", ".");
190                let field_prefix = format!("{}.", field_key);
191
192                // Borrow array items from current eval_data (zero-cost Arc deref)
193                let items: Vec<Value> = get_value_by_pointer_without_properties(self.eval_data.data(), &normalize_to_json_pointer(&subform_path))
194                    .and_then(|v| v.as_array())
195                    .cloned()          // clone the Vec<Value> reference list (shallow Arc clone per item)
196                    .unwrap_or_default();
197
198                if items.is_empty() {
199                    continue;
200                }
201
202                for (idx, item) in items.iter().enumerate() {
203                    // Subforms expect the data to be wrapped in the field key
204                    // We merge main form data so that $params and other top-level fields are available
205                    let mut merged_data = self.eval_data.data().clone();
206                    if let Value::Object(ref mut map) = merged_data {
207                        map.insert(field_key.clone(), item.clone());
208                    } else {
209                        let mut map = serde_json::Map::new();
210                        map.insert(field_key.clone(), item.clone());
211                        merged_data = Value::Object(map);
212                    }
213                    
214                    // Map absolute array changed paths (e.g., "illustration.insured.riders[0].base") to flat internal paths ("riders.base")
215                    let mut item_changed_paths = Vec::new();
216                    let prefix_with_bracket = format!("{}[{}].", subform_dot_path, idx);
217                    let prefix_with_dot = format!("{}.{}.", subform_dot_path, idx);
218                    
219                    for path in changed_paths {
220                        if path.starts_with(&prefix_with_bracket) {
221                            item_changed_paths.push(path.replacen(&prefix_with_bracket, &field_prefix, 1));
222                        } else if path.starts_with(&prefix_with_dot) {
223                            item_changed_paths.push(path.replacen(&prefix_with_dot, &field_prefix, 1));
224                        }
225                        // Fallback support for paths matching the internal field key exactly without parent context
226                        else if path.starts_with(&format!("{}[{}].", field_key, idx)) {
227                            item_changed_paths.push(path.replacen(&format!("{}[{}].", field_key, idx), &field_prefix, 1));
228                        }
229                    }
230
231                    // If there are no relevant changes and we aren't re-evaluating, we can skip this item
232                    if item_changed_paths.is_empty() && !re_evaluate {
233                        continue;
234                    }
235
236                    if let Some(subform) = self.subforms.get_mut(&subform_path) {
237                        // Bypass string overhead: update the subform data natively!
238                        // This achieves "zero-copy" across string boundaries, though native serde_json::Value
239                        // internal mapping still clones fields natively during the map insert.
240                        subform.eval_data.replace_data_and_context(
241                            merged_data, 
242                            self.eval_data.data().get("$context").cloned().unwrap_or(serde_json::Value::Null)
243                        );
244
245                        // context is passed through as-is natively via replace_data_and_context above
246                        let subform_result_or_err = subform.evaluate_dependents(
247                            &item_changed_paths,
248                            None, // passing None skips string parsing entirely
249                            None,
250                            re_evaluate,
251                            token,
252                            None,
253                            false,
254                        );
255
256                        if let Ok(Value::Array(changes)) = subform_result_or_err {
257                            for change in changes {
258                                if let Some(obj) = change.as_object() {
259                                    let mut new_obj = obj.clone();
260                                    if let Some(Value::String(ref_path)) = obj.get("$ref") {
261                                        // Map the isolated subform's path (e.g. "riders.calculated.value") 
262                                        // to the absolute index-qualified parent path (e.g. "illustration.insured.riders.0.calculated.value")
263                                        let new_ref = if ref_path.starts_with(&field_prefix) {
264                                            let trailing_path = &ref_path[field_prefix.len()..];
265                                            format!("{}.{}.{}", subform_dot_path, idx, trailing_path)
266                                        } else {
267                                            // Fallback if the path didn't start with the subform's root key
268                                            format!("{}.{}.{}", subform_dot_path, idx, ref_path)
269                                        };
270                                        new_obj.insert("$ref".to_string(), Value::String(new_ref));
271                                    }
272                                    result.push(Value::Object(new_obj));
273                                }
274                            }
275                        }
276                    }
277                }
278            }
279        }
280
281        Ok(Value::Array(result))
282    }
283
284    /// Helper to evaluate a dependent value - uses pre-compiled eval keys for fast lookup
285    pub(crate) fn evaluate_dependent_value_static(
286        engine: &RLogic,
287        evaluations: &IndexMap<String, LogicId>,
288        eval_data: &EvalData,
289        value: &Value,
290        changed_field_value: &Value,
291        changed_field_ref_value: &Value,
292    ) -> Result<Value, String> {
293        match value {
294            // If it's a String, check if it's an eval key reference
295            Value::String(eval_key) => {
296                if let Some(logic_id) = evaluations.get(eval_key) {
297                    // It's a pre-compiled evaluation - run it with scoped context
298                    // Create internal context with $value and $refValue
299                    let mut internal_context = serde_json::Map::new();
300                    internal_context.insert("$value".to_string(), changed_field_value.clone());
301                    internal_context.insert("$refValue".to_string(), changed_field_ref_value.clone());
302                    let context_value = Value::Object(internal_context);
303
304                    let result = engine.run_with_context(logic_id, eval_data.data(), &context_value)
305                        .map_err(|e| format!("Failed to evaluate dependent logic '{}': {}", eval_key, e))?;
306                    Ok(result)
307                } else {
308                    // It's a regular string value
309                    Ok(value.clone())
310                }
311            }
312            // For backwards compatibility: compile $evaluation on-the-fly
313            // This shouldn't happen with properly parsed schemas
314            Value::Object(map) if map.contains_key("$evaluation") => {
315                Err("Dependent evaluation contains unparsed $evaluation - schema was not properly parsed".to_string())
316            }
317            // Primitive value - return as-is
318            _ => Ok(value.clone()),
319        }
320    }
321
322    /// Check if a single field is readonly and populate vectors for both changes and all values
323    pub(crate) fn check_readonly_for_dependents(
324        &self,
325        schema_element: &Value,
326        path: &str,
327        changes: &mut Vec<(String, Value)>,
328        all_values: &mut Vec<(String, Value)>,
329    ) {
330        match schema_element {
331            Value::Object(map) => {
332                // Check if field is disabled (ReadOnly)
333                let mut is_disabled = false;
334                if let Some(Value::Object(condition)) = map.get("condition") {
335                    if let Some(Value::Bool(d)) = condition.get("disabled") {
336                        is_disabled = *d;
337                    }
338                }
339
340                // Check skipReadOnlyValue config
341                 let mut skip_readonly = false;
342                if let Some(Value::Object(config)) = map.get("config") {
343                    if let Some(Value::Object(all)) = config.get("all") {
344                         if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
345                             skip_readonly = *skip;
346                         }
347                    }
348                }
349
350                if is_disabled && !skip_readonly {
351                    if let Some(schema_value) = map.get("value") {
352                         let data_path = path_utils::normalize_to_json_pointer(path)
353                            .replace("/properties/", "/")
354                            .trim_start_matches('#')
355                            .to_string();
356                         
357                         let current_data = self.eval_data.data().pointer(&data_path).unwrap_or(&Value::Null);
358                         
359                         // Add to all_values (include in dependents result regardless of change)
360                         all_values.push((path.to_string(), schema_value.clone()));
361                         
362                         // Only add to changes if value doesn't match
363                         if current_data != schema_value {
364                             changes.push((path.to_string(), schema_value.clone()));
365                         }
366                    }
367                }
368            }
369            _ => {}
370        }
371    }
372    
373    /// Recursively collect read-only fields that need updates (Legacy/Full-Scan)
374    #[allow(dead_code)]
375    pub(crate) fn collect_readonly_fixes(
376        &self,
377        schema_element: &Value,
378        path: &str,
379        changes: &mut Vec<(String, Value)>,
380    ) {
381        match schema_element {
382            Value::Object(map) => {
383                // Check if field is disabled (ReadOnly)
384                let mut is_disabled = false;
385                if let Some(Value::Object(condition)) = map.get("condition") {
386                    if let Some(Value::Bool(d)) = condition.get("disabled") {
387                        is_disabled = *d;
388                    }
389                }
390
391                // Check skipReadOnlyValue config
392                 let mut skip_readonly = false;
393                if let Some(Value::Object(config)) = map.get("config") {
394                    if let Some(Value::Object(all)) = config.get("all") {
395                         if let Some(Value::Bool(skip)) = all.get("skipReadOnlyValue") {
396                             skip_readonly = *skip;
397                         }
398                    }
399                }
400
401                if is_disabled && !skip_readonly {
402                    // Check if it's a value field (has "value" property or implicit via path?)
403                    // In JS: "const readOnlyValues = this.getSchemaValues();"
404                    // We only care if data != schema value
405                    if let Some(schema_value) = map.get("value") {
406                         let data_path = path_utils::normalize_to_json_pointer(path)
407                            .replace("/properties/", "/")
408                            .trim_start_matches('#')
409                            .to_string();
410                         
411                         let current_data = self.eval_data.data().pointer(&data_path).unwrap_or(&Value::Null);
412                         
413                         if current_data != schema_value {
414                             changes.push((path.to_string(), schema_value.clone()));
415                         }
416                    }
417                }
418
419                // Recurse into properties
420                 if let Some(Value::Object(props)) = map.get("properties") {
421                    for (key, val) in props {
422                        let next_path = if path == "#" {
423                            format!("#/properties/{}", key)
424                        } else {
425                            format!("{}/properties/{}", path, key)
426                        };
427                        self.collect_readonly_fixes(val, &next_path, changes);
428                    }
429                }
430            }
431            _ => {}
432        }
433    }
434
435    /// Check if a single field is hidden and needs clearing (Optimized non-recursive)
436    pub(crate) fn check_hidden_field(
437        &self,
438        schema_element: &Value,
439        path: &str,
440        hidden_fields: &mut Vec<String>,
441    ) {
442         match schema_element {
443            Value::Object(map) => {
444                 // Check if field is hidden
445                let mut is_hidden = false;
446                if let Some(Value::Object(condition)) = map.get("condition") {
447                    if let Some(Value::Bool(h)) = condition.get("hidden") {
448                        is_hidden = *h;
449                    }
450                }
451
452                 // Check keepHiddenValue config
453                 let mut keep_hidden = false;
454                if let Some(Value::Object(config)) = map.get("config") {
455                    if let Some(Value::Object(all)) = config.get("all") {
456                         if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
457                             keep_hidden = *keep;
458                         }
459                    }
460                }
461
462                if is_hidden && !keep_hidden {
463                     let data_path = path_utils::normalize_to_json_pointer(path)
464                        .replace("/properties/", "/")
465                        .trim_start_matches('#')
466                        .to_string();
467
468                     let current_data = self.eval_data.data().pointer(&data_path).unwrap_or(&Value::Null);
469                     
470                     // If hidden and has non-empty value, add to list
471                     if current_data != &Value::Null && current_data != "" {
472                         hidden_fields.push(path.to_string());
473                     }
474                }
475            }
476             _ => {}
477         }
478    }
479
480    /// Recursively collect hidden fields that have values (candidates for clearing) (Legacy/Full-Scan)
481    #[allow(dead_code)]
482    pub(crate) fn collect_hidden_fields(
483        &self,
484        schema_element: &Value,
485        path: &str,
486        hidden_fields: &mut Vec<String>,
487    ) {
488         match schema_element {
489            Value::Object(map) => {
490                 // Check if field is hidden
491                let mut is_hidden = false;
492                if let Some(Value::Object(condition)) = map.get("condition") {
493                    if let Some(Value::Bool(h)) = condition.get("hidden") {
494                        is_hidden = *h;
495                    }
496                }
497
498                 // Check keepHiddenValue config
499                 let mut keep_hidden = false;
500                if let Some(Value::Object(config)) = map.get("config") {
501                    if let Some(Value::Object(all)) = config.get("all") {
502                         if let Some(Value::Bool(keep)) = all.get("keepHiddenValue") {
503                             keep_hidden = *keep;
504                         }
505                    }
506                }
507                
508                if is_hidden && !keep_hidden {
509                     let data_path = path_utils::normalize_to_json_pointer(path)
510                        .replace("/properties/", "/")
511                        .trim_start_matches('#')
512                        .to_string();
513
514                     let current_data = self.eval_data.data().pointer(&data_path).unwrap_or(&Value::Null);
515                     
516                     // If hidden and has non-empty value, add to list
517                     if current_data != &Value::Null && current_data != "" {
518                         hidden_fields.push(path.to_string());
519                     }
520                }
521
522                // Recurse into children
523                for (key, val) in map {
524                    if key == "properties" {
525                        if let Value::Object(props) = val {
526                            for (p_key, p_val) in props {
527                                let next_path = if path == "#" {
528                                    format!("#/properties/{}", p_key)
529                                } else {
530                                    format!("{}/properties/{}", path, p_key)
531                                };
532                                self.collect_hidden_fields(p_val, &next_path, hidden_fields);
533                            }
534                        }
535                    } else if let Value::Object(_) = val {
536                        // Skip known metadata keys and explicitly handled keys
537                        if key == "condition" 
538                            || key == "config" 
539                            || key == "rules" 
540                            || key == "dependents" 
541                            || key == "hideLayout" 
542                            || key == "$layout" 
543                            || key == "$params" 
544                            || key == "definitions"
545                            || key == "$defs"
546                            || key.starts_with('$') 
547                        {
548                            continue;
549                        }
550                        
551                         let next_path = if path == "#" {
552                            format!("#/{}", key)
553                        } else {
554                            format!("{}/{}", path, key)
555                        };
556                        self.collect_hidden_fields(val, &next_path, hidden_fields);
557                    }
558                }
559            }
560            _ => {}
561        }
562    }
563
564    /// Perform recursive hiding effect using reffed_by graph.
565    /// Collects every data path that gets nulled into `invalidated_paths`.
566    pub(crate) fn recursive_hide_effect(
567        engine: &RLogic,
568        evaluations: &IndexMap<String, LogicId>,
569        reffed_by: &IndexMap<String, Vec<String>>,
570        eval_data: &mut EvalData,
571        mut hidden_fields: Vec<String>,
572        queue: &mut Vec<(String, bool)>, 
573        result: &mut Vec<Value>,
574    ) {
575        while let Some(hf) = hidden_fields.pop() {
576            let data_path = path_utils::normalize_to_json_pointer(&hf)
577                .replace("/properties/", "/")
578                .trim_start_matches('#')
579                .to_string();
580            
581            // clear data
582            eval_data.set(&data_path, Value::Null);
583            
584             // Create dependent object for result
585            let mut change_obj = serde_json::Map::new();
586            change_obj.insert("$ref".to_string(), Value::String(path_utils::pointer_to_dot_notation(&data_path)));
587            change_obj.insert("$hidden".to_string(), Value::Bool(true));
588            change_obj.insert("clear".to_string(), Value::Bool(true));
589            result.push(Value::Object(change_obj));
590            
591            // Add to queue for standard dependent processing
592            queue.push((hf.clone(), true));
593
594            // Check reffed_by to find other fields that might become hidden
595            if let Some(referencing_fields) = reffed_by.get(&data_path) {
596                for rb in referencing_fields {
597                    // Evaluate condition.hidden for rb
598                    // We need a way to run specific evaluation?
599                    // We can check if rb has a hidden evaluation in self.evaluations
600                    let hidden_eval_key = format!("{}/condition/hidden", rb);
601                    
602                    if let Some(logic_id) = evaluations.get(&hidden_eval_key) {
603                        // Run evaluation
604                        // Context: $value = current field (rb) value? No, $value usually refers to changed field in deps.
605                        // But here we are just re-evaluating the rule.
606                        // In JS logic: "const result = hiddenFn(runnerCtx);"
607                        // runnerCtx has the updated data (we just set hf to null).
608                        
609                         let rb_data_path = path_utils::normalize_to_json_pointer(rb)
610                                .replace("/properties/", "/")
611                                .trim_start_matches('#')
612                                .to_string();
613                         let rb_value = eval_data.data().pointer(&rb_data_path).cloned().unwrap_or(Value::Null);
614                         
615                         // We can use engine.run w/ eval_data
616                         if let Ok(Value::Bool(is_hidden)) = engine.run(
617                             logic_id, 
618                             eval_data.data()
619                         ) {
620                             if is_hidden {
621                                 // Check if rb is not already in hidden_fields and has value
622                                 // rb is &String, hidden_fields is Vec<String>
623                                 if !hidden_fields.contains(rb) {
624                                     let has_value = rb_value != Value::Null && rb_value != "";
625                                     if has_value {
626                                          hidden_fields.push(rb.clone());
627                                     }
628                                 }
629                             }
630                         }
631                    }
632                }
633            }
634        }
635    }
636
637    /// Process the dependents queue.
638    /// Collects every data path written into `eval_data` into `invalidated_paths`.
639    pub(crate) fn process_dependents_queue(
640        engine: &RLogic,
641        evaluations: &IndexMap<String, LogicId>,
642        eval_data: &mut EvalData,
643        dependents_evaluations: &IndexMap<String, Vec<DependentItem>>,
644        evaluated_schema: &Value,
645        queue: &mut Vec<(String, bool)>,
646        processed: &mut IndexSet<String>,
647        result: &mut Vec<Value>,
648        token: Option<&CancellationToken>,
649        canceled_paths: Option<&mut Vec<String>>,
650    ) -> Result<(), String> {
651        while let Some((current_path, is_transitive)) = queue.pop() {
652            if let Some(t) = token {
653                if t.is_cancelled() {
654                    // Accumulate canceled paths if buffer provided
655                    if let Some(cp) = canceled_paths {
656                        cp.push(current_path.clone());
657                        // Also push remaining items in queue?
658                        // The user request says "accumulate canceled path if provided", usually implies what was actively cancelled 
659                        // or what was pending. Since we pop one by one, we can just dump the queue back or just push pending.
660                        // But since we just popped `current_path`, it is the one being cancelled on.
661                        // Let's also drain the queue.
662                        for (path, _) in queue.iter() {
663                             cp.push(path.clone());
664                        }
665                    }
666                    return Err("Cancelled".to_string());
667                }
668            }
669            if processed.contains(&current_path) {
670                continue;
671            }
672            processed.insert(current_path.clone());
673
674            // Get the value of the changed field for $value context
675            let current_data_path = path_utils::normalize_to_json_pointer(&current_path)
676                .replace("/properties/", "/")
677                .trim_start_matches('#')
678                .to_string();
679            let mut current_value = eval_data
680                .data()
681                .pointer(&current_data_path)
682                .cloned()
683                .unwrap_or(Value::Null);
684
685            // Find dependents for this path
686            if let Some(dependent_items) = dependents_evaluations.get(&current_path) {
687                for dep_item in dependent_items {
688                    let ref_path = &dep_item.ref_path;
689                    let pointer_path = path_utils::normalize_to_json_pointer(ref_path);
690                    // Data paths don't include /properties/, strip it for data access
691                    let data_path = pointer_path.replace("/properties/", "/");
692
693                    let current_ref_value = eval_data
694                        .data()
695                        .pointer(&data_path)
696                        .cloned()
697                        .unwrap_or(Value::Null);
698
699                    // Get field and parent field from schema
700                    let field = evaluated_schema.pointer(&pointer_path).cloned();
701
702                    // Get parent field - skip /properties/ to get actual parent object
703                    let parent_path = if let Some(last_slash) = pointer_path.rfind("/properties") {
704                        &pointer_path[..last_slash]
705                    } else {
706                        "/"
707                    };
708                    let mut parent_field = if parent_path.is_empty() || parent_path == "/" {
709                        evaluated_schema.clone()
710                    } else {
711                        evaluated_schema
712                            .pointer(parent_path)
713                            .cloned()
714                            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
715                    };
716
717                    // omit properties to minimize size of parent field
718                    if let Value::Object(ref mut map) = parent_field {
719                        map.remove("properties");
720                        map.remove("$layout");
721                    }
722
723                    let mut change_obj = serde_json::Map::new();
724                    change_obj.insert(
725                        "$ref".to_string(),
726                        Value::String(path_utils::pointer_to_dot_notation(&data_path)),
727                    );
728                    if let Some(f) = field {
729                        change_obj.insert("$field".to_string(), f);
730                    }
731                    change_obj.insert("$parentField".to_string(), parent_field);
732                    change_obj.insert("transitive".to_string(), Value::Bool(is_transitive));
733
734                    let mut add_transitive = false;
735                    let mut add_deps = false;
736                    // Process clear
737                    if let Some(clear_val) = &dep_item.clear {
738                        let should_clear = Self::evaluate_dependent_value_static(
739                            engine,
740                            evaluations,
741                            eval_data,
742                            clear_val,
743                            &current_value,
744                            &current_ref_value,
745                        )?;
746                        let clear_bool = match should_clear {
747                            Value::Bool(b) => b,
748                            _ => false,
749                        };
750
751                        if clear_bool {
752                            if data_path == current_data_path {
753                                current_value = Value::Null;
754                            }
755                            eval_data.set(&data_path, Value::Null);
756                            change_obj.insert("clear".to_string(), Value::Bool(true));
757                            add_transitive = true;
758                            add_deps = true;
759                        }
760                    }
761
762                    // Process value
763                    if let Some(value_val) = &dep_item.value {
764                        let computed_value = Self::evaluate_dependent_value_static(
765                            engine,
766                            evaluations,
767                            eval_data,
768                            value_val,
769                            &current_value,
770                            &current_ref_value,
771                        )?;
772                        let cleaned_val = clean_float_noise_scalar(computed_value);
773
774                        if cleaned_val != current_ref_value && cleaned_val != Value::Null {
775                            if data_path == current_data_path {
776                                current_value = cleaned_val.clone();
777                            }
778                            eval_data.set(&data_path, cleaned_val.clone());
779                            change_obj.insert("value".to_string(), cleaned_val);
780                            add_transitive = true;
781                            add_deps = true;
782                        }
783                    }
784
785                    // add only when has clear / value
786                    if add_deps {
787                        result.push(Value::Object(change_obj));
788                    }
789
790                    // Add this dependent to queue for transitive processing
791                    if add_transitive {
792                        queue.push((ref_path.clone(), true));
793                    }
794                }
795            }
796        }
797        Ok(())
798    }
799}
800
801/// Extract the field key from a subform path.
802///
803/// Examples:
804/// - `#/riders`                               → `riders`
805/// - `#/properties/form/properties/riders`    → `riders`
806/// - `#/items`                                → `items`
807fn subform_field_key(subform_path: &str) -> String {
808    // Strip leading `#/`
809    let stripped = subform_path.trim_start_matches('#').trim_start_matches('/');
810
811    // The last non-"properties" segment is the field key
812    stripped
813        .split('/')
814        .filter(|seg| !seg.is_empty() && *seg != "properties")
815        .last()
816        .unwrap_or(stripped)
817        .to_string()
818}