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