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, ¶ms_table_keys);
485
486 let eval_data_snapshot = self.eval_data.snapshot_data();
487 for key in ¶ms_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}