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 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.
718 pub fn resolve_layout_subform(
719 &mut self,
720 subform_path: &str,
721 evaluate: bool,
722 ) -> Result<(), 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 let _ = subform.resolve_layout(evaluate);
729 Ok(())
730 }
731
732 /// Get evaluated schema from subform.
733 pub fn get_evaluated_schema_subform(
734 &mut self,
735 subform_path: &str,
736 resolve_layout: bool,
737 ) -> Value {
738 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
739
740 if let Some(idx) = idx_opt {
741 // Read the per-item evaluated_schema snapshot stored by the most recent
742 // evaluate_subform_item call for this index (Step 6 in with_item_cache_swap).
743 //
744 // This is the correct approach: the subform's evaluated_schema is a single
745 // shared object that is overwritten by every evaluate_subform call. Trying to
746 // re-run evaluate_internal in a shared context is fragile and ordering-dependent.
747 // Instead, we capture the schema snapshot immediately after each item evaluates
748 // and store it in SubformItemCache.evaluated_schema for O(1) retrieval here.
749 if let Some(schema) = self
750 .eval_cache
751 .subform_caches
752 .get(&idx)
753 .and_then(|c| c.evaluated_schema.clone())
754 {
755 return schema;
756 }
757 // Fallback: no snapshot yet — run the evaluation now.
758 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
759 subform.get_evaluated_schema(resolve_layout)
760 } else {
761 Value::Null
762 }
763 } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
764 subform.get_evaluated_schema(resolve_layout)
765 } else {
766 Value::Null
767 }
768 }
769
770 /// Get schema value from subform in nested object format (all .value fields).
771 pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
772 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
773 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
774 subform.get_schema_value()
775 } else {
776 Value::Null
777 }
778 }
779
780 /// Get schema values from subform as a flat array of path-value pairs.
781 pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
782 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
783 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
784 subform.get_schema_value_array()
785 } else {
786 Value::Array(vec![])
787 }
788 }
789
790 /// Get schema values from subform as a flat object with dotted path keys.
791 pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
792 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
793 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
794 subform.get_schema_value_object()
795 } else {
796 Value::Object(serde_json::Map::new())
797 }
798 }
799
800 /// Get evaluated schema without $params from subform.
801 pub fn get_evaluated_schema_without_params_subform(
802 &mut self,
803 subform_path: &str,
804 resolve_layout: bool,
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_without_params(resolve_layout)
809 } else {
810 Value::Null
811 }
812 }
813
814 /// Get evaluated schema by specific path from subform.
815 pub fn get_evaluated_schema_by_path_subform(
816 &mut self,
817 subform_path: &str,
818 schema_path: &str,
819 skip_layout: bool,
820 ) -> Option<Value> {
821 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
822 self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
823 sf.get_evaluated_schema_by_paths(
824 &[schema_path.to_string()],
825 skip_layout,
826 Some(ReturnFormat::Nested),
827 )
828 })
829 }
830
831 /// Get evaluated schema by multiple paths from subform.
832 pub fn get_evaluated_schema_by_paths_subform(
833 &mut self,
834 subform_path: &str,
835 schema_paths: &[String],
836 skip_layout: bool,
837 format: Option<crate::ReturnFormat>,
838 ) -> Value {
839 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
840 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
841 subform.get_evaluated_schema_by_paths(
842 schema_paths,
843 skip_layout,
844 Some(format.unwrap_or(ReturnFormat::Flat)),
845 )
846 } else {
847 match format.unwrap_or_default() {
848 crate::ReturnFormat::Array => Value::Array(vec![]),
849 _ => Value::Object(serde_json::Map::new()),
850 }
851 }
852 }
853
854 /// Get schema by specific path from subform.
855 pub fn get_schema_by_path_subform(
856 &self,
857 subform_path: &str,
858 schema_path: &str,
859 ) -> Option<Value> {
860 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
861 self.subforms
862 .get(base_path.as_ref() as &str)
863 .and_then(|sf| sf.get_schema_by_path(schema_path))
864 }
865
866 /// Get schema by multiple paths from subform.
867 pub fn get_schema_by_paths_subform(
868 &self,
869 subform_path: &str,
870 schema_paths: &[String],
871 format: Option<crate::ReturnFormat>,
872 ) -> Value {
873 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
874 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
875 subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
876 } else {
877 match format.unwrap_or_default() {
878 crate::ReturnFormat::Array => Value::Array(vec![]),
879 _ => Value::Object(serde_json::Map::new()),
880 }
881 }
882 }
883
884 /// Get list of available subform paths.
885 pub fn get_subform_paths(&self) -> Vec<String> {
886 self.subforms.keys().cloned().collect()
887 }
888
889 /// Check if a subform exists at the given path.
890 pub fn has_subform(&self, subform_path: &str) -> bool {
891 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
892 self.subforms.contains_key(base_path.as_ref() as &str)
893 }
894}