Skip to main content

json_eval_rs/jsoneval/
subform_methods.rs

1// Subform methods for isolated array field evaluation
2
3use super::JSONEval;
4use crate::jsoneval::cancellation::CancellationToken;
5use crate::jsoneval::eval_data::EvalData;
6use crate::jsoneval::types::{ResolvedLayoutResult, ReturnFormat};
7use serde_json::Value;
8
9/// Decomposes a subform path that may optionally include a trailing item index,
10/// and normalizes the base portion to the canonical schema-pointer key used in the
11/// subform registry (e.g. `"#/illustration/properties/product_benefit/properties/riders"`).
12///
13/// Accepted formats for the **base** portion:
14/// - Schema pointer:    `"#/illustration/properties/product_benefit/properties/riders"`
15/// - Raw JSON pointer:  `"/illustration/properties/product_benefit/properties/riders"`
16/// - Dot notation:      `"illustration.product_benefit.riders"`
17///
18/// Accepted formats for the **index** suffix (stripped before lookup):
19/// - Trailing dot-index:     `"…riders.1"`
20/// - Trailing slash-index:   `"…riders/1"`
21/// - Bracket array index:    `"…riders[1]"` or `"…riders[1]."`
22///
23/// Returns `(canonical_base_path, optional_index)`.
24fn resolve_subform_path(path: &str) -> (String, Option<usize>) {
25    // --- Step 1: strip a trailing bracket array index, e.g. "riders[2]" or "riders[2]."
26    let path = path.trim_end_matches('.');
27    let (path, bracket_idx) = if let Some(bracket_start) = path.rfind('[') {
28        let after = &path[bracket_start + 1..];
29        if let Some(bracket_end) = after.find(']') {
30            let idx_str = &after[..bracket_end];
31            if let Ok(idx) = idx_str.parse::<usize>() {
32                // strip everything from '[' onward (including any trailing '.')
33                let base = path[..bracket_start].trim_end_matches('.');
34                (base, Some(idx))
35            } else {
36                (path, None)
37            }
38        } else {
39            (path, None)
40        }
41    } else {
42        (path, None)
43    };
44
45    // --- Step 2: strip a trailing numeric segment (dot or slash separated)
46    let (base_raw, trailing_idx) = if bracket_idx.is_none() {
47        // Check dot-notation trailing index: "foo.bar.2"
48        if let Some(dot_pos) = path.rfind('.') {
49            let suffix = &path[dot_pos + 1..];
50            if let Ok(idx) = suffix.parse::<usize>() {
51                (&path[..dot_pos], Some(idx))
52            } else {
53                (path, None)
54            }
55        }
56        // Check JSON-pointer trailing index: "#/foo/bar/0" or "/foo/bar/0"
57        else if let Some(slash_pos) = path.rfind('/') {
58            let suffix = &path[slash_pos + 1..];
59            if let Ok(idx) = suffix.parse::<usize>() {
60                (&path[..slash_pos], Some(idx))
61            } else {
62                (path, None)
63            }
64        } else {
65            (path, None)
66        }
67    } else {
68        (path, None)
69    };
70
71    let final_idx = bracket_idx.or(trailing_idx);
72
73    // --- Step 3: normalize base_raw to a canonical schema pointer
74    let canonical = normalize_to_subform_key(base_raw);
75
76    (canonical, final_idx)
77}
78
79/// Normalize any path format to the canonical subform registry key.
80///
81/// The registry stores keys as `"#/field/properties/subfield/properties/…"` — exactly
82/// as produced by the schema `walk()` function. This function converts all supported
83/// formats into that form.
84fn normalize_to_subform_key(path: &str) -> String {
85    // Already a schema pointer — return as-is
86    if path.starts_with("#/") {
87        return path.to_string();
88    }
89
90    // Raw JSON pointer "/foo/properties/bar" → prefix with '#'
91    if path.starts_with('/') {
92        return format!("#{}", path);
93    }
94
95    // Dot-notation: "illustration.product_benefit.riders"
96    // → "#/illustration/properties/product_benefit/properties/riders"
97    crate::jsoneval::path_utils::dot_notation_to_schema_pointer(path)
98}
99
100impl JSONEval {
101    /// Resolves the subform path, allowing aliases like "riders" to match the full
102    /// schema pointer "#/illustration/properties/product_benefit/properties/riders".
103    /// This ensures alias paths and full paths share the same underlying subform store and cache.
104    pub(crate) fn resolve_subform_path_alias(&self, path: &str) -> (String, Option<usize>) {
105        let (mut canonical, idx) = resolve_subform_path(path);
106
107        if !self.subforms.contains_key(&canonical) {
108            let search_suffix = if canonical.starts_with("#/") {
109                format!("/properties/{}", &canonical[2..])
110            } else {
111                format!("/properties/{}", canonical)
112            };
113
114            for k in self.subforms.keys() {
115                if k.ends_with(&search_suffix) || k == &canonical {
116                    canonical = k.to_string();
117                    break;
118                }
119            }
120        }
121
122        (canonical, idx)
123    }
124
125    /// Execute `f` on the subform at `base_path[idx]` with the parent cache swapped in.
126    ///
127    /// Lifecycle:
128    /// 1. Set `data_value` + `context_value` on the subform's `eval_data`.
129    /// 2. Compute item-level diff for `field_key` → bump `subform_caches[idx].data_versions`.
130    /// 3. `mem::take` parent cache → set `active_item_index = Some(idx)` → swap into subform.
131    /// 4. Execute `f(subform)` → collect result.
132    /// 5. Swap parent cache back out → restore `self.eval_cache`.
133    ///
134    /// This ensures all three operations (evaluate / validate / evaluate_dependents)
135    /// share parent-form Tier-2 cache entries, without duplicating the swap boilerplate.
136    fn with_item_cache_swap<F, T>(
137        &mut self,
138        base_path: &str,
139        idx: usize,
140        data_value: Value,
141        context_value: Value,
142        f: F,
143    ) -> Result<T, String>
144    where
145        F: FnOnce(&mut JSONEval) -> Result<T, String>,
146    {
147        let original_field_key = base_path
148            .split('/')
149            .next_back()
150            .unwrap_or(base_path)
151            .to_string();
152
153        let schema_pointer = if base_path.starts_with("#/") {
154            &base_path[1..]
155        } else if base_path.starts_with('#') {
156            &base_path[1..]
157        } else {
158            base_path
159        };
160
161        let root_key =
162            crate::jsoneval::path_utils::get_value_by_pointer(&self.schema, schema_pointer)
163                .and_then(|node| node.get("itemsRootKey"))
164                .and_then(|v| v.as_str())
165                .unwrap_or(&original_field_key)
166                .to_string();
167
168        let array_path =
169            crate::jsoneval::path_utils::schema_path_to_data_pointer(base_path).into_owned();
170        let item_path = format!("{}/{}", array_path, idx);
171        let full_parent_payload = data_value.pointer(&array_path).is_some();
172        let payload_has_parent_context = data_value
173            .as_object()
174            .map(|map| map.keys().any(|key| key != &root_key))
175            .unwrap_or(false);
176
177        // Normalize both public payload shapes into one active item before scope setup.
178        let normalized_item = if full_parent_payload {
179            data_value
180                .pointer(&item_path)
181                .cloned()
182                .or_else(|| data_value.get(&root_key).cloned())
183        } else {
184            data_value.get(&root_key).cloned()
185        }
186        .ok_or_else(|| {
187            format!(
188                "Invalid indexed subform payload for {base_path}[{idx}]: expected active item at {item_path} or wrapper root {root_key}"
189            )
190        })?;
191
192        // Prepare item data and cache state.
193        let (old_item_snapshot, new_item_val, subform_item_cache_opt) = {
194            let subform = self
195                .subforms
196                .get_mut(base_path)
197                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
198
199            let old_item_snapshot = subform
200                .eval_cache
201                .subform_caches
202                .get(&idx)
203                .map(|c| c.item_snapshot.clone())
204                .unwrap_or(Value::Null);
205
206            // Merge parent payload when available.
207            let mut scoped_data = EvalData::new(self.eval_data.snapshot_data_clone());
208            if full_parent_payload || payload_has_parent_context {
209                scoped_data.replace_data_and_context(data_value.clone(), context_value.clone());
210            }
211            scoped_data.set(&item_path, normalized_item.clone());
212            let canonical_parent = scoped_data.snapshot_data_clone();
213            let scope = crate::jsoneval::subform_scope::SubformScope::new(
214                base_path,
215                &array_path,
216                Some(idx),
217            );
218            let mut scoped_view = scope.evaluation_view(&canonical_parent);
219            if let Some(view) = scoped_view.as_object_mut() {
220                view.insert("$context".to_string(), context_value.clone());
221            }
222            subform.eval_data = EvalData::new(scoped_view);
223            let new_item_val = normalized_item.clone();
224
225            // Move item cache into parent cache.
226            let existing = subform.eval_cache.subform_caches.remove(&idx);
227            (old_item_snapshot, new_item_val, existing)
228        }; // subform borrow released here
229
230        // Fall back to parent item snapshot.
231        let parent_item = self.eval_data.get(&item_path).cloned();
232        let old_item_snapshot = if old_item_snapshot == Value::Null {
233            parent_item.clone().unwrap_or(Value::Null)
234        } else {
235            old_item_snapshot
236        };
237
238        // Parent data determines whether item is new.
239        let is_new_item = parent_item.is_none();
240
241        let mut parent_cache = std::mem::take(&mut self.eval_cache);
242        if full_parent_payload {
243            let old_parent_data = self.eval_data.snapshot_data_clone();
244            self.eval_data
245                .replace_data_and_context(data_value.clone(), context_value.clone());
246            let new_parent_data = self.eval_data.snapshot_data_clone();
247            crate::jsoneval::eval_cache::diff_and_update_versions(
248                &mut parent_cache.data_versions,
249                "",
250                &old_parent_data,
251                &new_parent_data,
252                "sync_full_subform_payload",
253            );
254        }
255        parent_cache.ensure_active_item_cache(idx);
256
257        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
258            // Merge parent versions without item-local paths.
259            c.data_versions
260                .merge_excluding_prefix(&parent_cache.data_versions, &format!("/{root_key}/"));
261            c.data_versions
262                .merge_from_params(&parent_cache.params_versions);
263
264            // Merge item version history before diffing.
265            if let Some(subform_item_cache) = &subform_item_cache_opt {
266                c.data_versions
267                    .merge_from(&subform_item_cache.data_versions);
268            }
269        }
270
271        // Keep baseline to detect new version bumps.
272        let pre_diff_item_versions = parent_cache
273            .subform_caches
274            .get(&idx)
275            .map(|c| c.data_versions.clone());
276
277        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
278            // Diff only the item field to find what changed (skips the 5 MB parent tree).
279            crate::jsoneval::eval_cache::diff_and_update_versions(
280                &mut c.data_versions,
281                &format!("/{}", root_key),
282                &old_item_snapshot,
283                &new_item_val,
284                "with_item_cache_swap_diff_and_update_versions",
285            );
286            c.item_snapshot = new_item_val.clone();
287        }
288
289        // Propagate new item changes to parent T2 versions.
290        {
291            let item_field_prefix = format!("/{}/", root_key);
292            if let (Some(ref pre), Some(c)) = (
293                &pre_diff_item_versions,
294                parent_cache.subform_caches.get(&idx),
295            ) {
296                let newly_bumped: Vec<String> = c
297                    .data_versions
298                    .versions()
299                    .filter(|(k, &v)| k.starts_with(&item_field_prefix) && v > pre.get(k))
300                    .map(|(k, _)| k.to_string())
301                    .collect();
302                if !newly_bumped.is_empty() {
303                    for k in newly_bumped {
304                        parent_cache
305                            .data_versions
306                            .bump(&k, "propagate_newly_bumped");
307                    }
308                    parent_cache.eval_generation += 1;
309                }
310            }
311        }
312
313        parent_cache.active_item_index = Some(idx);
314
315        // Restore valid item cache entries.
316        if let Some(subform_item_cache) = subform_item_cache_opt {
317            if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
318                // Merge historical item versions before validation.
319                let current_dv = c.data_versions.clone();
320                for (k, v) in subform_item_cache.entries {
321                    // Skip if entry already exists (parent-form run may have added a fresher result).
322                    if c.entries.contains_key(&k) {
323                        continue;
324                    }
325                    // Validate all dep versions against the current item data_versions.
326                    let still_valid = v.dep_versions.iter().all(|(dep_path, &cached_ver)| {
327                        let current_ver = if dep_path.starts_with("/$params") {
328                            parent_cache.params_versions.get(dep_path)
329                        } else {
330                            current_dv.get(dep_path)
331                        };
332                        current_ver == cached_ver
333                    });
334                    if still_valid {
335                        c.entries.insert(k, v);
336                    }
337                }
338            }
339        }
340
341        // Sync changed item into parent data.
342        let current_at_item_path = self.eval_data.get(&item_path).cloned();
343        if current_at_item_path.as_ref() != Some(&new_item_val) {
344            self.eval_data.set(&item_path, new_item_val.clone());
345            if is_new_item {
346                parent_cache.bump_data_version(&array_path);
347            }
348        }
349
350        // Refresh affected $params tables after new item changes.
351        let field_prefix = format!("/{}/", root_key);
352        let item_paths_bumped = match &pre_diff_item_versions {
353            None => {
354                // No pre-diff snapshot = cache slot was just created, treat as new
355                parent_cache
356                    .subform_caches
357                    .get(&idx)
358                    .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
359                    .unwrap_or(false)
360            }
361            Some(pre) => {
362                // Only count bumps that occurred during this specific diff pass
363                parent_cache
364                    .subform_caches
365                    .get(&idx)
366                    .map(|c| {
367                        c.data_versions
368                            .any_newly_bumped_with_prefix(&field_prefix, pre)
369                    })
370                    .unwrap_or(false)
371            }
372        };
373
374        if is_new_item || item_paths_bumped {
375            // Collect newly changed item paths.
376            let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
377                let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
378                    parent_cache.subform_caches.get(&idx).map(|c| {
379                        c.data_versions
380                            .versions()
381                            .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
382                            .map(|(k, _)| {
383                                // Convert data path to schema path.
384                                let sub = k.trim_start_matches(&field_prefix);
385                                format!("#/{}/properties/{}", root_key, sub)
386                            })
387                            .collect::<Vec<_>>()
388                    })
389                });
390                paths
391            } else {
392                None
393            };
394
395            let params_table_keys: Vec<String> = self
396                .table_metadata
397                .keys()
398                .filter(|k| k.starts_with("#/$params"))
399                .filter(|k| {
400                    if is_new_item {
401                        return true; // new rider: invalidate all tables
402                    }
403                    // Invalidate tables with changed dependencies.
404                    let Some(ref bumped) = newly_bumped_paths else {
405                        return true;
406                    };
407                    if bumped.is_empty() {
408                        return false;
409                    }
410                    self.dependencies
411                        .get(*k)
412                        .map(|deps| {
413                            deps.iter().any(|dep| {
414                                bumped
415                                    .iter()
416                                    .any(|b| dep == b || dep.starts_with(b.as_str()))
417                            })
418                        })
419                        .unwrap_or(false)
420                })
421                .cloned()
422                .collect();
423            if !params_table_keys.is_empty() {
424                parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
425
426                let eval_data_snapshot = self.eval_data.snapshot_data();
427                for key in &params_table_keys {
428                    // Item-dependent tables run in subform scope.
429                    let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
430                        let subform_dep_prefix = format!("#/{}/properties/", root_key);
431                        let subform_dep_prefix_short = format!("#/{}/", root_key);
432                        deps.iter().any(|dep| {
433                            dep.starts_with(&subform_dep_prefix)
434                                || dep.starts_with(&subform_dep_prefix_short)
435                        })
436                    } else {
437                        false
438                    };
439
440                    if depends_on_subform_item {
441                        continue;
442                    }
443
444                    // Evaluate the table using parent's updated data
445                    if let Ok((arc_val, external_deps_opt)) =
446                        crate::jsoneval::table_evaluate::evaluate_table(
447                            self,
448                            key,
449                            &EvalData::from_arc(std::sync::Arc::clone(&eval_data_snapshot)),
450                            None,
451                        )
452                    {
453                        if crate::utils::is_debug_cache_enabled() {
454                            let rows_len = arc_val.as_array().map(|a| a.len()).unwrap_or(0);
455                            println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows_len);
456                        }
457
458                        if let Some(external_deps) = external_deps_opt {
459                            // Store parent result in T2.
460                            parent_cache.active_item_index = None;
461                            parent_cache.store_cache_arc(key, &external_deps, arc_val);
462                            parent_cache.active_item_index = Some(idx);
463                        }
464                    } else {
465                        if crate::utils::is_debug_cache_enabled() {
466                            println!("PARENT EVALUATED TABLE {} -> ERROR", key);
467                        }
468                    }
469                }
470            }
471        }
472
473        // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
474        {
475            let subform = self.subforms.get_mut(base_path).unwrap();
476            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
477        }
478
479        // Step 4: run the caller-supplied operation.
480        let result = {
481            let subform = self.subforms.get_mut(base_path).unwrap();
482            f(subform)
483        };
484
485        // Step 5: restore parent cache.
486        {
487            let subform = self.subforms.get_mut(base_path).unwrap();
488            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
489        }
490        parent_cache.active_item_index = None;
491        self.eval_cache = parent_cache;
492
493        // Persist item cache and evaluated schema.
494        {
495            let subform = self.subforms.get_mut(base_path).unwrap();
496            if let Some(item_cache) = self.eval_cache.subform_caches.get_mut(&idx) {
497                item_cache.evaluated_schema = Some(subform.evaluated_schema.clone());
498                subform
499                    .eval_cache
500                    .subform_caches
501                    .insert(idx, item_cache.clone());
502            }
503        }
504
505        result
506    }
507
508    /// Evaluate a subform identified by `subform_path`.
509    ///
510    /// The path may include a trailing item index to bind the evaluation to a specific
511    /// array element and enable the two-tier cache-swap strategy automatically:
512    ///
513    /// ```text
514    /// // Evaluate riders item 1 with index-aware cache
515    /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
516    /// ```
517    ///
518    /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
519    pub fn evaluate_subform(
520        &mut self,
521        subform_path: &str,
522        data: &str,
523        context: Option<&str>,
524        paths: Option<&[String]>,
525        token: Option<&CancellationToken>,
526    ) -> Result<(), String> {
527        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
528        if let Some(idx) = idx_opt {
529            self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
530        } else {
531            let subform = self
532                .subforms
533                .get_mut(base_path.as_ref() as &str)
534                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
535            subform.evaluate(data, context, paths, token)
536        }
537    }
538
539    /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
540    fn evaluate_subform_item(
541        &mut self,
542        base_path: &str,
543        idx: usize,
544        data: &str,
545        context: Option<&str>,
546        paths: Option<&[String]>,
547        token: Option<&CancellationToken>,
548    ) -> Result<(), String> {
549        let data_value = crate::jsoneval::json_parser::parse_json_str(data)
550            .map_err(|e| format!("Failed to parse subform data: {}", e))?;
551        let context_value = if let Some(ctx) = context {
552            crate::jsoneval::json_parser::parse_json_str(ctx)
553                .map_err(|e| format!("Failed to parse subform context: {}", e))?
554        } else {
555            Value::Object(serde_json::Map::new())
556        };
557
558        self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
559            // Match main-form lifecycle: resolve visibility, hydrate missing visible static
560            // defaults and their dependents, then re-evaluate only when data was written.
561            sf.evaluate_internal_pre_diffed(paths, token)?;
562            if sf.apply_visible_static_defaults_with_dependents(token)? {
563                sf.evaluate_internal_pre_diffed(paths, token)?;
564            }
565            Ok(())
566        })
567    }
568
569    /// Validate subform data against its schema rules.
570    ///
571    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
572    /// is present the parent cache is swapped in first, ensuring rule evaluations that
573    /// depend on `$params` tables share already-computed parent-form results.
574    pub fn validate_subform(
575        &mut self,
576        subform_path: &str,
577        data: &str,
578        context: Option<&str>,
579        paths: Option<&[String]>,
580        token: Option<&CancellationToken>,
581    ) -> Result<crate::ValidationResult, String> {
582        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
583        if let Some(idx) = idx_opt {
584            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
585                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
586            let context_value = if let Some(ctx) = context {
587                crate::jsoneval::json_parser::parse_json_str(ctx)
588                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
589            } else {
590                Value::Object(serde_json::Map::new())
591            };
592            let data_for_validation = data_value.clone();
593            self.with_item_cache_swap(
594                base_path.as_ref(),
595                idx,
596                data_value,
597                context_value,
598                move |sf| {
599                    // Warm the evaluation cache before running rule checks.
600                    sf.evaluate_internal_pre_diffed(paths, token)?;
601                    sf.validate_pre_set(data_for_validation, paths, token)
602                },
603            )
604        } else {
605            let subform = self
606                .subforms
607                .get_mut(base_path.as_ref() as &str)
608                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
609            subform.validate(data, context, paths, token)
610        }
611    }
612
613    /// Evaluate dependents in a subform when a field changes.
614    ///
615    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
616    /// is present the parent cache is swapped in, so dependent evaluation runs with
617    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
618    pub fn evaluate_dependents_subform(
619        &mut self,
620        subform_path: &str,
621        changed_paths: &[String],
622        data: Option<&str>,
623        context: Option<&str>,
624        re_evaluate: bool,
625        token: Option<&CancellationToken>,
626        canceled_paths: Option<&mut Vec<String>>,
627        include_subforms: bool,
628    ) -> Result<Value, String> {
629        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
630        if let Some(idx) = idx_opt {
631            // Parse or snapshot data for the swap / diff computation.
632            let (data_value, context_value) = if let Some(data_str) = data {
633                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
634                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
635                let cv = if let Some(ctx) = context {
636                    crate::jsoneval::json_parser::parse_json_str(ctx)
637                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
638                } else {
639                    Value::Object(serde_json::Map::new())
640                };
641                (dv, cv)
642            } else {
643                // No new data provided — snapshot current subform state so diff is a no-op.
644                let subform = self
645                    .subforms
646                    .get(base_path.as_ref() as &str)
647                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
648                let dv = subform.eval_data.snapshot_data_clone();
649                (dv, Value::Object(serde_json::Map::new()))
650            };
651            let changes = self.with_item_cache_swap(
652                base_path.as_ref(),
653                idx,
654                data_value,
655                context_value,
656                |sf| {
657                    // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
658                    sf.evaluate_dependents(
659                        changed_paths,
660                        None,
661                        None,
662                        re_evaluate,
663                        token,
664                        None,
665                        include_subforms,
666                    )
667                },
668            )?;
669            // Public indexed-subform patches retain subform-local refs so callers can apply
670            // them directly to their `{ riders: item }` editing payload. Internal dependent
671            // evaluation stays canonical; only active-item result refs are projected here.
672            let subform_dot_path =
673                crate::jsoneval::path_utils::pointer_to_dot_notation(base_path.as_ref())
674                    .replace(".properties.", ".");
675            let canonical_prefix = format!("{subform_dot_path}.{idx}");
676            let root_key = base_path.rsplit('/').next().unwrap_or(base_path.as_ref());
677            let changes = match changes {
678                Value::Array(changes) => Value::Array(
679                    changes
680                        .into_iter()
681                        .map(|change| {
682                            let Some(change_map) = change.as_object() else {
683                                return change;
684                            };
685                            let Some(Value::String(reference)) = change_map.get("$ref") else {
686                                return change;
687                            };
688                            let Some(suffix) = reference.strip_prefix(&canonical_prefix) else {
689                                return change;
690                            };
691                            if !suffix.is_empty() && !suffix.starts_with('.') {
692                                return change;
693                            }
694                            let mut mapped = change_map.clone();
695                            mapped.insert(
696                                "$ref".to_string(),
697                                Value::String(format!("{root_key}{suffix}")),
698                            );
699                            Value::Object(mapped)
700                        })
701                        .collect(),
702                ),
703                value => value,
704            };
705            Ok(changes)
706        } else {
707            let subform = self
708                .subforms
709                .get_mut(base_path.as_ref() as &str)
710                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
711            subform.evaluate_dependents(
712                changed_paths,
713                data,
714                context,
715                re_evaluate,
716                token,
717                canceled_paths,
718                include_subforms,
719            )
720        }
721    }
722
723    /// Resolve layout for subform, returning overlay entries.
724    pub fn resolve_layout_subform(
725        &mut self,
726        subform_path: &str,
727        evaluate: bool,
728    ) -> Result<ResolvedLayoutResult, String> {
729        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
730        let subform = self
731            .subforms
732            .get_mut(base_path.as_ref() as &str)
733            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
734        subform.resolve_layout(evaluate)
735    }
736
737    /// Get evaluated schema from subform.
738    pub fn get_evaluated_schema_subform(&mut self, subform_path: &str) -> Value {
739        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
740
741        if let Some(idx) = idx_opt {
742            if let Some(schema) = self
743                .eval_cache
744                .subform_caches
745                .get(&idx)
746                .and_then(|c| c.evaluated_schema.clone())
747            {
748                return schema;
749            }
750            if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
751                subform.get_evaluated_schema()
752            } else {
753                Value::Null
754            }
755        } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
756            subform.get_evaluated_schema()
757        } else {
758            Value::Null
759        }
760    }
761
762    /// Get schema value from subform in nested object format (all .value fields).
763    ///
764    /// Indexed evaluation stores an absolute parent-array wrapper in the subform's
765    /// eval data for cross-item formulas. This endpoint exposes only fields declared
766    /// by its isolated subform schema, never that implementation wrapper.
767    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
768        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
769        let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) else {
770            return Value::Null;
771        };
772
773        let values = subform.get_schema_value();
774        let Some(values) = values.as_object() else {
775            return values;
776        };
777
778        let schema_root_keys: Vec<&str> = subform
779            .schema
780            .as_object()
781            .into_iter()
782            .flat_map(|schema| schema.keys())
783            .filter(|key| !key.starts_with('$'))
784            .map(String::as_str)
785            .collect();
786
787        Value::Object(
788            schema_root_keys
789                .into_iter()
790                .filter_map(|key| {
791                    values
792                        .get(key)
793                        .cloned()
794                        .map(|value| (key.to_string(), value))
795                })
796                .collect(),
797        )
798    }
799
800    /// Get schema values from subform as a flat array of path-value pairs.
801    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
802        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
803        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
804            subform.get_schema_value_array()
805        } else {
806            Value::Array(vec![])
807        }
808    }
809
810    /// Get schema values from subform as a flat object with dotted path keys.
811    pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
812        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
813        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
814            subform.get_schema_value_object()
815        } else {
816            Value::Object(serde_json::Map::new())
817        }
818    }
819
820    /// Get evaluated schema without $params from subform.
821    pub fn get_evaluated_schema_without_params_subform(&mut self, subform_path: &str) -> Value {
822        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
823        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
824            subform.get_evaluated_schema_without_params()
825        } else {
826            Value::Null
827        }
828    }
829
830    /// Get evaluated schema by specific path from subform.
831    pub fn get_evaluated_schema_by_path_subform(
832        &mut self,
833        subform_path: &str,
834        schema_path: &str,
835    ) -> Option<Value> {
836        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
837        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
838            sf.get_evaluated_schema_by_paths(&[schema_path.to_string()], Some(ReturnFormat::Nested))
839        })
840    }
841
842    /// Get evaluated schema by multiple paths from subform.
843    pub fn get_evaluated_schema_by_paths_subform(
844        &mut self,
845        subform_path: &str,
846        schema_paths: &[String],
847        format: Option<crate::ReturnFormat>,
848    ) -> Value {
849        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
850        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
851            subform.get_evaluated_schema_by_paths(
852                schema_paths,
853                Some(format.unwrap_or(ReturnFormat::Flat)),
854            )
855        } else {
856            match format.unwrap_or_default() {
857                crate::ReturnFormat::Array => Value::Array(vec![]),
858                _ => Value::Object(serde_json::Map::new()),
859            }
860        }
861    }
862
863    /// Get schema by specific path from subform.
864    pub fn get_schema_by_path_subform(
865        &self,
866        subform_path: &str,
867        schema_path: &str,
868    ) -> Option<Value> {
869        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
870        self.subforms
871            .get(base_path.as_ref() as &str)
872            .and_then(|sf| sf.get_schema_by_path(schema_path))
873    }
874
875    /// Get schema by multiple paths from subform.
876    pub fn get_schema_by_paths_subform(
877        &self,
878        subform_path: &str,
879        schema_paths: &[String],
880        format: Option<crate::ReturnFormat>,
881    ) -> Value {
882        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
883        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
884            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
885        } else {
886            match format.unwrap_or_default() {
887                crate::ReturnFormat::Array => Value::Array(vec![]),
888                _ => Value::Object(serde_json::Map::new()),
889            }
890        }
891    }
892
893    /// Get resolved layout overlay entries for subform.
894    pub fn get_resolved_layout_subform(&mut self, subform_path: &str) -> ResolvedLayoutResult {
895        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
896        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
897            subform.get_resolved_layout()
898        } else {
899            ResolvedLayoutResult::default()
900        }
901    }
902
903    /// Get evaluated schema with layout fully resolved for subform.
904    pub fn get_evaluated_schema_resolved_subform(&mut self, subform_path: &str) -> Value {
905        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
906        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
907            subform.get_evaluated_schema_resolved()
908        } else {
909            Value::Null
910        }
911    }
912
913    /// Get list of available subform paths.
914    pub fn get_subform_paths(&self) -> Vec<String> {
915        self.subforms.keys().cloned().collect()
916    }
917
918    /// Check if a subform exists at the given path.
919    pub fn has_subform(&self, subform_path: &str) -> bool {
920        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
921        self.subforms.contains_key(base_path.as_ref() as &str)
922    }
923}