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| {
311 c.data_versions
312 .any_newly_bumped_with_prefix(&field_prefix, pre)
313 })
314 .unwrap_or(false)
315 }
316 };
317
318 if is_new_item || item_paths_bumped {
319 // Collect which rider data paths were NEWLY bumped in this diff pass.
320 // When item_paths_bumped = true, the diff detected changes — but we only want to
321 // invalidate tables that ACTUALLY depend on those changed paths. Tables like
322 // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
323 // first_prem), so bumping them forces unnecessary re-evaluation and increments
324 // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
325 let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
326 let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
327 parent_cache.subform_caches.get(&idx).map(|c| {
328 c.data_versions
329 .versions()
330 .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
331 .map(|(k, _)| {
332 // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
333 // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
334 let sub = k.trim_start_matches(&field_prefix);
335 format!("#/{}/properties/{}", field_key, sub)
336 })
337 .collect::<Vec<_>>()
338 })
339 });
340 paths
341 } else {
342 None
343 };
344
345 let params_table_keys: Vec<String> = self
346 .table_metadata
347 .keys()
348 .filter(|k| k.starts_with("#/$params"))
349 .filter(|k| {
350 if is_new_item {
351 return true; // new rider: invalidate all tables
352 }
353 // Only invalidate tables whose declared deps overlap the changed paths.
354 // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
355 // fall back to invalidating all.
356 let Some(ref bumped) = newly_bumped_paths else {
357 return true;
358 };
359 if bumped.is_empty() {
360 return false;
361 }
362 self.dependencies
363 .get(*k)
364 .map(|deps| {
365 deps.iter().any(|dep| {
366 bumped
367 .iter()
368 .any(|b| dep == b || dep.starts_with(b.as_str()))
369 })
370 })
371 .unwrap_or(false)
372 })
373 .cloned()
374 .collect();
375 if !params_table_keys.is_empty() {
376 parent_cache.invalidate_params_tables_for_item(idx, ¶ms_table_keys);
377
378 let eval_data_snapshot = self.eval_data.exclusive_clone();
379 for key in ¶ms_table_keys {
380 // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
381 // depend on subform-specific item paths (like `#/riders/...`).
382 // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
383 // and MUST be evaluated by the subform engine to see the subform's current data.
384 // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
385 // and MUST be evaluated by the parent engine to see the full parent array.
386 let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
387 let subform_dep_prefix = format!("#/{}/properties/", field_key);
388 let subform_dep_prefix_short = format!("#/{}/", field_key);
389 deps.iter().any(|dep| {
390 dep.starts_with(&subform_dep_prefix)
391 || dep.starts_with(&subform_dep_prefix_short)
392 })
393 } else {
394 false
395 };
396
397 if depends_on_subform_item {
398 continue;
399 }
400
401 // Evaluate the table using parent's updated data
402 if let Ok(rows) = crate::jsoneval::table_evaluate::evaluate_table(
403 self,
404 key,
405 &eval_data_snapshot,
406 None,
407 ) {
408 if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
409 println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
410 }
411 let result_val = serde_json::Value::Array(rows);
412
413 // Collect external dependencies for this cache entry
414 let mut external_deps = indexmap::IndexSet::new();
415 let pointer_data_prefix =
416 crate::jsoneval::path_utils::normalize_to_json_pointer(key)
417 .replace("/properties/", "/");
418 let pointer_data_prefix_slash = format!("{}/", pointer_data_prefix);
419 if let Some(deps) = self.dependencies.get(key) {
420 for dep in deps {
421 let dep_data_path =
422 crate::jsoneval::path_utils::normalize_to_json_pointer(dep)
423 .replace("/properties/", "/");
424 if dep_data_path != pointer_data_prefix
425 && !dep_data_path.starts_with(&pointer_data_prefix_slash)
426 {
427 external_deps.insert(dep.clone());
428 }
429 }
430 }
431
432 // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
433 // Then the subform can hit it via T2 fallback check.
434 parent_cache.active_item_index = None;
435 parent_cache.store_cache(key, &external_deps, result_val);
436 parent_cache.active_item_index = Some(idx);
437 } else {
438 if std::env::var("JSONEVAL_DEBUG_CACHE").is_ok() {
439 println!("PARENT EVALUATED TABLE {} -> ERROR", key);
440 }
441 }
442 }
443 }
444 }
445
446 // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
447 {
448 let subform = self.subforms.get_mut(base_path).unwrap();
449 std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
450 }
451
452 // Step 4: run the caller-supplied operation.
453 let result = {
454 let subform = self.subforms.get_mut(base_path).unwrap();
455 f(subform)
456 };
457
458 // Step 5: restore parent cache.
459 {
460 let subform = self.subforms.get_mut(base_path).unwrap();
461 std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
462 }
463 parent_cache.active_item_index = None;
464 self.eval_cache = parent_cache;
465
466 // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
467 // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
468 // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
469 // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
470 {
471 let subform = self.subforms.get_mut(base_path).unwrap();
472 if let Some(item_cache) = self.eval_cache.subform_caches.get(&idx) {
473 subform
474 .eval_cache
475 .subform_caches
476 .insert(idx, item_cache.clone());
477 }
478 }
479
480 result
481 }
482
483 /// Evaluate a subform identified by `subform_path`.
484 ///
485 /// The path may include a trailing item index to bind the evaluation to a specific
486 /// array element and enable the two-tier cache-swap strategy automatically:
487 ///
488 /// ```text
489 /// // Evaluate riders item 1 with index-aware cache
490 /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
491 /// ```
492 ///
493 /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
494 pub fn evaluate_subform(
495 &mut self,
496 subform_path: &str,
497 data: &str,
498 context: Option<&str>,
499 paths: Option<&[String]>,
500 token: Option<&CancellationToken>,
501 ) -> Result<(), String> {
502 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
503 if let Some(idx) = idx_opt {
504 self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
505 } else {
506 let subform = self
507 .subforms
508 .get_mut(base_path.as_ref() as &str)
509 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
510 subform.evaluate(data, context, paths, token)
511 }
512 }
513
514 /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
515 fn evaluate_subform_item(
516 &mut self,
517 base_path: &str,
518 idx: usize,
519 data: &str,
520 context: Option<&str>,
521 paths: Option<&[String]>,
522 token: Option<&CancellationToken>,
523 ) -> Result<(), String> {
524 let data_value = crate::jsoneval::json_parser::parse_json_str(data)
525 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
526 let context_value = if let Some(ctx) = context {
527 crate::jsoneval::json_parser::parse_json_str(ctx)
528 .map_err(|e| format!("Failed to parse subform context: {}", e))?
529 } else {
530 Value::Object(serde_json::Map::new())
531 };
532
533 self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
534 sf.evaluate_internal_pre_diffed(paths, token)
535 })
536 }
537
538 /// Validate subform data against its schema rules.
539 ///
540 /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
541 /// is present the parent cache is swapped in first, ensuring rule evaluations that
542 /// depend on `$params` tables share already-computed parent-form results.
543 pub fn validate_subform(
544 &mut self,
545 subform_path: &str,
546 data: &str,
547 context: Option<&str>,
548 paths: Option<&[String]>,
549 token: Option<&CancellationToken>,
550 ) -> Result<crate::ValidationResult, String> {
551 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
552 if let Some(idx) = idx_opt {
553 let data_value = crate::jsoneval::json_parser::parse_json_str(data)
554 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
555 let context_value = if let Some(ctx) = context {
556 crate::jsoneval::json_parser::parse_json_str(ctx)
557 .map_err(|e| format!("Failed to parse subform context: {}", e))?
558 } else {
559 Value::Object(serde_json::Map::new())
560 };
561 let data_for_validation = data_value.clone();
562 self.with_item_cache_swap(
563 base_path.as_ref(),
564 idx,
565 data_value,
566 context_value,
567 move |sf| {
568 // Warm the evaluation cache before running rule checks.
569 sf.evaluate_internal_pre_diffed(paths, token)?;
570 sf.validate_pre_set(data_for_validation, paths, token)
571 },
572 )
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.validate(data, context, paths, token)
579 }
580 }
581
582 /// Evaluate dependents in a subform when a field changes.
583 ///
584 /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
585 /// is present the parent cache is swapped in, so dependent evaluation runs with
586 /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
587 pub fn evaluate_dependents_subform(
588 &mut self,
589 subform_path: &str,
590 changed_paths: &[String],
591 data: Option<&str>,
592 context: Option<&str>,
593 re_evaluate: bool,
594 token: Option<&CancellationToken>,
595 canceled_paths: Option<&mut Vec<String>>,
596 include_subforms: bool,
597 ) -> Result<Value, String> {
598 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
599 if let Some(idx) = idx_opt {
600 // Parse or snapshot data for the swap / diff computation.
601 let (data_value, context_value) = if let Some(data_str) = data {
602 let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
603 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
604 let cv = if let Some(ctx) = context {
605 crate::jsoneval::json_parser::parse_json_str(ctx)
606 .map_err(|e| format!("Failed to parse subform context: {}", e))?
607 } else {
608 Value::Object(serde_json::Map::new())
609 };
610 (dv, cv)
611 } else {
612 // No new data provided — snapshot current subform state so diff is a no-op.
613 let subform = self
614 .subforms
615 .get(base_path.as_ref() as &str)
616 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
617 let dv = subform.eval_data.snapshot_data_clone();
618 (dv, Value::Object(serde_json::Map::new()))
619 };
620 self.with_item_cache_swap(base_path.as_ref(), idx, data_value, context_value, |sf| {
621 // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
622 sf.evaluate_dependents(
623 changed_paths,
624 None,
625 None,
626 re_evaluate,
627 token,
628 None,
629 include_subforms,
630 )
631 })
632 } else {
633 let subform = self
634 .subforms
635 .get_mut(base_path.as_ref() as &str)
636 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
637 subform.evaluate_dependents(
638 changed_paths,
639 data,
640 context,
641 re_evaluate,
642 token,
643 canceled_paths,
644 include_subforms,
645 )
646 }
647 }
648
649 /// Resolve layout for subform.
650 pub fn resolve_layout_subform(
651 &mut self,
652 subform_path: &str,
653 evaluate: bool,
654 ) -> Result<(), String> {
655 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
656 let subform = self
657 .subforms
658 .get_mut(base_path.as_ref() as &str)
659 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
660 let _ = subform.resolve_layout(evaluate);
661 Ok(())
662 }
663
664 /// Get evaluated schema from subform.
665 pub fn get_evaluated_schema_subform(
666 &mut self,
667 subform_path: &str,
668 resolve_layout: bool,
669 ) -> 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_evaluated_schema(resolve_layout)
673 } else {
674 Value::Null
675 }
676 }
677
678 /// Get schema value from subform in nested object format (all .value fields).
679 pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
680 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
681 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
682 subform.get_schema_value()
683 } else {
684 Value::Null
685 }
686 }
687
688 /// Get schema values from subform as a flat array of path-value pairs.
689 pub fn get_schema_value_array_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_array()
693 } else {
694 Value::Array(vec![])
695 }
696 }
697
698 /// Get schema values from subform as a flat object with dotted path keys.
699 pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
700 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
701 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
702 subform.get_schema_value_object()
703 } else {
704 Value::Object(serde_json::Map::new())
705 }
706 }
707
708 /// Get evaluated schema without $params from subform.
709 pub fn get_evaluated_schema_without_params_subform(
710 &mut self,
711 subform_path: &str,
712 resolve_layout: bool,
713 ) -> Value {
714 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
715 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
716 subform.get_evaluated_schema_without_params(resolve_layout)
717 } else {
718 Value::Null
719 }
720 }
721
722 /// Get evaluated schema by specific path from subform.
723 pub fn get_evaluated_schema_by_path_subform(
724 &mut self,
725 subform_path: &str,
726 schema_path: &str,
727 skip_layout: bool,
728 ) -> Option<Value> {
729 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
730 self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
731 sf.get_evaluated_schema_by_paths(
732 &[schema_path.to_string()],
733 skip_layout,
734 Some(ReturnFormat::Nested),
735 )
736 })
737 }
738
739 /// Get evaluated schema by multiple paths from subform.
740 pub fn get_evaluated_schema_by_paths_subform(
741 &mut self,
742 subform_path: &str,
743 schema_paths: &[String],
744 skip_layout: bool,
745 format: Option<crate::ReturnFormat>,
746 ) -> Value {
747 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
748 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
749 subform.get_evaluated_schema_by_paths(
750 schema_paths,
751 skip_layout,
752 Some(format.unwrap_or(ReturnFormat::Flat)),
753 )
754 } else {
755 match format.unwrap_or_default() {
756 crate::ReturnFormat::Array => Value::Array(vec![]),
757 _ => Value::Object(serde_json::Map::new()),
758 }
759 }
760 }
761
762 /// Get schema by specific path from subform.
763 pub fn get_schema_by_path_subform(
764 &self,
765 subform_path: &str,
766 schema_path: &str,
767 ) -> Option<Value> {
768 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
769 self.subforms
770 .get(base_path.as_ref() as &str)
771 .and_then(|sf| sf.get_schema_by_path(schema_path))
772 }
773
774 /// Get schema by multiple paths from subform.
775 pub fn get_schema_by_paths_subform(
776 &self,
777 subform_path: &str,
778 schema_paths: &[String],
779 format: Option<crate::ReturnFormat>,
780 ) -> 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_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
784 } else {
785 match format.unwrap_or_default() {
786 crate::ReturnFormat::Array => Value::Array(vec![]),
787 _ => Value::Object(serde_json::Map::new()),
788 }
789 }
790 }
791
792 /// Get list of available subform paths.
793 pub fn get_subform_paths(&self) -> Vec<String> {
794 self.subforms.keys().cloned().collect()
795 }
796
797 /// Check if a subform exists at the given path.
798 pub fn has_subform(&self, subform_path: &str) -> bool {
799 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
800 self.subforms.contains_key(base_path.as_ref() as &str)
801 }
802}