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