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        validate_readonly: Option<bool>,
582    ) -> Result<crate::ValidationResult, String> {
583        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
584        if let Some(idx) = idx_opt {
585            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
586                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
587            let context_value = if let Some(ctx) = context {
588                crate::jsoneval::json_parser::parse_json_str(ctx)
589                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
590            } else {
591                Value::Object(serde_json::Map::new())
592            };
593            let data_for_validation = data_value.clone();
594            self.with_item_cache_swap(
595                base_path.as_ref(),
596                idx,
597                data_value,
598                context_value,
599                move |sf| {
600                    // Warm the evaluation cache before running rule checks.
601                    sf.evaluate_internal_pre_diffed(paths, token)?;
602                    sf.validate_pre_set(data_for_validation, paths, token, validate_readonly)
603                },
604            )
605        } else {
606            let subform = self
607                .subforms
608                .get_mut(base_path.as_ref() as &str)
609                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
610            subform.validate(data, context, paths, token, validate_readonly)
611        }
612    }
613
614    /// Evaluate dependents in a subform when a field changes.
615    ///
616    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
617    /// is present the parent cache is swapped in, so dependent evaluation runs with
618    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
619    pub fn evaluate_dependents_subform(
620        &mut self,
621        subform_path: &str,
622        changed_paths: &[String],
623        data: Option<&str>,
624        context: Option<&str>,
625        re_evaluate: bool,
626        token: Option<&CancellationToken>,
627        canceled_paths: Option<&mut Vec<String>>,
628        include_subforms: bool,
629    ) -> Result<Value, String> {
630        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
631        if let Some(idx) = idx_opt {
632            // Parse or snapshot data for the swap / diff computation.
633            let (data_value, context_value) = if let Some(data_str) = data {
634                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
635                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
636                let cv = if let Some(ctx) = context {
637                    crate::jsoneval::json_parser::parse_json_str(ctx)
638                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
639                } else {
640                    Value::Object(serde_json::Map::new())
641                };
642                (dv, cv)
643            } else {
644                // No new data provided — snapshot current subform state so diff is a no-op.
645                let subform = self
646                    .subforms
647                    .get(base_path.as_ref() as &str)
648                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
649                let dv = subform.eval_data.snapshot_data_clone();
650                (dv, Value::Object(serde_json::Map::new()))
651            };
652            let changes = self.with_item_cache_swap(
653                base_path.as_ref(),
654                idx,
655                data_value,
656                context_value,
657                |sf| {
658                    // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
659                    sf.evaluate_dependents(
660                        changed_paths,
661                        None,
662                        None,
663                        re_evaluate,
664                        token,
665                        None,
666                        include_subforms,
667                    )
668                },
669            )?;
670            // Public indexed-subform patches retain subform-local refs so callers can apply
671            // them directly to their `{ riders: item }` editing payload. Internal dependent
672            // evaluation stays canonical; only active-item result refs are projected here.
673            let subform_dot_path =
674                crate::jsoneval::path_utils::pointer_to_dot_notation(base_path.as_ref())
675                    .replace(".properties.", ".");
676            let canonical_prefix = format!("{subform_dot_path}.{idx}");
677            let root_key = base_path.rsplit('/').next().unwrap_or(base_path.as_ref());
678            let changes = match changes {
679                Value::Array(changes) => Value::Array(
680                    changes
681                        .into_iter()
682                        .map(|change| {
683                            let Some(change_map) = change.as_object() else {
684                                return change;
685                            };
686                            let Some(Value::String(reference)) = change_map.get("$ref") else {
687                                return change;
688                            };
689                            let Some(suffix) = reference.strip_prefix(&canonical_prefix) else {
690                                return change;
691                            };
692                            if !suffix.is_empty() && !suffix.starts_with('.') {
693                                return change;
694                            }
695                            let mut mapped = change_map.clone();
696                            mapped.insert(
697                                "$ref".to_string(),
698                                Value::String(format!("{root_key}{suffix}")),
699                            );
700                            Value::Object(mapped)
701                        })
702                        .collect(),
703                ),
704                value => value,
705            };
706            Ok(changes)
707        } else {
708            let subform = self
709                .subforms
710                .get_mut(base_path.as_ref() as &str)
711                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
712            subform.evaluate_dependents(
713                changed_paths,
714                data,
715                context,
716                re_evaluate,
717                token,
718                canceled_paths,
719                include_subforms,
720            )
721        }
722    }
723
724    /// Resolve layout for subform, returning overlay entries.
725    pub fn resolve_layout_subform(
726        &mut self,
727        subform_path: &str,
728        evaluate: bool,
729    ) -> Result<ResolvedLayoutResult, String> {
730        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
731        let subform = self
732            .subforms
733            .get_mut(base_path.as_ref() as &str)
734            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
735        subform.resolve_layout(evaluate)
736    }
737
738    /// Get evaluated schema from subform.
739    pub fn get_evaluated_schema_subform(&mut self, subform_path: &str) -> Value {
740        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
741
742        if let Some(idx) = idx_opt {
743            if let Some(schema) = self
744                .eval_cache
745                .subform_caches
746                .get(&idx)
747                .and_then(|c| c.evaluated_schema.clone())
748            {
749                return schema;
750            }
751            if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
752                subform.get_evaluated_schema()
753            } else {
754                Value::Null
755            }
756        } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
757            subform.get_evaluated_schema()
758        } else {
759            Value::Null
760        }
761    }
762
763    /// Get schema value from subform in nested object format (all .value fields).
764    ///
765    /// Indexed evaluation stores an absolute parent-array wrapper in the subform's
766    /// eval data for cross-item formulas. This endpoint exposes only fields declared
767    /// by its isolated subform schema, never that implementation wrapper.
768    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
769        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
770        let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) else {
771            return Value::Null;
772        };
773
774        let values = subform.get_schema_value();
775        let Some(values) = values.as_object() else {
776            return values;
777        };
778
779        let schema_root_keys: Vec<&str> = subform
780            .schema
781            .as_object()
782            .into_iter()
783            .flat_map(|schema| schema.keys())
784            .filter(|key| !key.starts_with('$'))
785            .map(String::as_str)
786            .collect();
787
788        Value::Object(
789            schema_root_keys
790                .into_iter()
791                .filter_map(|key| {
792                    values
793                        .get(key)
794                        .cloned()
795                        .map(|value| (key.to_string(), value))
796                })
797                .collect(),
798        )
799    }
800
801    /// Get schema values from subform as a flat array of path-value pairs.
802    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
803        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
804        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
805            subform.get_schema_value_array()
806        } else {
807            Value::Array(vec![])
808        }
809    }
810
811    /// Get schema values from subform as a flat object with dotted path keys.
812    pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
813        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
814        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
815            subform.get_schema_value_object()
816        } else {
817            Value::Object(serde_json::Map::new())
818        }
819    }
820
821    /// Get evaluated schema without $params from subform.
822    pub fn get_evaluated_schema_without_params_subform(&mut self, subform_path: &str) -> Value {
823        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
824        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
825            subform.get_evaluated_schema_without_params()
826        } else {
827            Value::Null
828        }
829    }
830
831    /// Get evaluated schema by specific path from subform.
832    pub fn get_evaluated_schema_by_path_subform(
833        &mut self,
834        subform_path: &str,
835        schema_path: &str,
836    ) -> Option<Value> {
837        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
838        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
839            sf.get_evaluated_schema_by_paths(&[schema_path.to_string()], Some(ReturnFormat::Nested))
840        })
841    }
842
843    /// Get evaluated schema by multiple paths from subform.
844    pub fn get_evaluated_schema_by_paths_subform(
845        &mut self,
846        subform_path: &str,
847        schema_paths: &[String],
848        format: Option<crate::ReturnFormat>,
849    ) -> Value {
850        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
851        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
852            subform.get_evaluated_schema_by_paths(
853                schema_paths,
854                Some(format.unwrap_or(ReturnFormat::Flat)),
855            )
856        } else {
857            match format.unwrap_or_default() {
858                crate::ReturnFormat::Array => Value::Array(vec![]),
859                _ => Value::Object(serde_json::Map::new()),
860            }
861        }
862    }
863
864    /// Get schema by specific path from subform.
865    pub fn get_schema_by_path_subform(
866        &self,
867        subform_path: &str,
868        schema_path: &str,
869    ) -> Option<Value> {
870        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
871        self.subforms
872            .get(base_path.as_ref() as &str)
873            .and_then(|sf| sf.get_schema_by_path(schema_path))
874    }
875
876    /// Get schema by multiple paths from subform.
877    pub fn get_schema_by_paths_subform(
878        &self,
879        subform_path: &str,
880        schema_paths: &[String],
881        format: Option<crate::ReturnFormat>,
882    ) -> Value {
883        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
884        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
885            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
886        } else {
887            match format.unwrap_or_default() {
888                crate::ReturnFormat::Array => Value::Array(vec![]),
889                _ => Value::Object(serde_json::Map::new()),
890            }
891        }
892    }
893
894    /// Get resolved layout overlay entries for subform.
895    pub fn get_resolved_layout_subform(&mut self, subform_path: &str) -> ResolvedLayoutResult {
896        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
897        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
898            subform.get_resolved_layout()
899        } else {
900            ResolvedLayoutResult::default()
901        }
902    }
903
904    /// Get evaluated schema with layout fully resolved for subform.
905    pub fn get_evaluated_schema_resolved_subform(&mut self, subform_path: &str) -> Value {
906        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
907        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
908            subform.get_evaluated_schema_resolved()
909        } else {
910            Value::Null
911        }
912    }
913
914    /// Get list of available subform paths.
915    pub fn get_subform_paths(&self) -> Vec<String> {
916        self.subforms.keys().cloned().collect()
917    }
918
919    /// Check if a subform exists at the given path.
920    pub fn has_subform(&self, subform_path: &str) -> bool {
921        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
922        self.subforms.contains_key(base_path.as_ref() as &str)
923    }
924}