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