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