json_eval_rs/jsoneval/subform_methods.rs
1// Subform methods for isolated array field evaluation
2
3use crate::jsoneval::cancellation::CancellationToken;
4use crate::JSONEval;
5use crate::ReturnFormat;
6use serde_json::Value;
7
8/// Decomposes a subform path that may optionally include a trailing item index,
9/// and normalizes the base portion to the canonical schema-pointer key used in the
10/// subform registry (e.g. `"#/illustration/properties/product_benefit/properties/riders"`).
11///
12/// Accepted formats for the **base** portion:
13/// - Schema pointer: `"#/illustration/properties/product_benefit/properties/riders"`
14/// - Raw JSON pointer: `"/illustration/properties/product_benefit/properties/riders"`
15/// - Dot notation: `"illustration.product_benefit.riders"`
16///
17/// Accepted formats for the **index** suffix (stripped before lookup):
18/// - Trailing dot-index: `"…riders.1"`
19/// - Trailing slash-index: `"…riders/1"`
20/// - Bracket array index: `"…riders[1]"` or `"…riders[1]."`
21///
22/// Returns `(canonical_base_path, optional_index)`.
23fn resolve_subform_path(path: &str) -> (String, Option<usize>) {
24 // --- Step 1: strip a trailing bracket array index, e.g. "riders[2]" or "riders[2]."
25 let path = path.trim_end_matches('.');
26 let (path, bracket_idx) = if let Some(bracket_start) = path.rfind('[') {
27 let after = &path[bracket_start + 1..];
28 if let Some(bracket_end) = after.find(']') {
29 let idx_str = &after[..bracket_end];
30 if let Ok(idx) = idx_str.parse::<usize>() {
31 // strip everything from '[' onward (including any trailing '.')
32 let base = path[..bracket_start].trim_end_matches('.');
33 (base, Some(idx))
34 } else {
35 (path, None)
36 }
37 } else {
38 (path, None)
39 }
40 } else {
41 (path, None)
42 };
43
44 // --- Step 2: strip a trailing numeric segment (dot or slash separated)
45 let (base_raw, trailing_idx) = if bracket_idx.is_none() {
46 // Check dot-notation trailing index: "foo.bar.2"
47 if let Some(dot_pos) = path.rfind('.') {
48 let suffix = &path[dot_pos + 1..];
49 if let Ok(idx) = suffix.parse::<usize>() {
50 (&path[..dot_pos], Some(idx))
51 } else {
52 (path, None)
53 }
54 }
55 // Check JSON-pointer trailing index: "#/foo/bar/0" or "/foo/bar/0"
56 else if let Some(slash_pos) = path.rfind('/') {
57 let suffix = &path[slash_pos + 1..];
58 if let Ok(idx) = suffix.parse::<usize>() {
59 (&path[..slash_pos], Some(idx))
60 } else {
61 (path, None)
62 }
63 } else {
64 (path, None)
65 }
66 } else {
67 (path, None)
68 };
69
70 let final_idx = bracket_idx.or(trailing_idx);
71
72 // --- Step 3: normalize base_raw to a canonical schema pointer
73 let canonical = normalize_to_subform_key(base_raw);
74
75 (canonical, final_idx)
76}
77
78/// Normalize any path format to the canonical subform registry key.
79///
80/// The registry stores keys as `"#/field/properties/subfield/properties/…"` — exactly
81/// as produced by the schema `walk()` function. This function converts all supported
82/// formats into that form.
83fn normalize_to_subform_key(path: &str) -> String {
84 // Already a schema pointer — return as-is
85 if path.starts_with("#/") {
86 return path.to_string();
87 }
88
89 // Raw JSON pointer "/foo/properties/bar" → prefix with '#'
90 if path.starts_with('/') {
91 return format!("#{}", path);
92 }
93
94 // Dot-notation: "illustration.product_benefit.riders"
95 // → "#/illustration/properties/product_benefit/properties/riders"
96 crate::jsoneval::path_utils::dot_notation_to_schema_pointer(path)
97}
98
99impl JSONEval {
100 /// Resolves the subform path, allowing aliases like "riders" to match the full
101 /// schema pointer "#/illustration/properties/product_benefit/properties/riders".
102 /// This ensures alias paths and full paths share the same underlying subform store and cache.
103 pub(crate) fn resolve_subform_path_alias(&self, path: &str) -> (String, Option<usize>) {
104 let (mut canonical, idx) = resolve_subform_path(path);
105
106 if !self.subforms.contains_key(&canonical) {
107 let search_suffix = if canonical.starts_with("#/") {
108 format!("/properties/{}", &canonical[2..])
109 } else {
110 format!("/properties/{}", canonical)
111 };
112
113 for k in self.subforms.keys() {
114 if k.ends_with(&search_suffix) || k == &canonical {
115 canonical = k.to_string();
116 break;
117 }
118 }
119 }
120
121 (canonical, idx)
122 }
123
124 /// Execute `f` on the subform at `base_path[idx]` with the parent cache swapped in.
125 ///
126 /// Lifecycle:
127 /// 1. Set `data_value` + `context_value` on the subform's `eval_data`.
128 /// 2. Compute item-level diff for `field_key` → bump `subform_caches[idx].data_versions`.
129 /// 3. `mem::take` parent cache → set `active_item_index = Some(idx)` → swap into subform.
130 /// 4. Execute `f(subform)` → collect result.
131 /// 5. Swap parent cache back out → restore `self.eval_cache`.
132 ///
133 /// This ensures all three operations (evaluate / validate / evaluate_dependents)
134 /// share parent-form Tier-2 cache entries, without duplicating the swap boilerplate.
135 fn with_item_cache_swap<F, T>(
136 &mut self,
137 base_path: &str,
138 idx: usize,
139 data_value: Value,
140 context_value: Value,
141 f: F,
142 ) -> Result<T, String>
143 where
144 F: FnOnce(&mut JSONEval) -> Result<T, String>,
145 {
146 let field_key = base_path
147 .split('/')
148 .next_back()
149 .unwrap_or(base_path)
150 .to_string();
151
152 // Step 1: update subform data and extract item snapshot for targeted diff.
153 // Scoped block releases the mutable borrow on `self.subforms` before we touch
154 // `self.eval_cache` (they are disjoint fields, but keep it explicit).
155 let (old_item_snapshot, new_item_val, subform_item_cache_opt, array_path, item_path) = {
156 let subform = self
157 .subforms
158 .get_mut(base_path)
159 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
160
161 let old_item_snapshot = subform
162 .eval_cache
163 .subform_caches
164 .get(&idx)
165 .map(|c| c.item_snapshot.clone())
166 .unwrap_or(Value::Null);
167
168 subform
169 .eval_data
170 .replace_data_and_context(data_value, context_value);
171 let new_item_val = subform
172 .eval_data
173 .data()
174 .get(&field_key)
175 .cloned()
176 .unwrap_or(Value::Null);
177
178 // INJECT the item into the parent array location within subform's eval_data!
179 // The frontend sometimes only provides the active item root but leaves the
180 // corresponding slot empty or stale in the parent array tree of the wrapper.
181 // Formulas that aggregate over the parent array must see the active item.
182 let data_pointer = crate::jsoneval::path_utils::normalize_to_json_pointer(base_path)
183 .replace("/properties/", "/");
184 let array_path = data_pointer.to_string();
185 let item_path = format!("{}/{}", array_path, idx);
186 subform.eval_data.set(&item_path, new_item_val.clone());
187
188 // Pull out any existing item-scoped entries from the subform's own cache
189 // so they can be merged into the parent cache below.
190 let existing = subform.eval_cache.subform_caches.remove(&idx);
191 (
192 old_item_snapshot,
193 new_item_val,
194 existing,
195 array_path,
196 item_path,
197 )
198 }; // subform borrow released here
199
200 // Unified store fallback: if the subform's own per-item cache has no snapshot for this
201 // index (e.g. this is the first evaluate_subform call after a full evaluate()), treat the
202 // parent's eval_data slot as the canonical baseline. The parent always holds the most
203 // recent array data written by evaluate() or evaluate_dependents(), so using it avoids
204 // treating an already-evaluated item as brand-new and forcing full table re-evaluation.
205 let parent_item = self.eval_data.get(&item_path).cloned();
206 let old_item_snapshot = if old_item_snapshot == Value::Null {
207 parent_item.clone().unwrap_or(Value::Null)
208 } else {
209 old_item_snapshot
210 };
211
212 // An item is "new" only when the parent's eval_data has no entry at the item path.
213 // Using the subform's own snapshot cache as the authority (old_item_snapshot == Null)
214 // is not correct after Step 6 persistence re-seeds the cache: a rider that was
215 // previously evaluate_subform'd would have a snapshot but may still be absent from
216 // the parent array (e.g. new rider scenario after evaluate_dependents_subform).
217 let is_new_item = parent_item.is_none();
218
219 let mut parent_cache = std::mem::take(&mut self.eval_cache);
220 parent_cache.ensure_active_item_cache(idx);
221
222 // Snapshot item versions BEFORE the diff so we can detect only NEW bumps below.
223 // `any_bumped_with_prefix(v > 0)` would return true for historical bumps from prior
224 // calls, causing invalidate_params_tables_for_item to fire on every evaluate_subform
225 // even when no rider data actually changed.
226 let pre_diff_item_versions = parent_cache
227 .subform_caches
228 .get(&idx)
229 .map(|c| c.data_versions.clone());
230
231 if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
232 // Only inherit $params-scoped versions from the parent so that data-path
233 // bumps from other items or previous calls don't contaminate this item's baseline.
234 c.data_versions
235 .merge_from_params(&parent_cache.params_versions);
236 // Diff only the item field to find what changed (skips the 5 MB parent tree).
237 crate::jsoneval::eval_cache::diff_and_update_versions(
238 &mut c.data_versions,
239 &format!("/{}", field_key),
240 &old_item_snapshot,
241 &new_item_val,
242 );
243 c.item_snapshot = new_item_val.clone();
244 }
245 parent_cache.active_item_index = Some(idx);
246
247 // Restore cached entries that lived in the subform's own per-item cache.
248 // Only restore entries whose dependency versions still match the current item
249 // data_versions: if a field changed (e.g. sa bumped), entries that depended on
250 // that field are stale and must not be re-inserted (they would cause false T1 hits).
251 if let Some(subform_item_cache) = subform_item_cache_opt {
252 if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
253 let current_dv = c.data_versions.clone();
254 for (k, v) in subform_item_cache.entries {
255 // Skip if entry already exists (parent-form run may have added a fresher result).
256 if c.entries.contains_key(&k) {
257 continue;
258 }
259 // Validate all dep versions against the current item data_versions.
260 let still_valid = v.dep_versions.iter().all(|(dep_path, &cached_ver)| {
261 let current_ver = if dep_path.starts_with("/$params") {
262 parent_cache.params_versions.get(dep_path)
263 } else {
264 current_dv.get(dep_path)
265 };
266 current_ver == cached_ver
267 });
268 if still_valid {
269 c.entries.insert(k, v);
270 }
271 }
272 }
273 }
274
275 // Insert into the parent eval_data as well (to make the item visible to global formulas on main evaluate).
276 // Only write (and bump version) when the value actually changed: prevents spurious riders-array
277 // version increments on repeated evaluate_subform calls where the rider data is unchanged.
278 let current_at_item_path = self.eval_data.get(&item_path).cloned();
279 if current_at_item_path.as_ref() != Some(&new_item_val) {
280 self.eval_data.set(&item_path, new_item_val.clone());
281 if is_new_item {
282 parent_cache.bump_data_version(&array_path);
283 }
284 }
285
286 // Re-evaluate `$params` tables that depend on subform item paths that changed.
287 // This is required not just for brand-new items, but also whenever a tracked field
288 // (like `riders.sa`) changes value: tables like RIDER_ZLOB_TABLE depend on rider.sa
289 // and must produce updated rows that reflect the new sa before the subform's own
290 // formula evaluation runs (otherwise cached old rows are reused).
291 //
292 // Gate: only re-evaluate tables when at least one item-level path was NEWLY bumped
293 // in this diff pass. Using any_bumped_with_prefix(v > 0) would return true for
294 // historical bumps from prior calls, causing spurious table invalidation every time.
295 let field_prefix = format!("/{}/", field_key);
296 let item_paths_bumped = match &pre_diff_item_versions {
297 None => {
298 // No pre-diff snapshot = cache slot was just created, treat as new
299 parent_cache
300 .subform_caches
301 .get(&idx)
302 .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
303 .unwrap_or(false)
304 }
305 Some(pre) => {
306 // Only count bumps that occurred during this specific diff pass
307 parent_cache
308 .subform_caches
309 .get(&idx)
310 .map(|c| c.data_versions.any_newly_bumped_with_prefix(&field_prefix, pre))
311 .unwrap_or(false)
312 }
313 };
314
315 if is_new_item || item_paths_bumped {
316 // Collect which rider data paths were NEWLY bumped in this diff pass.
317 // When item_paths_bumped = true, the diff detected changes — but we only want to
318 // invalidate tables that ACTUALLY depend on those changed paths. Tables like
319 // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
320 // first_prem), so bumping them forces unnecessary re-evaluation and increments
321 // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
322 let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
323 let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
324 parent_cache.subform_caches.get(&idx).map(|c| {
325 c.data_versions
326 .versions()
327 .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
328 .map(|(k, _)| {
329 // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
330 // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
331 let sub = k.trim_start_matches(&field_prefix);
332 format!("#/{}/properties/{}", field_key, sub)
333 })
334 .collect::<Vec<_>>()
335 })
336 });
337 paths
338 } else {
339 None
340 };
341
342 let params_table_keys: Vec<String> = self
343 .table_metadata
344 .keys()
345 .filter(|k| k.starts_with("#/$params"))
346 .filter(|k| {
347 if is_new_item {
348 return true; // new rider: invalidate all tables
349 }
350 // Only invalidate tables whose declared deps overlap the changed paths.
351 // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
352 // fall back to invalidating all.
353 let Some(ref bumped) = newly_bumped_paths else {
354 return true;
355 };
356 if bumped.is_empty() {
357 return false;
358 }
359 self.dependencies.get(*k).map(|deps| {
360 deps.iter().any(|dep| bumped.iter().any(|b| dep == b || dep.starts_with(b.as_str())))
361 }).unwrap_or(false)
362 })
363 .cloned()
364 .collect();
365 if !params_table_keys.is_empty() {
366 parent_cache.invalidate_params_tables_for_item(idx, ¶ms_table_keys);
367
368 let eval_data_snapshot = self.eval_data.exclusive_clone();
369 for key in ¶ms_table_keys {
370 // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
371 // depend on subform-specific item paths (like `#/riders/...`).
372 // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
373 // and MUST be evaluated by the subform engine to see the subform's current data.
374 // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
375 // and MUST be evaluated by the parent engine to see the full parent array.
376 let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
377 let subform_dep_prefix = format!("#/{}/properties/", field_key);
378 let subform_dep_prefix_short = format!("#/{}/", field_key);
379 deps.iter().any(|dep| {
380 dep.starts_with(&subform_dep_prefix)
381 || dep.starts_with(&subform_dep_prefix_short)
382 })
383 } else {
384 false
385 };
386
387 if depends_on_subform_item {
388 continue;
389 }
390
391 // Evaluate the table using parent's updated data
392 if let Ok(rows) = crate::jsoneval::table_evaluate::evaluate_table(
393 self,
394 key,
395 &eval_data_snapshot,
396 None,
397 ) {
398 if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
399 println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
400 }
401 let result_val = serde_json::Value::Array(rows);
402
403 // Collect external dependencies for this cache entry
404 let mut external_deps = indexmap::IndexSet::new();
405 let pointer_data_prefix =
406 crate::jsoneval::path_utils::normalize_to_json_pointer(key)
407 .replace("/properties/", "/");
408 let pointer_data_prefix_slash = format!("{}/", pointer_data_prefix);
409 if let Some(deps) = self.dependencies.get(key) {
410 for dep in deps {
411 let dep_data_path =
412 crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
413 .replace("/properties/", "/");
414 if dep_data_path != pointer_data_prefix
415 && !dep_data_path.starts_with(&pointer_data_prefix_slash)
416 {
417 external_deps.insert(dep.clone());
418 }
419 }
420 }
421
422 // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
423 // Then the subform can hit it via T2 fallback check.
424 parent_cache.active_item_index = None;
425 parent_cache.store_cache(key, &external_deps, result_val);
426 parent_cache.active_item_index = Some(idx);
427 } else {
428 if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
429 println!("PARENT EVALUATED TABLE {} -> ERROR", key);
430 }
431 }
432 }
433 }
434 }
435
436 // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
437 {
438 let subform = self.subforms.get_mut(base_path).unwrap();
439 std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
440 }
441
442 // Step 4: run the caller-supplied operation.
443 let result = {
444 let subform = self.subforms.get_mut(base_path).unwrap();
445 f(subform)
446 };
447
448 // Step 5: restore parent cache.
449 {
450 let subform = self.subforms.get_mut(base_path).unwrap();
451 std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
452 }
453 parent_cache.active_item_index = None;
454 self.eval_cache = parent_cache;
455
456 // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
457 // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
458 // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
459 // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
460 {
461 let subform = self.subforms.get_mut(base_path).unwrap();
462 if let Some(item_cache) = self.eval_cache.subform_caches.get(&idx) {
463 subform
464 .eval_cache
465 .subform_caches
466 .insert(idx, item_cache.clone());
467 }
468 }
469
470 result
471 }
472
473 /// Evaluate a subform identified by `subform_path`.
474 ///
475 /// The path may include a trailing item index to bind the evaluation to a specific
476 /// array element and enable the two-tier cache-swap strategy automatically:
477 ///
478 /// ```text
479 /// // Evaluate riders item 1 with index-aware cache
480 /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
481 /// ```
482 ///
483 /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
484 pub fn evaluate_subform(
485 &mut self,
486 subform_path: &str,
487 data: &str,
488 context: Option<&str>,
489 paths: Option<&[String]>,
490 token: Option<&CancellationToken>,
491 ) -> Result<(), String> {
492 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
493 if let Some(idx) = idx_opt {
494 self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
495 } else {
496 let subform = self
497 .subforms
498 .get_mut(base_path.as_ref() as &str)
499 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
500 subform.evaluate(data, context, paths, token)
501 }
502 }
503
504 /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
505 fn evaluate_subform_item(
506 &mut self,
507 base_path: &str,
508 idx: usize,
509 data: &str,
510 context: Option<&str>,
511 paths: Option<&[String]>,
512 token: Option<&CancellationToken>,
513 ) -> Result<(), String> {
514 let data_value = crate::jsoneval::json_parser::parse_json_str(data)
515 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
516 let context_value = if let Some(ctx) = context {
517 crate::jsoneval::json_parser::parse_json_str(ctx)
518 .map_err(|e| format!("Failed to parse subform context: {}", e))?
519 } else {
520 Value::Object(serde_json::Map::new())
521 };
522
523 self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
524 sf.evaluate_internal_pre_diffed(paths, token)
525 })
526 }
527
528 /// Validate subform data against its schema rules.
529 ///
530 /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
531 /// is present the parent cache is swapped in first, ensuring rule evaluations that
532 /// depend on `$params` tables share already-computed parent-form results.
533 pub fn validate_subform(
534 &mut self,
535 subform_path: &str,
536 data: &str,
537 context: Option<&str>,
538 paths: Option<&[String]>,
539 token: Option<&CancellationToken>,
540 ) -> Result<crate::ValidationResult, String> {
541 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
542 if let Some(idx) = idx_opt {
543 let data_value = crate::jsoneval::json_parser::parse_json_str(data)
544 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
545 let context_value = if let Some(ctx) = context {
546 crate::jsoneval::json_parser::parse_json_str(ctx)
547 .map_err(|e| format!("Failed to parse subform context: {}", e))?
548 } else {
549 Value::Object(serde_json::Map::new())
550 };
551 let data_for_validation = data_value.clone();
552 self.with_item_cache_swap(
553 base_path.as_ref(),
554 idx,
555 data_value,
556 context_value,
557 move |sf| {
558 // Warm the evaluation cache before running rule checks.
559 sf.evaluate_internal_pre_diffed(paths, token)?;
560 sf.validate_pre_set(data_for_validation, paths, token)
561 },
562 )
563 } else {
564 let subform = self
565 .subforms
566 .get_mut(base_path.as_ref() as &str)
567 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
568 subform.validate(data, context, paths, token)
569 }
570 }
571
572 /// Evaluate dependents in a subform when a field changes.
573 ///
574 /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
575 /// is present the parent cache is swapped in, so dependent evaluation runs with
576 /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
577 pub fn evaluate_dependents_subform(
578 &mut self,
579 subform_path: &str,
580 changed_paths: &[String],
581 data: Option<&str>,
582 context: Option<&str>,
583 re_evaluate: bool,
584 token: Option<&CancellationToken>,
585 canceled_paths: Option<&mut Vec<String>>,
586 include_subforms: bool,
587 ) -> Result<Value, String> {
588 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
589 if let Some(idx) = idx_opt {
590 // Parse or snapshot data for the swap / diff computation.
591 let (data_value, context_value) = if let Some(data_str) = data {
592 let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
593 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
594 let cv = 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 (dv, cv)
601 } else {
602 // No new data provided — snapshot current subform state so diff is a no-op.
603 let subform = self
604 .subforms
605 .get(base_path.as_ref() as &str)
606 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
607 let dv = subform.eval_data.snapshot_data_clone();
608 (dv, Value::Object(serde_json::Map::new()))
609 };
610 self.with_item_cache_swap(base_path.as_ref(), idx, data_value, context_value, |sf| {
611 // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
612 sf.evaluate_dependents(
613 changed_paths,
614 None,
615 None,
616 re_evaluate,
617 token,
618 None,
619 include_subforms,
620 )
621 })
622 } else {
623 let subform = self
624 .subforms
625 .get_mut(base_path.as_ref() as &str)
626 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
627 subform.evaluate_dependents(
628 changed_paths,
629 data,
630 context,
631 re_evaluate,
632 token,
633 canceled_paths,
634 include_subforms,
635 )
636 }
637 }
638
639 /// Resolve layout for subform.
640 pub fn resolve_layout_subform(
641 &mut self,
642 subform_path: &str,
643 evaluate: bool,
644 ) -> Result<(), String> {
645 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
646 let subform = self
647 .subforms
648 .get_mut(base_path.as_ref() as &str)
649 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
650 let _ = subform.resolve_layout(evaluate);
651 Ok(())
652 }
653
654 /// Get evaluated schema from subform.
655 pub fn get_evaluated_schema_subform(
656 &mut self,
657 subform_path: &str,
658 resolve_layout: bool,
659 ) -> Value {
660 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
661 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
662 subform.get_evaluated_schema(resolve_layout)
663 } else {
664 Value::Null
665 }
666 }
667
668 /// Get schema value from subform in nested object format (all .value fields).
669 pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
670 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
671 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
672 subform.get_schema_value()
673 } else {
674 Value::Null
675 }
676 }
677
678 /// Get schema values from subform as a flat array of path-value pairs.
679 pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
680 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
681 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
682 subform.get_schema_value_array()
683 } else {
684 Value::Array(vec![])
685 }
686 }
687
688 /// Get schema values from subform as a flat object with dotted path keys.
689 pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
690 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
691 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
692 subform.get_schema_value_object()
693 } else {
694 Value::Object(serde_json::Map::new())
695 }
696 }
697
698 /// Get evaluated schema without $params from subform.
699 pub fn get_evaluated_schema_without_params_subform(
700 &mut self,
701 subform_path: &str,
702 resolve_layout: bool,
703 ) -> Value {
704 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
705 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
706 subform.get_evaluated_schema_without_params(resolve_layout)
707 } else {
708 Value::Null
709 }
710 }
711
712 /// Get evaluated schema by specific path from subform.
713 pub fn get_evaluated_schema_by_path_subform(
714 &mut self,
715 subform_path: &str,
716 schema_path: &str,
717 skip_layout: bool,
718 ) -> Option<Value> {
719 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
720 self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
721 sf.get_evaluated_schema_by_paths(
722 &[schema_path.to_string()],
723 skip_layout,
724 Some(ReturnFormat::Nested),
725 )
726 })
727 }
728
729 /// Get evaluated schema by multiple paths from subform.
730 pub fn get_evaluated_schema_by_paths_subform(
731 &mut self,
732 subform_path: &str,
733 schema_paths: &[String],
734 skip_layout: bool,
735 format: Option<crate::ReturnFormat>,
736 ) -> Value {
737 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
738 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
739 subform.get_evaluated_schema_by_paths(
740 schema_paths,
741 skip_layout,
742 Some(format.unwrap_or(ReturnFormat::Flat)),
743 )
744 } else {
745 match format.unwrap_or_default() {
746 crate::ReturnFormat::Array => Value::Array(vec![]),
747 _ => Value::Object(serde_json::Map::new()),
748 }
749 }
750 }
751
752 /// Get schema by specific path from subform.
753 pub fn get_schema_by_path_subform(
754 &self,
755 subform_path: &str,
756 schema_path: &str,
757 ) -> Option<Value> {
758 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
759 self.subforms
760 .get(base_path.as_ref() as &str)
761 .and_then(|sf| sf.get_schema_by_path(schema_path))
762 }
763
764 /// Get schema by multiple paths from subform.
765 pub fn get_schema_by_paths_subform(
766 &self,
767 subform_path: &str,
768 schema_paths: &[String],
769 format: Option<crate::ReturnFormat>,
770 ) -> 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_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
774 } else {
775 match format.unwrap_or_default() {
776 crate::ReturnFormat::Array => Value::Array(vec![]),
777 _ => Value::Object(serde_json::Map::new()),
778 }
779 }
780 }
781
782 /// Get list of available subform paths.
783 pub fn get_subform_paths(&self) -> Vec<String> {
784 self.subforms.keys().cloned().collect()
785 }
786
787 /// Check if a subform exists at the given path.
788 pub fn has_subform(&self, subform_path: &str) -> bool {
789 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
790 self.subforms.contains_key(base_path.as_ref() as &str)
791 }
792}