Skip to main content

json_eval_rs/jsoneval/
subform_methods.rs

1// Subform methods for isolated array field evaluation
2
3use super::JSONEval;
4use crate::jsoneval::cancellation::CancellationToken;
5use crate::jsoneval::eval_data::EvalData;
6use crate::jsoneval::types::{ResolvedLayoutResult, ReturnFormat};
7use serde_json::Value;
8
9/// Decomposes a subform path that may optionally include a trailing item index,
10/// and normalizes the base portion to the canonical schema-pointer key used in the
11/// subform registry (e.g. `"#/illustration/properties/product_benefit/properties/riders"`).
12///
13/// Accepted formats for the **base** portion:
14/// - Schema pointer:    `"#/illustration/properties/product_benefit/properties/riders"`
15/// - Raw JSON pointer:  `"/illustration/properties/product_benefit/properties/riders"`
16/// - Dot notation:      `"illustration.product_benefit.riders"`
17///
18/// Accepted formats for the **index** suffix (stripped before lookup):
19/// - Trailing dot-index:     `"…riders.1"`
20/// - Trailing slash-index:   `"…riders/1"`
21/// - Bracket array index:    `"…riders[1]"` or `"…riders[1]."`
22///
23/// Returns `(canonical_base_path, optional_index)`.
24fn resolve_subform_path(path: &str) -> (String, Option<usize>) {
25    // --- Step 1: strip a trailing bracket array index, e.g. "riders[2]" or "riders[2]."
26    let path = path.trim_end_matches('.');
27    let (path, bracket_idx) = if let Some(bracket_start) = path.rfind('[') {
28        let after = &path[bracket_start + 1..];
29        if let Some(bracket_end) = after.find(']') {
30            let idx_str = &after[..bracket_end];
31            if let Ok(idx) = idx_str.parse::<usize>() {
32                // strip everything from '[' onward (including any trailing '.')
33                let base = path[..bracket_start].trim_end_matches('.');
34                (base, Some(idx))
35            } else {
36                (path, None)
37            }
38        } else {
39            (path, None)
40        }
41    } else {
42        (path, None)
43    };
44
45    // --- Step 2: strip a trailing numeric segment (dot or slash separated)
46    let (base_raw, trailing_idx) = if bracket_idx.is_none() {
47        // Check dot-notation trailing index: "foo.bar.2"
48        if let Some(dot_pos) = path.rfind('.') {
49            let suffix = &path[dot_pos + 1..];
50            if let Ok(idx) = suffix.parse::<usize>() {
51                (&path[..dot_pos], Some(idx))
52            } else {
53                (path, None)
54            }
55        }
56        // Check JSON-pointer trailing index: "#/foo/bar/0" or "/foo/bar/0"
57        else if let Some(slash_pos) = path.rfind('/') {
58            let suffix = &path[slash_pos + 1..];
59            if let Ok(idx) = suffix.parse::<usize>() {
60                (&path[..slash_pos], Some(idx))
61            } else {
62                (path, None)
63            }
64        } else {
65            (path, None)
66        }
67    } else {
68        (path, None)
69    };
70
71    let final_idx = bracket_idx.or(trailing_idx);
72
73    // --- Step 3: normalize base_raw to a canonical schema pointer
74    let canonical = normalize_to_subform_key(base_raw);
75
76    (canonical, final_idx)
77}
78
79/// Normalize any path format to the canonical subform registry key.
80///
81/// The registry stores keys as `"#/field/properties/subfield/properties/…"` — exactly
82/// as produced by the schema `walk()` function. This function converts all supported
83/// formats into that form.
84fn normalize_to_subform_key(path: &str) -> String {
85    // Already a schema pointer — return as-is
86    if path.starts_with("#/") {
87        return path.to_string();
88    }
89
90    // Raw JSON pointer "/foo/properties/bar" → prefix with '#'
91    if path.starts_with('/') {
92        return format!("#{}", path);
93    }
94
95    // Dot-notation: "illustration.product_benefit.riders"
96    // → "#/illustration/properties/product_benefit/properties/riders"
97    crate::jsoneval::path_utils::dot_notation_to_schema_pointer(path)
98}
99
100impl JSONEval {
101    /// Resolves the subform path, allowing aliases like "riders" to match the full
102    /// schema pointer "#/illustration/properties/product_benefit/properties/riders".
103    /// This ensures alias paths and full paths share the same underlying subform store and cache.
104    pub(crate) fn resolve_subform_path_alias(&self, path: &str) -> (String, Option<usize>) {
105        let (mut canonical, idx) = resolve_subform_path(path);
106
107        if !self.subforms.contains_key(&canonical) {
108            let search_suffix = if canonical.starts_with("#/") {
109                format!("/properties/{}", &canonical[2..])
110            } else {
111                format!("/properties/{}", canonical)
112            };
113
114            for k in self.subforms.keys() {
115                if k.ends_with(&search_suffix) || k == &canonical {
116                    canonical = k.to_string();
117                    break;
118                }
119            }
120        }
121
122        (canonical, idx)
123    }
124
125    /// Execute `f` on the subform at `base_path[idx]` with the parent cache swapped in.
126    ///
127    /// Lifecycle:
128    /// 1. Set `data_value` + `context_value` on the subform's `eval_data`.
129    /// 2. Compute item-level diff for `field_key` → bump `subform_caches[idx].data_versions`.
130    /// 3. `mem::take` parent cache → set `active_item_index = Some(idx)` → swap into subform.
131    /// 4. Execute `f(subform)` → collect result.
132    /// 5. Swap parent cache back out → restore `self.eval_cache`.
133    ///
134    /// This ensures all three operations (evaluate / validate / evaluate_dependents)
135    /// share parent-form Tier-2 cache entries, without duplicating the swap boilerplate.
136    fn with_item_cache_swap<F, T>(
137        &mut self,
138        base_path: &str,
139        idx: usize,
140        data_value: Value,
141        context_value: Value,
142        f: F,
143    ) -> Result<T, String>
144    where
145        F: FnOnce(&mut JSONEval) -> Result<T, String>,
146    {
147        let original_field_key = base_path
148            .split('/')
149            .next_back()
150            .unwrap_or(base_path)
151            .to_string();
152
153        let schema_pointer = if base_path.starts_with("#/") {
154            &base_path[1..]
155        } else if base_path.starts_with('#') {
156            &base_path[1..]
157        } else {
158            base_path
159        };
160
161        let root_key =
162            crate::jsoneval::path_utils::get_value_by_pointer(&self.schema, schema_pointer)
163                .and_then(|node| node.get("itemsRootKey"))
164                .and_then(|v| v.as_str())
165                .unwrap_or(&original_field_key)
166                .to_string();
167
168        let array_path =
169            crate::jsoneval::path_utils::schema_path_to_data_pointer(base_path).into_owned();
170        let item_path = format!("{}/{}", array_path, idx);
171        let full_parent_payload = data_value.pointer(&array_path).is_some();
172        let payload_has_parent_context = data_value
173            .as_object()
174            .map(|map| map.keys().any(|key| key != &root_key))
175            .unwrap_or(false);
176
177        // Normalize both public payload shapes into one active item before scope setup.
178        let normalized_item = if full_parent_payload {
179            data_value
180                .pointer(&item_path)
181                .cloned()
182                .or_else(|| data_value.get(&root_key).cloned())
183        } else {
184            data_value.get(&root_key).cloned()
185        }
186        .ok_or_else(|| {
187            format!(
188                "Invalid indexed subform payload for {base_path}[{idx}]: expected active item at {item_path} or wrapper root {root_key}"
189            )
190        })?;
191
192        // Step 1: update subform data and extract item snapshot for targeted diff.
193        // Scoped block releases the mutable borrow on `self.subforms` before we touch
194        // `self.eval_cache` (they are disjoint fields, but keep it explicit).
195        let (old_item_snapshot, new_item_val, subform_item_cache_opt) = {
196            let subform = self
197                .subforms
198                .get_mut(base_path)
199                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
200
201            let old_item_snapshot = subform
202                .eval_cache
203                .subform_caches
204                .get(&idx)
205                .map(|c| c.item_snapshot.clone())
206                .unwrap_or(Value::Null);
207
208            // Retain parent evaluator's schema-owned `$params`, then merge a full
209            // payload's parent fields over it. Item wrappers supply only active item.
210            let mut scoped_data = EvalData::new(self.eval_data.snapshot_data_clone());
211            if full_parent_payload || payload_has_parent_context {
212                scoped_data.replace_data_and_context(data_value.clone(), context_value.clone());
213            }
214            scoped_data.set(&item_path, normalized_item.clone());
215            let canonical_parent = scoped_data.snapshot_data_clone();
216            let scope = crate::jsoneval::subform_scope::SubformScope::new(
217                base_path,
218                &array_path,
219                Some(idx),
220            );
221            let mut scoped_view = scope.evaluation_view(&canonical_parent);
222            if let Some(view) = scoped_view.as_object_mut() {
223                view.insert("$context".to_string(), context_value.clone());
224            }
225            subform.eval_data = EvalData::new(scoped_view);
226            let new_item_val = normalized_item.clone();
227
228            // Pull out any existing item-scoped entries from the subform's own cache
229            // so they can be merged into the parent cache below.
230            let existing = subform.eval_cache.subform_caches.remove(&idx);
231            (old_item_snapshot, new_item_val, existing)
232        }; // subform borrow released here
233
234        // Unified store fallback: if the subform's own per-item cache has no snapshot for this
235        // index (e.g. this is the first evaluate_subform call after a full evaluate()), treat the
236        // parent's eval_data slot as the canonical baseline. The parent always holds the most
237        // recent array data written by evaluate() or evaluate_dependents(), so using it avoids
238        // treating an already-evaluated item as brand-new and forcing full table re-evaluation.
239        let parent_item = self.eval_data.get(&item_path).cloned();
240        let old_item_snapshot = if old_item_snapshot == Value::Null {
241            parent_item.clone().unwrap_or(Value::Null)
242        } else {
243            old_item_snapshot
244        };
245
246        // An item is "new" only when the parent's eval_data has no entry at the item path.
247        // Using the subform's own snapshot cache as the authority (old_item_snapshot == Null)
248        // is not correct after Step 6 persistence re-seeds the cache: a rider that was
249        // previously evaluate_subform'd would have a snapshot but may still be absent from
250        // the parent array (e.g. new rider scenario after evaluate_dependents_subform).
251        let is_new_item = parent_item.is_none();
252
253        let mut parent_cache = std::mem::take(&mut self.eval_cache);
254        if full_parent_payload {
255            let old_parent_data = self.eval_data.snapshot_data_clone();
256            self.eval_data
257                .replace_data_and_context(data_value.clone(), context_value.clone());
258            let new_parent_data = self.eval_data.snapshot_data_clone();
259            crate::jsoneval::eval_cache::diff_and_update_versions(
260                &mut parent_cache.data_versions,
261                "",
262                &old_parent_data,
263                &new_parent_data,
264                "sync_full_subform_payload",
265            );
266        }
267        parent_cache.ensure_active_item_cache(idx);
268
269        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
270            // Parent dependency paths use absolute form pointers, while item formulas use
271            // `/riders/...`; merging parent versions invalidates parent-driven conditions without
272            // cross-item contamination. Item-local bumps remain isolated under `/riders/...`.
273            c.data_versions
274                .merge_excluding_prefix(&parent_cache.data_versions, &format!("/{root_key}/"));
275            c.data_versions
276                .merge_from_params(&parent_cache.params_versions);
277
278            // Merge the durable item history before diffing. Diffing first can reuse a version
279            // number held by a cached entry (false → true both appear as version 1), leaving an
280            // outdated condition/value cache entry valid. Historical versions form the diff base.
281            if let Some(subform_item_cache) = &subform_item_cache_opt {
282                c.data_versions
283                    .merge_from(&subform_item_cache.data_versions);
284            }
285        }
286
287        // Snapshot item versions BEFORE the diff so we can detect only NEW bumps below.
288        // `any_bumped_with_prefix(v > 0)` would return true for historical bumps from prior
289        // calls, causing invalidate_params_tables_for_item to fire on every evaluate_subform
290        // even when no rider data actually changed.
291        let pre_diff_item_versions = parent_cache
292            .subform_caches
293            .get(&idx)
294            .map(|c| c.data_versions.clone());
295
296        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
297            // Diff only the item field to find what changed (skips the 5 MB parent tree).
298            crate::jsoneval::eval_cache::diff_and_update_versions(
299                &mut c.data_versions,
300                &format!("/{}", root_key),
301                &old_item_snapshot,
302                &new_item_val,
303                "with_item_cache_swap_diff_and_update_versions",
304            );
305            c.item_snapshot = new_item_val.clone();
306        }
307
308        // Propagate paths NEWLY bumped by this diff into parent_cache.data_versions so that
309        // check_table_cache (which validates T2 global entries against self.data_versions only)
310        // correctly detects changes to rider fields like `sa`, `code`, etc.
311        //
312        // Without this, a field changed via evaluate_dependents_subform (e.g. sa: 0 → 200M)
313        // only bumps the per-item tracker. The T2 entry for RIDER_ZLOB_TABLE (cached with sa=0)
314        // still looks valid when validated against self.data_versions → stale rows → first_prem=0.
315        //
316        // We use pre_diff_item_versions as the baseline so only NEW bumps from THIS diff pass
317        // are propagated, NOT historical bumps accumulated by prior evaluate_subform calls.
318        // This prevents the regression where run_subform_pass sees stale per-rider bumps
319        // and erroneously re-evaluates expensive tables (RIDER_ZLOB_TABLE etc.) for every rider.
320        {
321            let item_field_prefix = format!("/{}/", root_key);
322            if let (Some(ref pre), Some(c)) = (
323                &pre_diff_item_versions,
324                parent_cache.subform_caches.get(&idx),
325            ) {
326                let newly_bumped: Vec<String> = c
327                    .data_versions
328                    .versions()
329                    .filter(|(k, &v)| k.starts_with(&item_field_prefix) && v > pre.get(k))
330                    .map(|(k, _)| k.to_string())
331                    .collect();
332                if !newly_bumped.is_empty() {
333                    for k in newly_bumped {
334                        parent_cache
335                            .data_versions
336                            .bump(&k, "propagate_newly_bumped");
337                    }
338                    parent_cache.eval_generation += 1;
339                }
340            }
341        }
342
343        parent_cache.active_item_index = Some(idx);
344
345        // Restore cached entries that lived in the subform's own per-item cache.
346        // Only restore entries whose dependency versions still match the current item
347        // data_versions: if a field changed (e.g. sa bumped), entries that depended on
348        // that field are stale and must not be re-inserted (they would cause false T1 hits).
349        if let Some(subform_item_cache) = subform_item_cache_opt {
350            if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
351                // Merge historical data_versions from the prior subform item cache BEFORE
352                // computing current_dv. The fresh item cache (ensure_active_item_cache) only
353                // has paths bumped by the current diff. Historical bumps (e.g. /riders/sa=1
354                // from prior calls) live in subform_item_cache.data_versions. Without this
355                // merge, current_dv["/riders/sa"]=0 while T1 entries store dep_ver=1, so all
356                // T1 entries are evicted and every table falls through to the T2 path.
357                // After the merge, current_dv reflects the full accumulated state; the diff
358                // above already bumped any newly-changed fields further, so stale entries that
359                // depended on those fields are still correctly evicted.
360                let current_dv = c.data_versions.clone();
361                for (k, v) in subform_item_cache.entries {
362                    // Skip if entry already exists (parent-form run may have added a fresher result).
363                    if c.entries.contains_key(&k) {
364                        continue;
365                    }
366                    // Validate all dep versions against the current item data_versions.
367                    let still_valid = v.dep_versions.iter().all(|(dep_path, &cached_ver)| {
368                        let current_ver = if dep_path.starts_with("/$params") {
369                            parent_cache.params_versions.get(dep_path)
370                        } else {
371                            current_dv.get(dep_path)
372                        };
373                        current_ver == cached_ver
374                    });
375                    if still_valid {
376                        c.entries.insert(k, v);
377                    }
378                }
379            }
380        }
381
382        // Insert into the parent eval_data as well (to make the item visible to global formulas on main evaluate).
383        // Only write (and bump version) when the value actually changed: prevents spurious riders-array
384        // version increments on repeated evaluate_subform calls where the rider data is unchanged.
385        let current_at_item_path = self.eval_data.get(&item_path).cloned();
386        if current_at_item_path.as_ref() != Some(&new_item_val) {
387            self.eval_data.set(&item_path, new_item_val.clone());
388            if is_new_item {
389                parent_cache.bump_data_version(&array_path);
390            }
391        }
392
393        // Re-evaluate `$params` tables that depend on subform item paths that changed.
394        // This is required not just for brand-new items, but also whenever a tracked field
395        // (like `riders.sa`) changes value: tables like RIDER_ZLOB_TABLE depend on rider.sa
396        // and must produce updated rows that reflect the new sa before the subform's own
397        // formula evaluation runs (otherwise cached old rows are reused).
398        //
399        // Gate: only re-evaluate tables when at least one item-level path was NEWLY bumped
400        // in this diff pass. Using any_bumped_with_prefix(v > 0) would return true for
401        // historical bumps from prior calls, causing spurious table invalidation every time.
402        let field_prefix = format!("/{}/", root_key);
403        let item_paths_bumped = match &pre_diff_item_versions {
404            None => {
405                // No pre-diff snapshot = cache slot was just created, treat as new
406                parent_cache
407                    .subform_caches
408                    .get(&idx)
409                    .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
410                    .unwrap_or(false)
411            }
412            Some(pre) => {
413                // Only count bumps that occurred during this specific diff pass
414                parent_cache
415                    .subform_caches
416                    .get(&idx)
417                    .map(|c| {
418                        c.data_versions
419                            .any_newly_bumped_with_prefix(&field_prefix, pre)
420                    })
421                    .unwrap_or(false)
422            }
423        };
424
425        if is_new_item || item_paths_bumped {
426            // Collect which rider data paths were NEWLY bumped in this diff pass.
427            // When item_paths_bumped = true, the diff detected changes — but we only want to
428            // invalidate tables that ACTUALLY depend on those changed paths. Tables like
429            // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
430            // first_prem), so bumping them forces unnecessary re-evaluation and increments
431            // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
432            let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
433                let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
434                    parent_cache.subform_caches.get(&idx).map(|c| {
435                        c.data_versions
436                            .versions()
437                            .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
438                            .map(|(k, _)| {
439                                // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
440                                // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
441                                let sub = k.trim_start_matches(&field_prefix);
442                                format!("#/{}/properties/{}", root_key, sub)
443                            })
444                            .collect::<Vec<_>>()
445                    })
446                });
447                paths
448            } else {
449                None
450            };
451
452            let params_table_keys: Vec<String> = self
453                .table_metadata
454                .keys()
455                .filter(|k| k.starts_with("#/$params"))
456                .filter(|k| {
457                    if is_new_item {
458                        return true; // new rider: invalidate all tables
459                    }
460                    // Only invalidate tables whose declared deps overlap the changed paths.
461                    // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
462                    // fall back to invalidating all.
463                    let Some(ref bumped) = newly_bumped_paths else {
464                        return true;
465                    };
466                    if bumped.is_empty() {
467                        return false;
468                    }
469                    self.dependencies
470                        .get(*k)
471                        .map(|deps| {
472                            deps.iter().any(|dep| {
473                                bumped
474                                    .iter()
475                                    .any(|b| dep == b || dep.starts_with(b.as_str()))
476                            })
477                        })
478                        .unwrap_or(false)
479                })
480                .cloned()
481                .collect();
482            if !params_table_keys.is_empty() {
483                parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
484
485                let eval_data_snapshot = self.eval_data.snapshot_data();
486                for key in &params_table_keys {
487                    // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
488                    // depend on subform-specific item paths (like `#/riders/...`).
489                    // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
490                    // and MUST be evaluated by the subform engine to see the subform's current data.
491                    // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
492                    // and MUST be evaluated by the parent engine to see the full parent array.
493                    let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
494                        let subform_dep_prefix = format!("#/{}/properties/", root_key);
495                        let subform_dep_prefix_short = format!("#/{}/", root_key);
496                        deps.iter().any(|dep| {
497                            dep.starts_with(&subform_dep_prefix)
498                                || dep.starts_with(&subform_dep_prefix_short)
499                        })
500                    } else {
501                        false
502                    };
503
504                    if depends_on_subform_item {
505                        continue;
506                    }
507
508                    // Evaluate the table using parent's updated data
509                    if let Ok((rows, external_deps_opt)) =
510                        crate::jsoneval::table_evaluate::evaluate_table(
511                            self,
512                            key,
513                            &EvalData::from_arc(std::sync::Arc::clone(&eval_data_snapshot)),
514                            None,
515                        )
516                    {
517                        if crate::utils::is_debug_cache_enabled() {
518                            println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
519                        }
520                        let result_val = serde_json::Value::Array(rows);
521
522                        if let Some(external_deps) = external_deps_opt {
523                            // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
524                            // Then the subform can hit it via T2 fallback check.
525                            parent_cache.active_item_index = None;
526                            parent_cache.store_cache(key, &external_deps, result_val);
527                            parent_cache.active_item_index = Some(idx);
528                        }
529                    } else {
530                        if crate::utils::is_debug_cache_enabled() {
531                            println!("PARENT EVALUATED TABLE {} -> ERROR", key);
532                        }
533                    }
534                }
535            }
536        }
537
538        // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
539        {
540            let subform = self.subforms.get_mut(base_path).unwrap();
541            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
542        }
543
544        // Step 4: run the caller-supplied operation.
545        let result = {
546            let subform = self.subforms.get_mut(base_path).unwrap();
547            f(subform)
548        };
549
550        // Step 5: restore parent cache.
551        {
552            let subform = self.subforms.get_mut(base_path).unwrap();
553            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
554        }
555        parent_cache.active_item_index = None;
556        self.eval_cache = parent_cache;
557
558        // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
559        // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
560        // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
561        // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
562        // Also store the subform's evaluated_schema snapshot (written by evaluate_internal above)
563        // so get_evaluated_schema_subform can return per-item values with an O(1) cache read.
564        {
565            let subform = self.subforms.get_mut(base_path).unwrap();
566            if let Some(item_cache) = self.eval_cache.subform_caches.get_mut(&idx) {
567                item_cache.evaluated_schema = Some(subform.evaluated_schema.clone());
568                subform
569                    .eval_cache
570                    .subform_caches
571                    .insert(idx, item_cache.clone());
572            }
573        }
574
575        result
576    }
577
578    /// Evaluate a subform identified by `subform_path`.
579    ///
580    /// The path may include a trailing item index to bind the evaluation to a specific
581    /// array element and enable the two-tier cache-swap strategy automatically:
582    ///
583    /// ```text
584    /// // Evaluate riders item 1 with index-aware cache
585    /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
586    /// ```
587    ///
588    /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
589    pub fn evaluate_subform(
590        &mut self,
591        subform_path: &str,
592        data: &str,
593        context: Option<&str>,
594        paths: Option<&[String]>,
595        token: Option<&CancellationToken>,
596    ) -> Result<(), String> {
597        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
598        if let Some(idx) = idx_opt {
599            self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
600        } else {
601            let subform = self
602                .subforms
603                .get_mut(base_path.as_ref() as &str)
604                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
605            subform.evaluate(data, context, paths, token)
606        }
607    }
608
609    /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
610    fn evaluate_subform_item(
611        &mut self,
612        base_path: &str,
613        idx: usize,
614        data: &str,
615        context: Option<&str>,
616        paths: Option<&[String]>,
617        token: Option<&CancellationToken>,
618    ) -> Result<(), String> {
619        let data_value = crate::jsoneval::json_parser::parse_json_str(data)
620            .map_err(|e| format!("Failed to parse subform data: {}", e))?;
621        let context_value = if let Some(ctx) = context {
622            crate::jsoneval::json_parser::parse_json_str(ctx)
623                .map_err(|e| format!("Failed to parse subform context: {}", e))?
624        } else {
625            Value::Object(serde_json::Map::new())
626        };
627
628        self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
629            // Match main-form lifecycle: resolve visibility, hydrate missing visible static
630            // defaults and their dependents, then re-evaluate only when data was written.
631            sf.evaluate_internal_pre_diffed(paths, token)?;
632            if sf.apply_visible_static_defaults_with_dependents(token)? {
633                sf.evaluate_internal_pre_diffed(paths, token)?;
634            }
635            Ok(())
636        })
637    }
638
639    /// Validate subform data against its schema rules.
640    ///
641    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
642    /// is present the parent cache is swapped in first, ensuring rule evaluations that
643    /// depend on `$params` tables share already-computed parent-form results.
644    pub fn validate_subform(
645        &mut self,
646        subform_path: &str,
647        data: &str,
648        context: Option<&str>,
649        paths: Option<&[String]>,
650        token: Option<&CancellationToken>,
651    ) -> Result<crate::ValidationResult, String> {
652        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
653        if let Some(idx) = idx_opt {
654            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
655                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
656            let context_value = if let Some(ctx) = context {
657                crate::jsoneval::json_parser::parse_json_str(ctx)
658                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
659            } else {
660                Value::Object(serde_json::Map::new())
661            };
662            let data_for_validation = data_value.clone();
663            self.with_item_cache_swap(
664                base_path.as_ref(),
665                idx,
666                data_value,
667                context_value,
668                move |sf| {
669                    // Warm the evaluation cache before running rule checks.
670                    sf.evaluate_internal_pre_diffed(paths, token)?;
671                    sf.validate_pre_set(data_for_validation, paths, token)
672                },
673            )
674        } else {
675            let subform = self
676                .subforms
677                .get_mut(base_path.as_ref() as &str)
678                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
679            subform.validate(data, context, paths, token)
680        }
681    }
682
683    /// Evaluate dependents in a subform when a field changes.
684    ///
685    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
686    /// is present the parent cache is swapped in, so dependent evaluation runs with
687    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
688    pub fn evaluate_dependents_subform(
689        &mut self,
690        subform_path: &str,
691        changed_paths: &[String],
692        data: Option<&str>,
693        context: Option<&str>,
694        re_evaluate: bool,
695        token: Option<&CancellationToken>,
696        canceled_paths: Option<&mut Vec<String>>,
697        include_subforms: bool,
698    ) -> Result<Value, String> {
699        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
700        if let Some(idx) = idx_opt {
701            // Parse or snapshot data for the swap / diff computation.
702            let (data_value, context_value) = if let Some(data_str) = data {
703                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
704                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
705                let cv = if let Some(ctx) = context {
706                    crate::jsoneval::json_parser::parse_json_str(ctx)
707                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
708                } else {
709                    Value::Object(serde_json::Map::new())
710                };
711                (dv, cv)
712            } else {
713                // No new data provided — snapshot current subform state so diff is a no-op.
714                let subform = self
715                    .subforms
716                    .get(base_path.as_ref() as &str)
717                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
718                let dv = subform.eval_data.snapshot_data_clone();
719                (dv, Value::Object(serde_json::Map::new()))
720            };
721            let changes = self.with_item_cache_swap(
722                base_path.as_ref(),
723                idx,
724                data_value,
725                context_value,
726                |sf| {
727                    // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
728                    sf.evaluate_dependents(
729                        changed_paths,
730                        None,
731                        None,
732                        re_evaluate,
733                        token,
734                        None,
735                        include_subforms,
736                    )
737                },
738            )?;
739            // Public indexed-subform patches retain subform-local refs so callers can apply
740            // them directly to their `{ riders: item }` editing payload. Internal dependent
741            // evaluation stays canonical; only active-item result refs are projected here.
742            let subform_dot_path =
743                crate::jsoneval::path_utils::pointer_to_dot_notation(base_path.as_ref())
744                    .replace(".properties.", ".");
745            let canonical_prefix = format!("{subform_dot_path}.{idx}");
746            let root_key = base_path.rsplit('/').next().unwrap_or(base_path.as_ref());
747            let changes = match changes {
748                Value::Array(changes) => Value::Array(
749                    changes
750                        .into_iter()
751                        .map(|change| {
752                            let Some(change_map) = change.as_object() else {
753                                return change;
754                            };
755                            let Some(Value::String(reference)) = change_map.get("$ref") else {
756                                return change;
757                            };
758                            let Some(suffix) = reference.strip_prefix(&canonical_prefix) else {
759                                return change;
760                            };
761                            if !suffix.is_empty() && !suffix.starts_with('.') {
762                                return change;
763                            }
764                            let mut mapped = change_map.clone();
765                            mapped.insert(
766                                "$ref".to_string(),
767                                Value::String(format!("{root_key}{suffix}")),
768                            );
769                            Value::Object(mapped)
770                        })
771                        .collect(),
772                ),
773                value => value,
774            };
775            Ok(changes)
776        } else {
777            let subform = self
778                .subforms
779                .get_mut(base_path.as_ref() as &str)
780                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
781            subform.evaluate_dependents(
782                changed_paths,
783                data,
784                context,
785                re_evaluate,
786                token,
787                canceled_paths,
788                include_subforms,
789            )
790        }
791    }
792
793    /// Resolve layout for subform, returning overlay entries.
794    pub fn resolve_layout_subform(
795        &mut self,
796        subform_path: &str,
797        evaluate: bool,
798    ) -> Result<ResolvedLayoutResult, String> {
799        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
800        let subform = self
801            .subforms
802            .get_mut(base_path.as_ref() as &str)
803            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
804        subform.resolve_layout(evaluate)
805    }
806
807    /// Get evaluated schema from subform.
808    pub fn get_evaluated_schema_subform(&mut self, subform_path: &str) -> Value {
809        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
810
811        if let Some(idx) = idx_opt {
812            if let Some(schema) = self
813                .eval_cache
814                .subform_caches
815                .get(&idx)
816                .and_then(|c| c.evaluated_schema.clone())
817            {
818                return schema;
819            }
820            if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
821                subform.get_evaluated_schema()
822            } else {
823                Value::Null
824            }
825        } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
826            subform.get_evaluated_schema()
827        } else {
828            Value::Null
829        }
830    }
831
832    /// Get schema value from subform in nested object format (all .value fields).
833    ///
834    /// Indexed evaluation stores an absolute parent-array wrapper in the subform's
835    /// eval data for cross-item formulas. This endpoint exposes only fields declared
836    /// by its isolated subform schema, never that implementation wrapper.
837    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
838        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
839        let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) else {
840            return Value::Null;
841        };
842
843        let values = subform.get_schema_value();
844        let Some(values) = values.as_object() else {
845            return values;
846        };
847
848        let schema_root_keys: Vec<&str> = subform
849            .schema
850            .as_object()
851            .into_iter()
852            .flat_map(|schema| schema.keys())
853            .filter(|key| !key.starts_with('$'))
854            .map(String::as_str)
855            .collect();
856
857        Value::Object(
858            schema_root_keys
859                .into_iter()
860                .filter_map(|key| {
861                    values
862                        .get(key)
863                        .cloned()
864                        .map(|value| (key.to_string(), value))
865                })
866                .collect(),
867        )
868    }
869
870    /// Get schema values from subform as a flat array of path-value pairs.
871    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
872        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
873        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
874            subform.get_schema_value_array()
875        } else {
876            Value::Array(vec![])
877        }
878    }
879
880    /// Get schema values from subform as a flat object with dotted path keys.
881    pub fn get_schema_value_object_subform(&self, subform_path: &str) -> 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_value_object()
885        } else {
886            Value::Object(serde_json::Map::new())
887        }
888    }
889
890    /// Get evaluated schema without $params from subform.
891    pub fn get_evaluated_schema_without_params_subform(&mut self, subform_path: &str) -> Value {
892        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
893        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
894            subform.get_evaluated_schema_without_params()
895        } else {
896            Value::Null
897        }
898    }
899
900    /// Get evaluated schema by specific path from subform.
901    pub fn get_evaluated_schema_by_path_subform(
902        &mut self,
903        subform_path: &str,
904        schema_path: &str,
905    ) -> Option<Value> {
906        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
907        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
908            sf.get_evaluated_schema_by_paths(&[schema_path.to_string()], Some(ReturnFormat::Nested))
909        })
910    }
911
912    /// Get evaluated schema by multiple paths from subform.
913    pub fn get_evaluated_schema_by_paths_subform(
914        &mut self,
915        subform_path: &str,
916        schema_paths: &[String],
917        format: Option<crate::ReturnFormat>,
918    ) -> Value {
919        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
920        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
921            subform.get_evaluated_schema_by_paths(
922                schema_paths,
923                Some(format.unwrap_or(ReturnFormat::Flat)),
924            )
925        } else {
926            match format.unwrap_or_default() {
927                crate::ReturnFormat::Array => Value::Array(vec![]),
928                _ => Value::Object(serde_json::Map::new()),
929            }
930        }
931    }
932
933    /// Get schema by specific path from subform.
934    pub fn get_schema_by_path_subform(
935        &self,
936        subform_path: &str,
937        schema_path: &str,
938    ) -> Option<Value> {
939        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
940        self.subforms
941            .get(base_path.as_ref() as &str)
942            .and_then(|sf| sf.get_schema_by_path(schema_path))
943    }
944
945    /// Get schema by multiple paths from subform.
946    pub fn get_schema_by_paths_subform(
947        &self,
948        subform_path: &str,
949        schema_paths: &[String],
950        format: Option<crate::ReturnFormat>,
951    ) -> Value {
952        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
953        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
954            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
955        } else {
956            match format.unwrap_or_default() {
957                crate::ReturnFormat::Array => Value::Array(vec![]),
958                _ => Value::Object(serde_json::Map::new()),
959            }
960        }
961    }
962
963    /// Get resolved layout overlay entries for subform.
964    pub fn get_resolved_layout_subform(&mut self, subform_path: &str) -> ResolvedLayoutResult {
965        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
966        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
967            subform.get_resolved_layout()
968        } else {
969            ResolvedLayoutResult::default()
970        }
971    }
972
973    /// Get evaluated schema with layout fully resolved for subform.
974    pub fn get_evaluated_schema_resolved_subform(&mut self, subform_path: &str) -> Value {
975        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
976        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
977            subform.get_evaluated_schema_resolved()
978        } else {
979            Value::Null
980        }
981    }
982
983    /// Get list of available subform paths.
984    pub fn get_subform_paths(&self) -> Vec<String> {
985        self.subforms.keys().cloned().collect()
986    }
987
988    /// Check if a subform exists at the given path.
989    pub fn has_subform(&self, subform_path: &str) -> bool {
990        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
991        self.subforms.contains_key(base_path.as_ref() as &str)
992    }
993}