Skip to main content

json_eval_rs/jsoneval/
subform_methods.rs

1// Subform methods for isolated array field evaluation
2
3use crate::jsoneval::cancellation::CancellationToken;
4use crate::JSONEval;
5use crate::ReturnFormat;
6use serde_json::Value;
7
8/// Decomposes a subform path that may optionally include a trailing item index,
9/// and normalizes the base portion to the canonical schema-pointer key used in the
10/// subform registry (e.g. `"#/illustration/properties/product_benefit/properties/riders"`).
11///
12/// Accepted formats for the **base** portion:
13/// - Schema pointer:    `"#/illustration/properties/product_benefit/properties/riders"`
14/// - Raw JSON pointer:  `"/illustration/properties/product_benefit/properties/riders"`
15/// - Dot notation:      `"illustration.product_benefit.riders"`
16///
17/// Accepted formats for the **index** suffix (stripped before lookup):
18/// - Trailing dot-index:     `"…riders.1"`
19/// - Trailing slash-index:   `"…riders/1"`
20/// - Bracket array index:    `"…riders[1]"` or `"…riders[1]."`
21///
22/// Returns `(canonical_base_path, optional_index)`.
23fn resolve_subform_path(path: &str) -> (String, Option<usize>) {
24    // --- Step 1: strip a trailing bracket array index, e.g. "riders[2]" or "riders[2]."
25    let path = path.trim_end_matches('.');
26    let (path, bracket_idx) = if let Some(bracket_start) = path.rfind('[') {
27        let after = &path[bracket_start + 1..];
28        if let Some(bracket_end) = after.find(']') {
29            let idx_str = &after[..bracket_end];
30            if let Ok(idx) = idx_str.parse::<usize>() {
31                // strip everything from '[' onward (including any trailing '.')
32                let base = path[..bracket_start].trim_end_matches('.');
33                (base, Some(idx))
34            } else {
35                (path, None)
36            }
37        } else {
38            (path, None)
39        }
40    } else {
41        (path, None)
42    };
43
44    // --- Step 2: strip a trailing numeric segment (dot or slash separated)
45    let (base_raw, trailing_idx) = if bracket_idx.is_none() {
46        // Check dot-notation trailing index: "foo.bar.2"
47        if let Some(dot_pos) = path.rfind('.') {
48            let suffix = &path[dot_pos + 1..];
49            if let Ok(idx) = suffix.parse::<usize>() {
50                (&path[..dot_pos], Some(idx))
51            } else {
52                (path, None)
53            }
54        }
55        // Check JSON-pointer trailing index: "#/foo/bar/0" or "/foo/bar/0"
56        else if let Some(slash_pos) = path.rfind('/') {
57            let suffix = &path[slash_pos + 1..];
58            if let Ok(idx) = suffix.parse::<usize>() {
59                (&path[..slash_pos], Some(idx))
60            } else {
61                (path, None)
62            }
63        } else {
64            (path, None)
65        }
66    } else {
67        (path, None)
68    };
69
70    let final_idx = bracket_idx.or(trailing_idx);
71
72    // --- Step 3: normalize base_raw to a canonical schema pointer
73    let canonical = normalize_to_subform_key(base_raw);
74
75    (canonical, final_idx)
76}
77
78/// Normalize any path format to the canonical subform registry key.
79///
80/// The registry stores keys as `"#/field/properties/subfield/properties/…"` — exactly
81/// as produced by the schema `walk()` function. This function converts all supported
82/// formats into that form.
83fn normalize_to_subform_key(path: &str) -> String {
84    // Already a schema pointer — return as-is
85    if path.starts_with("#/") {
86        return path.to_string();
87    }
88
89    // Raw JSON pointer "/foo/properties/bar" → prefix with '#'
90    if path.starts_with('/') {
91        return format!("#{}", path);
92    }
93
94    // Dot-notation: "illustration.product_benefit.riders"
95    // → "#/illustration/properties/product_benefit/properties/riders"
96    crate::jsoneval::path_utils::dot_notation_to_schema_pointer(path)
97}
98
99impl JSONEval {
100    /// Resolves the subform path, allowing aliases like "riders" to match the full
101    /// schema pointer "#/illustration/properties/product_benefit/properties/riders".
102    /// This ensures alias paths and full paths share the same underlying subform store and cache.
103    pub(crate) fn resolve_subform_path_alias(&self, path: &str) -> (String, Option<usize>) {
104        let (mut canonical, idx) = resolve_subform_path(path);
105
106        if !self.subforms.contains_key(&canonical) {
107            let search_suffix = if canonical.starts_with("#/") {
108                format!("/properties/{}", &canonical[2..])
109            } else {
110                format!("/properties/{}", canonical)
111            };
112
113            for k in self.subforms.keys() {
114                if k.ends_with(&search_suffix) || k == &canonical {
115                    canonical = k.to_string();
116                    break;
117                }
118            }
119        }
120
121        (canonical, idx)
122    }
123
124    /// Execute `f` on the subform at `base_path[idx]` with the parent cache swapped in.
125    ///
126    /// Lifecycle:
127    /// 1. Set `data_value` + `context_value` on the subform's `eval_data`.
128    /// 2. Compute item-level diff for `field_key` → bump `subform_caches[idx].data_versions`.
129    /// 3. `mem::take` parent cache → set `active_item_index = Some(idx)` → swap into subform.
130    /// 4. Execute `f(subform)` → collect result.
131    /// 5. Swap parent cache back out → restore `self.eval_cache`.
132    ///
133    /// This ensures all three operations (evaluate / validate / evaluate_dependents)
134    /// share parent-form Tier-2 cache entries, without duplicating the swap boilerplate.
135    fn with_item_cache_swap<F, T>(
136        &mut self,
137        base_path: &str,
138        idx: usize,
139        data_value: Value,
140        context_value: Value,
141        f: F,
142    ) -> Result<T, String>
143    where
144        F: FnOnce(&mut JSONEval) -> Result<T, String>,
145    {
146        let field_key = base_path
147            .split('/')
148            .next_back()
149            .unwrap_or(base_path)
150            .to_string();
151
152        // Step 1: update subform data and extract item snapshot for targeted diff.
153        // Scoped block releases the mutable borrow on `self.subforms` before we touch
154        // `self.eval_cache` (they are disjoint fields, but keep it explicit).
155        let (old_item_snapshot, new_item_val, subform_item_cache_opt, array_path, item_path) = {
156            let subform = self
157                .subforms
158                .get_mut(base_path)
159                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
160
161            let old_item_snapshot = subform
162                .eval_cache
163                .subform_caches
164                .get(&idx)
165                .map(|c| c.item_snapshot.clone())
166                .unwrap_or(Value::Null);
167
168            subform
169                .eval_data
170                .replace_data_and_context(data_value, context_value);
171            let new_item_val = subform
172                .eval_data
173                .data()
174                .get(&field_key)
175                .cloned()
176                .unwrap_or(Value::Null);
177
178            // INJECT the item into the parent array location within subform's eval_data!
179            // The frontend sometimes only provides the active item root but leaves the
180            // corresponding slot empty or stale in the parent array tree of the wrapper.
181            // Formulas that aggregate over the parent array must see the active item.
182            let data_pointer = crate::jsoneval::path_utils::normalize_to_json_pointer(base_path)
183                .replace("/properties/", "/");
184            let array_path = data_pointer.to_string();
185            let item_path = format!("{}/{}", array_path, idx);
186            subform.eval_data.set(&item_path, new_item_val.clone());
187
188            // Pull out any existing item-scoped entries from the subform's own cache
189            // so they can be merged into the parent cache below.
190            let existing = subform.eval_cache.subform_caches.remove(&idx);
191            (
192                old_item_snapshot,
193                new_item_val,
194                existing,
195                array_path,
196                item_path,
197            )
198        }; // subform borrow released here
199
200        // Unified store fallback: if the subform's own per-item cache has no snapshot for this
201        // index (e.g. this is the first evaluate_subform call after a full evaluate()), treat the
202        // parent's eval_data slot as the canonical baseline. The parent always holds the most
203        // recent array data written by evaluate() or evaluate_dependents(), so using it avoids
204        // treating an already-evaluated item as brand-new and forcing full table re-evaluation.
205        let parent_item = self.eval_data.get(&item_path).cloned();
206        let old_item_snapshot = if old_item_snapshot == Value::Null {
207            parent_item.clone().unwrap_or(Value::Null)
208        } else {
209            old_item_snapshot
210        };
211
212        // An item is "new" only when the parent's eval_data has no entry at the item path.
213        // Using the subform's own snapshot cache as the authority (old_item_snapshot == Null)
214        // is not correct after Step 6 persistence re-seeds the cache: a rider that was
215        // previously evaluate_subform'd would have a snapshot but may still be absent from
216        // the parent array (e.g. new rider scenario after evaluate_dependents_subform).
217        let is_new_item = parent_item.is_none();
218
219        let mut parent_cache = std::mem::take(&mut self.eval_cache);
220        parent_cache.ensure_active_item_cache(idx);
221
222        // Snapshot item versions BEFORE the diff so we can detect only NEW bumps below.
223        // `any_bumped_with_prefix(v > 0)` would return true for historical bumps from prior
224        // calls, causing invalidate_params_tables_for_item to fire on every evaluate_subform
225        // even when no rider data actually changed.
226        let pre_diff_item_versions = parent_cache
227            .subform_caches
228            .get(&idx)
229            .map(|c| c.data_versions.clone());
230
231        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
232            // Only inherit $params-scoped versions from the parent so that data-path
233            // bumps from other items or previous calls don't contaminate this item's baseline.
234            c.data_versions
235                .merge_from_params(&parent_cache.params_versions);
236            // Diff only the item field to find what changed (skips the 5 MB parent tree).
237            crate::jsoneval::eval_cache::diff_and_update_versions(
238                &mut c.data_versions,
239                &format!("/{}", field_key),
240                &old_item_snapshot,
241                &new_item_val,
242            );
243            c.item_snapshot = new_item_val.clone();
244        }
245        parent_cache.active_item_index = Some(idx);
246
247        // Restore cached entries that lived in the subform's own per-item cache.
248        // Only restore entries whose dependency versions still match the current item
249        // data_versions: if a field changed (e.g. sa bumped), entries that depended on
250        // that field are stale and must not be re-inserted (they would cause false T1 hits).
251        if let Some(subform_item_cache) = subform_item_cache_opt {
252            if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
253                let current_dv = c.data_versions.clone();
254                for (k, v) in subform_item_cache.entries {
255                    // Skip if entry already exists (parent-form run may have added a fresher result).
256                    if c.entries.contains_key(&k) {
257                        continue;
258                    }
259                    // Validate all dep versions against the current item data_versions.
260                    let still_valid = v.dep_versions.iter().all(|(dep_path, &cached_ver)| {
261                        let current_ver = if dep_path.starts_with("/$params") {
262                            parent_cache.params_versions.get(dep_path)
263                        } else {
264                            current_dv.get(dep_path)
265                        };
266                        current_ver == cached_ver
267                    });
268                    if still_valid {
269                        c.entries.insert(k, v);
270                    }
271                }
272            }
273        }
274
275        // Insert into the parent eval_data as well (to make the item visible to global formulas on main evaluate).
276        // Only write (and bump version) when the value actually changed: prevents spurious riders-array
277        // version increments on repeated evaluate_subform calls where the rider data is unchanged.
278        let current_at_item_path = self.eval_data.get(&item_path).cloned();
279        if current_at_item_path.as_ref() != Some(&new_item_val) {
280            self.eval_data.set(&item_path, new_item_val.clone());
281            if is_new_item {
282                parent_cache.bump_data_version(&array_path);
283            }
284        }
285
286        // Re-evaluate `$params` tables that depend on subform item paths that changed.
287        // This is required not just for brand-new items, but also whenever a tracked field
288        // (like `riders.sa`) changes value: tables like RIDER_ZLOB_TABLE depend on rider.sa
289        // and must produce updated rows that reflect the new sa before the subform's own
290        // formula evaluation runs (otherwise cached old rows are reused).
291        //
292        // Gate: only re-evaluate tables when at least one item-level path was NEWLY bumped
293        // in this diff pass. Using any_bumped_with_prefix(v > 0) would return true for
294        // historical bumps from prior calls, causing spurious table invalidation every time.
295        let field_prefix = format!("/{}/", field_key);
296        let item_paths_bumped = match &pre_diff_item_versions {
297            None => {
298                // No pre-diff snapshot = cache slot was just created, treat as new
299                parent_cache
300                    .subform_caches
301                    .get(&idx)
302                    .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
303                    .unwrap_or(false)
304            }
305            Some(pre) => {
306                // Only count bumps that occurred during this specific diff pass
307                parent_cache
308                    .subform_caches
309                    .get(&idx)
310                    .map(|c| {
311                        c.data_versions
312                            .any_newly_bumped_with_prefix(&field_prefix, pre)
313                    })
314                    .unwrap_or(false)
315            }
316        };
317
318        if is_new_item || item_paths_bumped {
319            // Collect which rider data paths were NEWLY bumped in this diff pass.
320            // When item_paths_bumped = true, the diff detected changes — but we only want to
321            // invalidate tables that ACTUALLY depend on those changed paths. Tables like
322            // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
323            // first_prem), so bumping them forces unnecessary re-evaluation and increments
324            // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
325            let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
326                let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
327                    parent_cache.subform_caches.get(&idx).map(|c| {
328                        c.data_versions
329                            .versions()
330                            .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
331                            .map(|(k, _)| {
332                                // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
333                                // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
334                                let sub = k.trim_start_matches(&field_prefix);
335                                format!("#/{}/properties/{}", field_key, sub)
336                            })
337                            .collect::<Vec<_>>()
338                    })
339                });
340                paths
341            } else {
342                None
343            };
344
345            let params_table_keys: Vec<String> = self
346                .table_metadata
347                .keys()
348                .filter(|k| k.starts_with("#/$params"))
349                .filter(|k| {
350                    if is_new_item {
351                        return true; // new rider: invalidate all tables
352                    }
353                    // Only invalidate tables whose declared deps overlap the changed paths.
354                    // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
355                    // fall back to invalidating all.
356                    let Some(ref bumped) = newly_bumped_paths else {
357                        return true;
358                    };
359                    if bumped.is_empty() {
360                        return false;
361                    }
362                    self.dependencies
363                        .get(*k)
364                        .map(|deps| {
365                            deps.iter().any(|dep| {
366                                bumped
367                                    .iter()
368                                    .any(|b| dep == b || dep.starts_with(b.as_str()))
369                            })
370                        })
371                        .unwrap_or(false)
372                })
373                .cloned()
374                .collect();
375            if !params_table_keys.is_empty() {
376                parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
377
378                let eval_data_snapshot = self.eval_data.exclusive_clone();
379                for key in &params_table_keys {
380                    // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
381                    // depend on subform-specific item paths (like `#/riders/...`).
382                    // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
383                    // and MUST be evaluated by the subform engine to see the subform's current data.
384                    // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
385                    // and MUST be evaluated by the parent engine to see the full parent array.
386                    let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
387                        let subform_dep_prefix = format!("#/{}/properties/", field_key);
388                        let subform_dep_prefix_short = format!("#/{}/", field_key);
389                        deps.iter().any(|dep| {
390                            dep.starts_with(&subform_dep_prefix)
391                                || dep.starts_with(&subform_dep_prefix_short)
392                        })
393                    } else {
394                        false
395                    };
396
397                    if depends_on_subform_item {
398                        continue;
399                    }
400
401                    // Evaluate the table using parent's updated data
402                    if let Ok(rows) = crate::jsoneval::table_evaluate::evaluate_table(
403                        self,
404                        key,
405                        &eval_data_snapshot,
406                        None,
407                    ) {
408                        if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
409                            println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
410                        }
411                        let result_val = serde_json::Value::Array(rows);
412
413                        // Collect external dependencies for this cache entry
414                        let mut external_deps = indexmap::IndexSet::new();
415                        let pointer_data_prefix =
416                            crate::jsoneval::path_utils::normalize_to_json_pointer(key)
417                                .replace("/properties/", "/");
418                        let pointer_data_prefix_slash = format!("{}/", pointer_data_prefix);
419                        if let Some(deps) = self.dependencies.get(key) {
420                            for dep in deps {
421                                let dep_data_path =
422                                    crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
423                                        .replace("/properties/", "/");
424                                if dep_data_path != pointer_data_prefix
425                                    && !dep_data_path.starts_with(&pointer_data_prefix_slash)
426                                {
427                                    external_deps.insert(dep.clone());
428                                }
429                            }
430                        }
431
432                        // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
433                        // Then the subform can hit it via T2 fallback check.
434                        parent_cache.active_item_index = None;
435                        parent_cache.store_cache(key, &external_deps, result_val);
436                        parent_cache.active_item_index = Some(idx);
437                    } else {
438                        if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
439                            println!("PARENT EVALUATED TABLE {} -> ERROR", key);
440                        }
441                    }
442                }
443            }
444        }
445
446        // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
447        {
448            let subform = self.subforms.get_mut(base_path).unwrap();
449            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
450        }
451
452        // Step 4: run the caller-supplied operation.
453        let result = {
454            let subform = self.subforms.get_mut(base_path).unwrap();
455            f(subform)
456        };
457
458        // Step 5: restore parent cache.
459        {
460            let subform = self.subforms.get_mut(base_path).unwrap();
461            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
462        }
463        parent_cache.active_item_index = None;
464        self.eval_cache = parent_cache;
465
466        // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
467        // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
468        // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
469        // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
470        {
471            let subform = self.subforms.get_mut(base_path).unwrap();
472            if let Some(item_cache) = self.eval_cache.subform_caches.get(&idx) {
473                subform
474                    .eval_cache
475                    .subform_caches
476                    .insert(idx, item_cache.clone());
477            }
478        }
479
480        result
481    }
482
483    /// Evaluate a subform identified by `subform_path`.
484    ///
485    /// The path may include a trailing item index to bind the evaluation to a specific
486    /// array element and enable the two-tier cache-swap strategy automatically:
487    ///
488    /// ```text
489    /// // Evaluate riders item 1 with index-aware cache
490    /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
491    /// ```
492    ///
493    /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
494    pub fn evaluate_subform(
495        &mut self,
496        subform_path: &str,
497        data: &str,
498        context: Option<&str>,
499        paths: Option<&[String]>,
500        token: Option<&CancellationToken>,
501    ) -> Result<(), String> {
502        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
503        if let Some(idx) = idx_opt {
504            self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
505        } else {
506            let subform = self
507                .subforms
508                .get_mut(base_path.as_ref() as &str)
509                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
510            subform.evaluate(data, context, paths, token)
511        }
512    }
513
514    /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
515    fn evaluate_subform_item(
516        &mut self,
517        base_path: &str,
518        idx: usize,
519        data: &str,
520        context: Option<&str>,
521        paths: Option<&[String]>,
522        token: Option<&CancellationToken>,
523    ) -> Result<(), String> {
524        let data_value = crate::jsoneval::json_parser::parse_json_str(data)
525            .map_err(|e| format!("Failed to parse subform data: {}", e))?;
526        let context_value = if let Some(ctx) = context {
527            crate::jsoneval::json_parser::parse_json_str(ctx)
528                .map_err(|e| format!("Failed to parse subform context: {}", e))?
529        } else {
530            Value::Object(serde_json::Map::new())
531        };
532
533        self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
534            sf.evaluate_internal_pre_diffed(paths, token)
535        })
536    }
537
538    /// Validate subform data against its schema rules.
539    ///
540    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
541    /// is present the parent cache is swapped in first, ensuring rule evaluations that
542    /// depend on `$params` tables share already-computed parent-form results.
543    pub fn validate_subform(
544        &mut self,
545        subform_path: &str,
546        data: &str,
547        context: Option<&str>,
548        paths: Option<&[String]>,
549        token: Option<&CancellationToken>,
550    ) -> Result<crate::ValidationResult, String> {
551        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
552        if let Some(idx) = idx_opt {
553            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
554                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
555            let context_value = if let Some(ctx) = context {
556                crate::jsoneval::json_parser::parse_json_str(ctx)
557                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
558            } else {
559                Value::Object(serde_json::Map::new())
560            };
561            let data_for_validation = data_value.clone();
562            self.with_item_cache_swap(
563                base_path.as_ref(),
564                idx,
565                data_value,
566                context_value,
567                move |sf| {
568                    // Warm the evaluation cache before running rule checks.
569                    sf.evaluate_internal_pre_diffed(paths, token)?;
570                    sf.validate_pre_set(data_for_validation, paths, token)
571                },
572            )
573        } else {
574            let subform = self
575                .subforms
576                .get_mut(base_path.as_ref() as &str)
577                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
578            subform.validate(data, context, paths, token)
579        }
580    }
581
582    /// Evaluate dependents in a subform when a field changes.
583    ///
584    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
585    /// is present the parent cache is swapped in, so dependent evaluation runs with
586    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
587    pub fn evaluate_dependents_subform(
588        &mut self,
589        subform_path: &str,
590        changed_paths: &[String],
591        data: Option<&str>,
592        context: Option<&str>,
593        re_evaluate: bool,
594        token: Option<&CancellationToken>,
595        canceled_paths: Option<&mut Vec<String>>,
596        include_subforms: bool,
597    ) -> Result<Value, String> {
598        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
599        if let Some(idx) = idx_opt {
600            // Parse or snapshot data for the swap / diff computation.
601            let (data_value, context_value) = if let Some(data_str) = data {
602                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
603                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
604                let cv = if let Some(ctx) = context {
605                    crate::jsoneval::json_parser::parse_json_str(ctx)
606                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
607                } else {
608                    Value::Object(serde_json::Map::new())
609                };
610                (dv, cv)
611            } else {
612                // No new data provided — snapshot current subform state so diff is a no-op.
613                let subform = self
614                    .subforms
615                    .get(base_path.as_ref() as &str)
616                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
617                let dv = subform.eval_data.snapshot_data_clone();
618                (dv, Value::Object(serde_json::Map::new()))
619            };
620            self.with_item_cache_swap(base_path.as_ref(), idx, data_value, context_value, |sf| {
621                // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
622                sf.evaluate_dependents(
623                    changed_paths,
624                    None,
625                    None,
626                    re_evaluate,
627                    token,
628                    None,
629                    include_subforms,
630                )
631            })
632        } else {
633            let subform = self
634                .subforms
635                .get_mut(base_path.as_ref() as &str)
636                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
637            subform.evaluate_dependents(
638                changed_paths,
639                data,
640                context,
641                re_evaluate,
642                token,
643                canceled_paths,
644                include_subforms,
645            )
646        }
647    }
648
649    /// Resolve layout for subform.
650    pub fn resolve_layout_subform(
651        &mut self,
652        subform_path: &str,
653        evaluate: bool,
654    ) -> Result<(), String> {
655        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
656        let subform = self
657            .subforms
658            .get_mut(base_path.as_ref() as &str)
659            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
660        let _ = subform.resolve_layout(evaluate);
661        Ok(())
662    }
663
664    /// Get evaluated schema from subform.
665    pub fn get_evaluated_schema_subform(
666        &mut self,
667        subform_path: &str,
668        resolve_layout: bool,
669    ) -> Value {
670        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
671        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
672            subform.get_evaluated_schema(resolve_layout)
673        } else {
674            Value::Null
675        }
676    }
677
678    /// Get schema value from subform in nested object format (all .value fields).
679    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
680        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
681        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
682            subform.get_schema_value()
683        } else {
684            Value::Null
685        }
686    }
687
688    /// Get schema values from subform as a flat array of path-value pairs.
689    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
690        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
691        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
692            subform.get_schema_value_array()
693        } else {
694            Value::Array(vec![])
695        }
696    }
697
698    /// Get schema values from subform as a flat object with dotted path keys.
699    pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
700        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
701        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
702            subform.get_schema_value_object()
703        } else {
704            Value::Object(serde_json::Map::new())
705        }
706    }
707
708    /// Get evaluated schema without $params from subform.
709    pub fn get_evaluated_schema_without_params_subform(
710        &mut self,
711        subform_path: &str,
712        resolve_layout: bool,
713    ) -> Value {
714        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
715        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
716            subform.get_evaluated_schema_without_params(resolve_layout)
717        } else {
718            Value::Null
719        }
720    }
721
722    /// Get evaluated schema by specific path from subform.
723    pub fn get_evaluated_schema_by_path_subform(
724        &mut self,
725        subform_path: &str,
726        schema_path: &str,
727        skip_layout: bool,
728    ) -> Option<Value> {
729        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
730        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
731            sf.get_evaluated_schema_by_paths(
732                &[schema_path.to_string()],
733                skip_layout,
734                Some(ReturnFormat::Nested),
735            )
736        })
737    }
738
739    /// Get evaluated schema by multiple paths from subform.
740    pub fn get_evaluated_schema_by_paths_subform(
741        &mut self,
742        subform_path: &str,
743        schema_paths: &[String],
744        skip_layout: bool,
745        format: Option<crate::ReturnFormat>,
746    ) -> Value {
747        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
748        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
749            subform.get_evaluated_schema_by_paths(
750                schema_paths,
751                skip_layout,
752                Some(format.unwrap_or(ReturnFormat::Flat)),
753            )
754        } else {
755            match format.unwrap_or_default() {
756                crate::ReturnFormat::Array => Value::Array(vec![]),
757                _ => Value::Object(serde_json::Map::new()),
758            }
759        }
760    }
761
762    /// Get schema by specific path from subform.
763    pub fn get_schema_by_path_subform(
764        &self,
765        subform_path: &str,
766        schema_path: &str,
767    ) -> Option<Value> {
768        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
769        self.subforms
770            .get(base_path.as_ref() as &str)
771            .and_then(|sf| sf.get_schema_by_path(schema_path))
772    }
773
774    /// Get schema by multiple paths from subform.
775    pub fn get_schema_by_paths_subform(
776        &self,
777        subform_path: &str,
778        schema_paths: &[String],
779        format: Option<crate::ReturnFormat>,
780    ) -> Value {
781        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
782        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
783            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
784        } else {
785            match format.unwrap_or_default() {
786                crate::ReturnFormat::Array => Value::Array(vec![]),
787                _ => Value::Object(serde_json::Map::new()),
788            }
789        }
790    }
791
792    /// Get list of available subform paths.
793    pub fn get_subform_paths(&self) -> Vec<String> {
794        self.subforms.keys().cloned().collect()
795    }
796
797    /// Check if a subform exists at the given path.
798    pub fn has_subform(&self, subform_path: &str) -> bool {
799        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
800        self.subforms.contains_key(base_path.as_ref() as &str)
801    }
802}