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| c.data_versions.any_newly_bumped_with_prefix(&field_prefix, pre))
311                    .unwrap_or(false)
312            }
313        };
314
315        if is_new_item || item_paths_bumped {
316            // Collect which rider data paths were NEWLY bumped in this diff pass.
317            // When item_paths_bumped = true, the diff detected changes — but we only want to
318            // invalidate tables that ACTUALLY depend on those changed paths. Tables like
319            // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
320            // first_prem), so bumping them forces unnecessary re-evaluation and increments
321            // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
322            let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
323                let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
324                    parent_cache.subform_caches.get(&idx).map(|c| {
325                        c.data_versions
326                            .versions()
327                            .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
328                            .map(|(k, _)| {
329                                // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
330                                // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
331                                let sub = k.trim_start_matches(&field_prefix);
332                                format!("#/{}/properties/{}", field_key, sub)
333                            })
334                            .collect::<Vec<_>>()
335                    })
336                });
337                paths
338            } else {
339                None
340            };
341
342            let params_table_keys: Vec<String> = self
343                .table_metadata
344                .keys()
345                .filter(|k| k.starts_with("#/$params"))
346                .filter(|k| {
347                    if is_new_item {
348                        return true; // new rider: invalidate all tables
349                    }
350                    // Only invalidate tables whose declared deps overlap the changed paths.
351                    // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
352                    // fall back to invalidating all.
353                    let Some(ref bumped) = newly_bumped_paths else {
354                        return true;
355                    };
356                    if bumped.is_empty() {
357                        return false;
358                    }
359                    self.dependencies.get(*k).map(|deps| {
360                        deps.iter().any(|dep| bumped.iter().any(|b| dep == b || dep.starts_with(b.as_str())))
361                    }).unwrap_or(false)
362                })
363                .cloned()
364                .collect();
365            if !params_table_keys.is_empty() {
366                parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
367
368                let eval_data_snapshot = self.eval_data.exclusive_clone();
369                for key in &params_table_keys {
370                    // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
371                    // depend on subform-specific item paths (like `#/riders/...`).
372                    // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
373                    // and MUST be evaluated by the subform engine to see the subform's current data.
374                    // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
375                    // and MUST be evaluated by the parent engine to see the full parent array.
376                    let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
377                        let subform_dep_prefix = format!("#/{}/properties/", field_key);
378                        let subform_dep_prefix_short = format!("#/{}/", field_key);
379                        deps.iter().any(|dep| {
380                            dep.starts_with(&subform_dep_prefix)
381                                || dep.starts_with(&subform_dep_prefix_short)
382                        })
383                    } else {
384                        false
385                    };
386
387                    if depends_on_subform_item {
388                        continue;
389                    }
390
391                    // Evaluate the table using parent's updated data
392                    if let Ok(rows) = crate::jsoneval::table_evaluate::evaluate_table(
393                        self,
394                        key,
395                        &eval_data_snapshot,
396                        None,
397                    ) {
398                        if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
399                            println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
400                        }
401                        let result_val = serde_json::Value::Array(rows);
402
403                        // Collect external dependencies for this cache entry
404                        let mut external_deps = indexmap::IndexSet::new();
405                        let pointer_data_prefix =
406                            crate::jsoneval::path_utils::normalize_to_json_pointer(key)
407                                .replace("/properties/", "/");
408                        let pointer_data_prefix_slash = format!("{}/", pointer_data_prefix);
409                        if let Some(deps) = self.dependencies.get(key) {
410                            for dep in deps {
411                                let dep_data_path =
412                                    crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
413                                        .replace("/properties/", "/");
414                                if dep_data_path != pointer_data_prefix
415                                    && !dep_data_path.starts_with(&pointer_data_prefix_slash)
416                                {
417                                    external_deps.insert(dep.clone());
418                                }
419                            }
420                        }
421
422                        // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
423                        // Then the subform can hit it via T2 fallback check.
424                        parent_cache.active_item_index = None;
425                        parent_cache.store_cache(key, &external_deps, result_val);
426                        parent_cache.active_item_index = Some(idx);
427                    } else {
428                        if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
429                            println!("PARENT EVALUATED TABLE {} -> ERROR", key);
430                        }
431                    }
432                }
433            }
434        }
435
436        // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
437        {
438            let subform = self.subforms.get_mut(base_path).unwrap();
439            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
440        }
441
442        // Step 4: run the caller-supplied operation.
443        let result = {
444            let subform = self.subforms.get_mut(base_path).unwrap();
445            f(subform)
446        };
447
448        // Step 5: restore parent cache.
449        {
450            let subform = self.subforms.get_mut(base_path).unwrap();
451            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
452        }
453        parent_cache.active_item_index = None;
454        self.eval_cache = parent_cache;
455
456        // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
457        // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
458        // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
459        // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
460        {
461            let subform = self.subforms.get_mut(base_path).unwrap();
462            if let Some(item_cache) = self.eval_cache.subform_caches.get(&idx) {
463                subform
464                    .eval_cache
465                    .subform_caches
466                    .insert(idx, item_cache.clone());
467            }
468        }
469
470        result
471    }
472
473    /// Evaluate a subform identified by `subform_path`.
474    ///
475    /// The path may include a trailing item index to bind the evaluation to a specific
476    /// array element and enable the two-tier cache-swap strategy automatically:
477    ///
478    /// ```text
479    /// // Evaluate riders item 1 with index-aware cache
480    /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
481    /// ```
482    ///
483    /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
484    pub fn evaluate_subform(
485        &mut self,
486        subform_path: &str,
487        data: &str,
488        context: Option<&str>,
489        paths: Option<&[String]>,
490        token: Option<&CancellationToken>,
491    ) -> Result<(), String> {
492        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
493        if let Some(idx) = idx_opt {
494            self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
495        } else {
496            let subform = self
497                .subforms
498                .get_mut(base_path.as_ref() as &str)
499                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
500            subform.evaluate(data, context, paths, token)
501        }
502    }
503
504    /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
505    fn evaluate_subform_item(
506        &mut self,
507        base_path: &str,
508        idx: usize,
509        data: &str,
510        context: Option<&str>,
511        paths: Option<&[String]>,
512        token: Option<&CancellationToken>,
513    ) -> Result<(), String> {
514        let data_value = crate::jsoneval::json_parser::parse_json_str(data)
515            .map_err(|e| format!("Failed to parse subform data: {}", e))?;
516        let context_value = if let Some(ctx) = context {
517            crate::jsoneval::json_parser::parse_json_str(ctx)
518                .map_err(|e| format!("Failed to parse subform context: {}", e))?
519        } else {
520            Value::Object(serde_json::Map::new())
521        };
522
523        self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
524            sf.evaluate_internal_pre_diffed(paths, token)
525        })
526    }
527
528    /// Validate subform data against its schema rules.
529    ///
530    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
531    /// is present the parent cache is swapped in first, ensuring rule evaluations that
532    /// depend on `$params` tables share already-computed parent-form results.
533    pub fn validate_subform(
534        &mut self,
535        subform_path: &str,
536        data: &str,
537        context: Option<&str>,
538        paths: Option<&[String]>,
539        token: Option<&CancellationToken>,
540    ) -> Result<crate::ValidationResult, String> {
541        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
542        if let Some(idx) = idx_opt {
543            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
544                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
545            let context_value = if let Some(ctx) = context {
546                crate::jsoneval::json_parser::parse_json_str(ctx)
547                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
548            } else {
549                Value::Object(serde_json::Map::new())
550            };
551            let data_for_validation = data_value.clone();
552            self.with_item_cache_swap(
553                base_path.as_ref(),
554                idx,
555                data_value,
556                context_value,
557                move |sf| {
558                    // Warm the evaluation cache before running rule checks.
559                    sf.evaluate_internal_pre_diffed(paths, token)?;
560                    sf.validate_pre_set(data_for_validation, paths, token)
561                },
562            )
563        } else {
564            let subform = self
565                .subforms
566                .get_mut(base_path.as_ref() as &str)
567                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
568            subform.validate(data, context, paths, token)
569        }
570    }
571
572    /// Evaluate dependents in a subform when a field changes.
573    ///
574    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
575    /// is present the parent cache is swapped in, so dependent evaluation runs with
576    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
577    pub fn evaluate_dependents_subform(
578        &mut self,
579        subform_path: &str,
580        changed_paths: &[String],
581        data: Option<&str>,
582        context: Option<&str>,
583        re_evaluate: bool,
584        token: Option<&CancellationToken>,
585        canceled_paths: Option<&mut Vec<String>>,
586        include_subforms: bool,
587    ) -> Result<Value, String> {
588        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
589        if let Some(idx) = idx_opt {
590            // Parse or snapshot data for the swap / diff computation.
591            let (data_value, context_value) = if let Some(data_str) = data {
592                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
593                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
594                let cv = if let Some(ctx) = context {
595                    crate::jsoneval::json_parser::parse_json_str(ctx)
596                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
597                } else {
598                    Value::Object(serde_json::Map::new())
599                };
600                (dv, cv)
601            } else {
602                // No new data provided — snapshot current subform state so diff is a no-op.
603                let subform = self
604                    .subforms
605                    .get(base_path.as_ref() as &str)
606                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
607                let dv = subform.eval_data.snapshot_data_clone();
608                (dv, Value::Object(serde_json::Map::new()))
609            };
610            self.with_item_cache_swap(base_path.as_ref(), idx, data_value, context_value, |sf| {
611                // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
612                sf.evaluate_dependents(
613                    changed_paths,
614                    None,
615                    None,
616                    re_evaluate,
617                    token,
618                    None,
619                    include_subforms,
620                )
621            })
622        } else {
623            let subform = self
624                .subforms
625                .get_mut(base_path.as_ref() as &str)
626                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
627            subform.evaluate_dependents(
628                changed_paths,
629                data,
630                context,
631                re_evaluate,
632                token,
633                canceled_paths,
634                include_subforms,
635            )
636        }
637    }
638
639    /// Resolve layout for subform.
640    pub fn resolve_layout_subform(
641        &mut self,
642        subform_path: &str,
643        evaluate: bool,
644    ) -> Result<(), String> {
645        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
646        let subform = self
647            .subforms
648            .get_mut(base_path.as_ref() as &str)
649            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
650        let _ = subform.resolve_layout(evaluate);
651        Ok(())
652    }
653
654    /// Get evaluated schema from subform.
655    pub fn get_evaluated_schema_subform(
656        &mut self,
657        subform_path: &str,
658        resolve_layout: bool,
659    ) -> Value {
660        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
661        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
662            subform.get_evaluated_schema(resolve_layout)
663        } else {
664            Value::Null
665        }
666    }
667
668    /// Get schema value from subform in nested object format (all .value fields).
669    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> 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_schema_value()
673        } else {
674            Value::Null
675        }
676    }
677
678    /// Get schema values from subform as a flat array of path-value pairs.
679    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
680        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
681        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
682            subform.get_schema_value_array()
683        } else {
684            Value::Array(vec![])
685        }
686    }
687
688    /// Get schema values from subform as a flat object with dotted path keys.
689    pub fn get_schema_value_object_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_object()
693        } else {
694            Value::Object(serde_json::Map::new())
695        }
696    }
697
698    /// Get evaluated schema without $params from subform.
699    pub fn get_evaluated_schema_without_params_subform(
700        &mut self,
701        subform_path: &str,
702        resolve_layout: bool,
703    ) -> Value {
704        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
705        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
706            subform.get_evaluated_schema_without_params(resolve_layout)
707        } else {
708            Value::Null
709        }
710    }
711
712    /// Get evaluated schema by specific path from subform.
713    pub fn get_evaluated_schema_by_path_subform(
714        &mut self,
715        subform_path: &str,
716        schema_path: &str,
717        skip_layout: bool,
718    ) -> Option<Value> {
719        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
720        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
721            sf.get_evaluated_schema_by_paths(
722                &[schema_path.to_string()],
723                skip_layout,
724                Some(ReturnFormat::Nested),
725            )
726        })
727    }
728
729    /// Get evaluated schema by multiple paths from subform.
730    pub fn get_evaluated_schema_by_paths_subform(
731        &mut self,
732        subform_path: &str,
733        schema_paths: &[String],
734        skip_layout: bool,
735        format: Option<crate::ReturnFormat>,
736    ) -> Value {
737        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
738        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
739            subform.get_evaluated_schema_by_paths(
740                schema_paths,
741                skip_layout,
742                Some(format.unwrap_or(ReturnFormat::Flat)),
743            )
744        } else {
745            match format.unwrap_or_default() {
746                crate::ReturnFormat::Array => Value::Array(vec![]),
747                _ => Value::Object(serde_json::Map::new()),
748            }
749        }
750    }
751
752    /// Get schema by specific path from subform.
753    pub fn get_schema_by_path_subform(
754        &self,
755        subform_path: &str,
756        schema_path: &str,
757    ) -> Option<Value> {
758        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
759        self.subforms
760            .get(base_path.as_ref() as &str)
761            .and_then(|sf| sf.get_schema_by_path(schema_path))
762    }
763
764    /// Get schema by multiple paths from subform.
765    pub fn get_schema_by_paths_subform(
766        &self,
767        subform_path: &str,
768        schema_paths: &[String],
769        format: Option<crate::ReturnFormat>,
770    ) -> Value {
771        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
772        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
773            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
774        } else {
775            match format.unwrap_or_default() {
776                crate::ReturnFormat::Array => Value::Array(vec![]),
777                _ => Value::Object(serde_json::Map::new()),
778            }
779        }
780    }
781
782    /// Get list of available subform paths.
783    pub fn get_subform_paths(&self) -> Vec<String> {
784        self.subforms.keys().cloned().collect()
785    }
786
787    /// Check if a subform exists at the given path.
788    pub fn has_subform(&self, subform_path: &str) -> bool {
789        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
790        self.subforms.contains_key(base_path.as_ref() as &str)
791    }
792}