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