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        // Step 1: update subform data and extract item snapshot for targeted diff.
169        // Scoped block releases the mutable borrow on `self.subforms` before we touch
170        // `self.eval_cache` (they are disjoint fields, but keep it explicit).
171        let (old_item_snapshot, new_item_val, subform_item_cache_opt, array_path, item_path) = {
172            let subform = self
173                .subforms
174                .get_mut(base_path)
175                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
176
177            let old_item_snapshot = subform
178                .eval_cache
179                .subform_caches
180                .get(&idx)
181                .map(|c| c.item_snapshot.clone())
182                .unwrap_or(Value::Null);
183
184            subform
185                .eval_data
186                .replace_data_and_context(data_value, context_value);
187            let mut new_item_val = subform.eval_data.data().get(&root_key).cloned();
188
189            // Fallback 1: Absolute data path extraction (e.g. for full form payload)
190            if new_item_val.is_none() {
191                let array_path =
192                    crate::jsoneval::path_utils::schema_path_to_data_pointer(base_path)
193                        .into_owned();
194                let item_path = format!("{}/{}", array_path, idx);
195                new_item_val = subform.eval_data.get(&item_path).cloned();
196            }
197
198            // Fallback 2: Single key wrapper heuristic
199            if new_item_val.is_none() {
200                if let Value::Object(map) = subform.eval_data.data() {
201                    if map.len() == 1 {
202                        new_item_val = Some(map.values().next().unwrap().clone());
203                    }
204                }
205            }
206
207            let new_item_val = new_item_val.unwrap_or(Value::Null);
208
209            // INJECT the item into the parent array location within subform's eval_data!
210            // The frontend sometimes only provides the active item root but leaves the
211            // corresponding slot empty or stale in the parent array tree of the wrapper.
212            // Formulas that aggregate over the parent array must see the active item.
213            let array_path =
214                crate::jsoneval::path_utils::schema_path_to_data_pointer(base_path).into_owned();
215            let item_path = format!("{}/{}", array_path, idx);
216            subform.eval_data.set(&item_path, new_item_val.clone());
217
218            // Pull out any existing item-scoped entries from the subform's own cache
219            // so they can be merged into the parent cache below.
220            let existing = subform.eval_cache.subform_caches.remove(&idx);
221            (
222                old_item_snapshot,
223                new_item_val,
224                existing,
225                array_path,
226                item_path,
227            )
228        }; // subform borrow released here
229
230        // Unified store fallback: if the subform's own per-item cache has no snapshot for this
231        // index (e.g. this is the first evaluate_subform call after a full evaluate()), treat the
232        // parent's eval_data slot as the canonical baseline. The parent always holds the most
233        // recent array data written by evaluate() or evaluate_dependents(), so using it avoids
234        // treating an already-evaluated item as brand-new and forcing full table re-evaluation.
235        let parent_item = self.eval_data.get(&item_path).cloned();
236        let old_item_snapshot = if old_item_snapshot == Value::Null {
237            parent_item.clone().unwrap_or(Value::Null)
238        } else {
239            old_item_snapshot
240        };
241
242        // An item is "new" only when the parent's eval_data has no entry at the item path.
243        // Using the subform's own snapshot cache as the authority (old_item_snapshot == Null)
244        // is not correct after Step 6 persistence re-seeds the cache: a rider that was
245        // previously evaluate_subform'd would have a snapshot but may still be absent from
246        // the parent array (e.g. new rider scenario after evaluate_dependents_subform).
247        let is_new_item = parent_item.is_none();
248
249        let mut parent_cache = std::mem::take(&mut self.eval_cache);
250        parent_cache.ensure_active_item_cache(idx);
251
252        // Snapshot item versions BEFORE the diff so we can detect only NEW bumps below.
253        // `any_bumped_with_prefix(v > 0)` would return true for historical bumps from prior
254        // calls, causing invalidate_params_tables_for_item to fire on every evaluate_subform
255        // even when no rider data actually changed.
256        let pre_diff_item_versions = parent_cache
257            .subform_caches
258            .get(&idx)
259            .map(|c| c.data_versions.clone());
260
261        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
262            // Only inherit $params-scoped versions from the parent so that data-path
263            // bumps from other items or previous calls don't contaminate this item's baseline.
264            c.data_versions
265                .merge_from_params(&parent_cache.params_versions);
266            // Diff only the item field to find what changed (skips the 5 MB parent tree).
267            crate::jsoneval::eval_cache::diff_and_update_versions(
268                &mut c.data_versions,
269                &format!("/{}", root_key),
270                &old_item_snapshot,
271                &new_item_val,
272                "with_item_cache_swap_diff_and_update_versions",
273            );
274            c.item_snapshot = new_item_val.clone();
275        }
276
277        // Propagate paths NEWLY bumped by this diff into parent_cache.data_versions so that
278        // check_table_cache (which validates T2 global entries against self.data_versions only)
279        // correctly detects changes to rider fields like `sa`, `code`, etc.
280        //
281        // Without this, a field changed via evaluate_dependents_subform (e.g. sa: 0 → 200M)
282        // only bumps the per-item tracker. The T2 entry for RIDER_ZLOB_TABLE (cached with sa=0)
283        // still looks valid when validated against self.data_versions → stale rows → first_prem=0.
284        //
285        // We use pre_diff_item_versions as the baseline so only NEW bumps from THIS diff pass
286        // are propagated, NOT historical bumps accumulated by prior evaluate_subform calls.
287        // This prevents the regression where run_subform_pass sees stale per-rider bumps
288        // and erroneously re-evaluates expensive tables (RIDER_ZLOB_TABLE etc.) for every rider.
289        {
290            let item_field_prefix = format!("/{}/", root_key);
291            if let (Some(ref pre), Some(c)) = (
292                &pre_diff_item_versions,
293                parent_cache.subform_caches.get(&idx),
294            ) {
295                let newly_bumped: Vec<String> = c
296                    .data_versions
297                    .versions()
298                    .filter(|(k, &v)| k.starts_with(&item_field_prefix) && v > pre.get(k))
299                    .map(|(k, _)| k.to_string())
300                    .collect();
301                if !newly_bumped.is_empty() {
302                    for k in newly_bumped {
303                        parent_cache
304                            .data_versions
305                            .bump(&k, "propagate_newly_bumped");
306                    }
307                    parent_cache.eval_generation += 1;
308                }
309            }
310        }
311
312        parent_cache.active_item_index = Some(idx);
313
314        // Restore cached entries that lived in the subform's own per-item cache.
315        // Only restore entries whose dependency versions still match the current item
316        // data_versions: if a field changed (e.g. sa bumped), entries that depended on
317        // that field are stale and must not be re-inserted (they would cause false T1 hits).
318        if let Some(subform_item_cache) = subform_item_cache_opt {
319            if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
320                // Merge historical data_versions from the prior subform item cache BEFORE
321                // computing current_dv. The fresh item cache (ensure_active_item_cache) only
322                // has paths bumped by the current diff. Historical bumps (e.g. /riders/sa=1
323                // from prior calls) live in subform_item_cache.data_versions. Without this
324                // merge, current_dv["/riders/sa"]=0 while T1 entries store dep_ver=1, so all
325                // T1 entries are evicted and every table falls through to the T2 path.
326                // After the merge, current_dv reflects the full accumulated state; the diff
327                // above already bumped any newly-changed fields further, so stale entries that
328                // depended on those fields are still correctly evicted.
329                c.data_versions
330                    .merge_from(&subform_item_cache.data_versions);
331
332                let current_dv = c.data_versions.clone();
333                for (k, v) in subform_item_cache.entries {
334                    // Skip if entry already exists (parent-form run may have added a fresher result).
335                    if c.entries.contains_key(&k) {
336                        continue;
337                    }
338                    // Validate all dep versions against the current item data_versions.
339                    let still_valid = v.dep_versions.iter().all(|(dep_path, &cached_ver)| {
340                        let current_ver = if dep_path.starts_with("/$params") {
341                            parent_cache.params_versions.get(dep_path)
342                        } else {
343                            current_dv.get(dep_path)
344                        };
345                        current_ver == cached_ver
346                    });
347                    if still_valid {
348                        c.entries.insert(k, v);
349                    }
350                }
351            }
352        }
353
354        // Insert into the parent eval_data as well (to make the item visible to global formulas on main evaluate).
355        // Only write (and bump version) when the value actually changed: prevents spurious riders-array
356        // version increments on repeated evaluate_subform calls where the rider data is unchanged.
357        let current_at_item_path = self.eval_data.get(&item_path).cloned();
358        if current_at_item_path.as_ref() != Some(&new_item_val) {
359            self.eval_data.set(&item_path, new_item_val.clone());
360            if is_new_item {
361                parent_cache.bump_data_version(&array_path);
362            }
363        }
364
365        // Re-evaluate `$params` tables that depend on subform item paths that changed.
366        // This is required not just for brand-new items, but also whenever a tracked field
367        // (like `riders.sa`) changes value: tables like RIDER_ZLOB_TABLE depend on rider.sa
368        // and must produce updated rows that reflect the new sa before the subform's own
369        // formula evaluation runs (otherwise cached old rows are reused).
370        //
371        // Gate: only re-evaluate tables when at least one item-level path was NEWLY bumped
372        // in this diff pass. Using any_bumped_with_prefix(v > 0) would return true for
373        // historical bumps from prior calls, causing spurious table invalidation every time.
374        let field_prefix = format!("/{}/", root_key);
375        let item_paths_bumped = match &pre_diff_item_versions {
376            None => {
377                // No pre-diff snapshot = cache slot was just created, treat as new
378                parent_cache
379                    .subform_caches
380                    .get(&idx)
381                    .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
382                    .unwrap_or(false)
383            }
384            Some(pre) => {
385                // Only count bumps that occurred during this specific diff pass
386                parent_cache
387                    .subform_caches
388                    .get(&idx)
389                    .map(|c| {
390                        c.data_versions
391                            .any_newly_bumped_with_prefix(&field_prefix, pre)
392                    })
393                    .unwrap_or(false)
394            }
395        };
396
397        if is_new_item || item_paths_bumped {
398            // Collect which rider data paths were NEWLY bumped in this diff pass.
399            // When item_paths_bumped = true, the diff detected changes — but we only want to
400            // invalidate tables that ACTUALLY depend on those changed paths. Tables like
401            // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
402            // first_prem), so bumping them forces unnecessary re-evaluation and increments
403            // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
404            let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
405                let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
406                    parent_cache.subform_caches.get(&idx).map(|c| {
407                        c.data_versions
408                            .versions()
409                            .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
410                            .map(|(k, _)| {
411                                // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
412                                // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
413                                let sub = k.trim_start_matches(&field_prefix);
414                                format!("#/{}/properties/{}", root_key, sub)
415                            })
416                            .collect::<Vec<_>>()
417                    })
418                });
419                paths
420            } else {
421                None
422            };
423
424            let params_table_keys: Vec<String> = self
425                .table_metadata
426                .keys()
427                .filter(|k| k.starts_with("#/$params"))
428                .filter(|k| {
429                    if is_new_item {
430                        return true; // new rider: invalidate all tables
431                    }
432                    // Only invalidate tables whose declared deps overlap the changed paths.
433                    // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
434                    // fall back to invalidating all.
435                    let Some(ref bumped) = newly_bumped_paths else {
436                        return true;
437                    };
438                    if bumped.is_empty() {
439                        return false;
440                    }
441                    self.dependencies
442                        .get(*k)
443                        .map(|deps| {
444                            deps.iter().any(|dep| {
445                                bumped
446                                    .iter()
447                                    .any(|b| dep == b || dep.starts_with(b.as_str()))
448                            })
449                        })
450                        .unwrap_or(false)
451                })
452                .cloned()
453                .collect();
454            if !params_table_keys.is_empty() {
455                parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
456
457                let eval_data_snapshot = self.eval_data.snapshot_data();
458                for key in &params_table_keys {
459                    // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
460                    // depend on subform-specific item paths (like `#/riders/...`).
461                    // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
462                    // and MUST be evaluated by the subform engine to see the subform's current data.
463                    // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
464                    // and MUST be evaluated by the parent engine to see the full parent array.
465                    let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
466                        let subform_dep_prefix = format!("#/{}/properties/", root_key);
467                        let subform_dep_prefix_short = format!("#/{}/", root_key);
468                        deps.iter().any(|dep| {
469                            dep.starts_with(&subform_dep_prefix)
470                                || dep.starts_with(&subform_dep_prefix_short)
471                        })
472                    } else {
473                        false
474                    };
475
476                    if depends_on_subform_item {
477                        continue;
478                    }
479
480                    // Evaluate the table using parent's updated data
481                    if let Ok((rows, external_deps_opt)) =
482                        crate::jsoneval::table_evaluate::evaluate_table(
483                            self,
484                            key,
485                            &EvalData::from_arc(std::sync::Arc::clone(&eval_data_snapshot)),
486                            None,
487                        )
488                    {
489                        if crate::utils::is_debug_cache_enabled() {
490                            println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
491                        }
492                        let result_val = serde_json::Value::Array(rows);
493
494                        if let Some(external_deps) = external_deps_opt {
495                            // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
496                            // Then the subform can hit it via T2 fallback check.
497                            parent_cache.active_item_index = None;
498                            parent_cache.store_cache(key, &external_deps, result_val);
499                            parent_cache.active_item_index = Some(idx);
500                        }
501                    } else {
502                        if crate::utils::is_debug_cache_enabled() {
503                            println!("PARENT EVALUATED TABLE {} -> ERROR", key);
504                        }
505                    }
506                }
507            }
508        }
509
510        // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
511        {
512            let subform = self.subforms.get_mut(base_path).unwrap();
513            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
514        }
515
516        // Step 4: run the caller-supplied operation.
517        let result = {
518            let subform = self.subforms.get_mut(base_path).unwrap();
519            f(subform)
520        };
521
522        // Step 5: restore parent cache.
523        {
524            let subform = self.subforms.get_mut(base_path).unwrap();
525            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
526        }
527        parent_cache.active_item_index = None;
528        self.eval_cache = parent_cache;
529
530        // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
531        // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
532        // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
533        // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
534        // Also store the subform's evaluated_schema snapshot (written by evaluate_internal above)
535        // so get_evaluated_schema_subform can return per-item values with an O(1) cache read.
536        {
537            let subform = self.subforms.get_mut(base_path).unwrap();
538            if let Some(item_cache) = self.eval_cache.subform_caches.get_mut(&idx) {
539                item_cache.evaluated_schema = Some(subform.evaluated_schema.clone());
540                subform
541                    .eval_cache
542                    .subform_caches
543                    .insert(idx, item_cache.clone());
544            }
545        }
546
547        result
548    }
549
550    /// Evaluate a subform identified by `subform_path`.
551    ///
552    /// The path may include a trailing item index to bind the evaluation to a specific
553    /// array element and enable the two-tier cache-swap strategy automatically:
554    ///
555    /// ```text
556    /// // Evaluate riders item 1 with index-aware cache
557    /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
558    /// ```
559    ///
560    /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
561    pub fn evaluate_subform(
562        &mut self,
563        subform_path: &str,
564        data: &str,
565        context: Option<&str>,
566        paths: Option<&[String]>,
567        token: Option<&CancellationToken>,
568    ) -> Result<(), String> {
569        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
570        if let Some(idx) = idx_opt {
571            self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
572        } else {
573            let subform = self
574                .subforms
575                .get_mut(base_path.as_ref() as &str)
576                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
577            subform.evaluate(data, context, paths, token)
578        }
579    }
580
581    /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
582    fn evaluate_subform_item(
583        &mut self,
584        base_path: &str,
585        idx: usize,
586        data: &str,
587        context: Option<&str>,
588        paths: Option<&[String]>,
589        token: Option<&CancellationToken>,
590    ) -> Result<(), String> {
591        let data_value = crate::jsoneval::json_parser::parse_json_str(data)
592            .map_err(|e| format!("Failed to parse subform data: {}", e))?;
593        let context_value = if let Some(ctx) = context {
594            crate::jsoneval::json_parser::parse_json_str(ctx)
595                .map_err(|e| format!("Failed to parse subform context: {}", e))?
596        } else {
597            Value::Object(serde_json::Map::new())
598        };
599
600        self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
601            // Match main-form lifecycle: resolve visibility, hydrate missing visible static
602            // defaults and their dependents, then re-evaluate only when data was written.
603            sf.evaluate_internal_pre_diffed(paths, token)?;
604            if sf.apply_visible_static_defaults_with_dependents(token)? {
605                sf.evaluate_internal_pre_diffed(paths, token)?;
606            }
607            Ok(())
608        })
609    }
610
611    /// Validate subform data against its schema rules.
612    ///
613    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
614    /// is present the parent cache is swapped in first, ensuring rule evaluations that
615    /// depend on `$params` tables share already-computed parent-form results.
616    pub fn validate_subform(
617        &mut self,
618        subform_path: &str,
619        data: &str,
620        context: Option<&str>,
621        paths: Option<&[String]>,
622        token: Option<&CancellationToken>,
623    ) -> Result<crate::ValidationResult, String> {
624        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
625        if let Some(idx) = idx_opt {
626            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
627                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
628            let context_value = if let Some(ctx) = context {
629                crate::jsoneval::json_parser::parse_json_str(ctx)
630                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
631            } else {
632                Value::Object(serde_json::Map::new())
633            };
634            let data_for_validation = data_value.clone();
635            self.with_item_cache_swap(
636                base_path.as_ref(),
637                idx,
638                data_value,
639                context_value,
640                move |sf| {
641                    // Warm the evaluation cache before running rule checks.
642                    sf.evaluate_internal_pre_diffed(paths, token)?;
643                    sf.validate_pre_set(data_for_validation, paths, token)
644                },
645            )
646        } else {
647            let subform = self
648                .subforms
649                .get_mut(base_path.as_ref() as &str)
650                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
651            subform.validate(data, context, paths, token)
652        }
653    }
654
655    /// Evaluate dependents in a subform when a field changes.
656    ///
657    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
658    /// is present the parent cache is swapped in, so dependent evaluation runs with
659    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
660    pub fn evaluate_dependents_subform(
661        &mut self,
662        subform_path: &str,
663        changed_paths: &[String],
664        data: Option<&str>,
665        context: Option<&str>,
666        re_evaluate: bool,
667        token: Option<&CancellationToken>,
668        canceled_paths: Option<&mut Vec<String>>,
669        include_subforms: bool,
670    ) -> Result<Value, String> {
671        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
672        if let Some(idx) = idx_opt {
673            // Parse or snapshot data for the swap / diff computation.
674            let (data_value, context_value) = if let Some(data_str) = data {
675                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
676                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
677                let cv = if let Some(ctx) = context {
678                    crate::jsoneval::json_parser::parse_json_str(ctx)
679                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
680                } else {
681                    Value::Object(serde_json::Map::new())
682                };
683                (dv, cv)
684            } else {
685                // No new data provided — snapshot current subform state so diff is a no-op.
686                let subform = self
687                    .subforms
688                    .get(base_path.as_ref() as &str)
689                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
690                let dv = subform.eval_data.snapshot_data_clone();
691                (dv, Value::Object(serde_json::Map::new()))
692            };
693            self.with_item_cache_swap(base_path.as_ref(), idx, data_value, context_value, |sf| {
694                // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
695                sf.evaluate_dependents(
696                    changed_paths,
697                    None,
698                    None,
699                    re_evaluate,
700                    token,
701                    None,
702                    include_subforms,
703                )
704            })
705        } else {
706            let subform = self
707                .subforms
708                .get_mut(base_path.as_ref() as &str)
709                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
710            subform.evaluate_dependents(
711                changed_paths,
712                data,
713                context,
714                re_evaluate,
715                token,
716                canceled_paths,
717                include_subforms,
718            )
719        }
720    }
721
722    /// Resolve layout for subform, returning overlay entries.
723    pub fn resolve_layout_subform(
724        &mut self,
725        subform_path: &str,
726        evaluate: bool,
727    ) -> Result<ResolvedLayoutResult, String> {
728        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
729        let subform = self
730            .subforms
731            .get_mut(base_path.as_ref() as &str)
732            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
733        subform.resolve_layout(evaluate)
734    }
735
736    /// Get evaluated schema from subform.
737    pub fn get_evaluated_schema_subform(&mut self, subform_path: &str) -> Value {
738        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
739
740        if let Some(idx) = idx_opt {
741            if let Some(schema) = self
742                .eval_cache
743                .subform_caches
744                .get(&idx)
745                .and_then(|c| c.evaluated_schema.clone())
746            {
747                return schema;
748            }
749            if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
750                subform.get_evaluated_schema()
751            } else {
752                Value::Null
753            }
754        } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
755            subform.get_evaluated_schema()
756        } else {
757            Value::Null
758        }
759    }
760
761    /// Get schema value from subform in nested object format (all .value fields).
762    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
763        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
764        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
765            subform.get_schema_value()
766        } else {
767            Value::Null
768        }
769    }
770
771    /// Get schema values from subform as a flat array of path-value pairs.
772    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
773        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
774        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
775            subform.get_schema_value_array()
776        } else {
777            Value::Array(vec![])
778        }
779    }
780
781    /// Get schema values from subform as a flat object with dotted path keys.
782    pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
783        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
784        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
785            subform.get_schema_value_object()
786        } else {
787            Value::Object(serde_json::Map::new())
788        }
789    }
790
791    /// Get evaluated schema without $params from subform.
792    pub fn get_evaluated_schema_without_params_subform(&mut self, subform_path: &str) -> Value {
793        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
794        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
795            subform.get_evaluated_schema_without_params()
796        } else {
797            Value::Null
798        }
799    }
800
801    /// Get evaluated schema by specific path from subform.
802    pub fn get_evaluated_schema_by_path_subform(
803        &mut self,
804        subform_path: &str,
805        schema_path: &str,
806    ) -> Option<Value> {
807        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
808        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
809            sf.get_evaluated_schema_by_paths(&[schema_path.to_string()], Some(ReturnFormat::Nested))
810        })
811    }
812
813    /// Get evaluated schema by multiple paths from subform.
814    pub fn get_evaluated_schema_by_paths_subform(
815        &mut self,
816        subform_path: &str,
817        schema_paths: &[String],
818        format: Option<crate::ReturnFormat>,
819    ) -> Value {
820        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
821        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
822            subform.get_evaluated_schema_by_paths(
823                schema_paths,
824                Some(format.unwrap_or(ReturnFormat::Flat)),
825            )
826        } else {
827            match format.unwrap_or_default() {
828                crate::ReturnFormat::Array => Value::Array(vec![]),
829                _ => Value::Object(serde_json::Map::new()),
830            }
831        }
832    }
833
834    /// Get schema by specific path from subform.
835    pub fn get_schema_by_path_subform(
836        &self,
837        subform_path: &str,
838        schema_path: &str,
839    ) -> Option<Value> {
840        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
841        self.subforms
842            .get(base_path.as_ref() as &str)
843            .and_then(|sf| sf.get_schema_by_path(schema_path))
844    }
845
846    /// Get schema by multiple paths from subform.
847    pub fn get_schema_by_paths_subform(
848        &self,
849        subform_path: &str,
850        schema_paths: &[String],
851        format: Option<crate::ReturnFormat>,
852    ) -> Value {
853        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
854        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
855            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
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 resolved layout overlay entries for subform.
865    pub fn get_resolved_layout_subform(&mut self, subform_path: &str) -> ResolvedLayoutResult {
866        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
867        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
868            subform.get_resolved_layout()
869        } else {
870            ResolvedLayoutResult::default()
871        }
872    }
873
874    /// Get evaluated schema with layout fully resolved for subform.
875    pub fn get_evaluated_schema_resolved_subform(&mut self, subform_path: &str) -> Value {
876        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
877        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
878            subform.get_evaluated_schema_resolved()
879        } else {
880            Value::Null
881        }
882    }
883
884    /// Get list of available subform paths.
885    pub fn get_subform_paths(&self) -> Vec<String> {
886        self.subforms.keys().cloned().collect()
887    }
888
889    /// Check if a subform exists at the given path.
890    pub fn has_subform(&self, subform_path: &str) -> bool {
891        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
892        self.subforms.contains_key(base_path.as_ref() as &str)
893    }
894}