json_eval_rs/jsoneval/subform_methods.rs
1// Subform methods for isolated array field evaluation
2
3use super::JSONEval;
4use crate::jsoneval::cancellation::CancellationToken;
5use crate::jsoneval::eval_data::EvalData;
6use crate::jsoneval::types::{ResolvedLayoutResult, ReturnFormat};
7use serde_json::Value;
8
9/// Decomposes a subform path that may optionally include a trailing item index,
10/// and normalizes the base portion to the canonical schema-pointer key used in the
11/// subform registry (e.g. `"#/illustration/properties/product_benefit/properties/riders"`).
12///
13/// Accepted formats for the **base** portion:
14/// - Schema pointer: `"#/illustration/properties/product_benefit/properties/riders"`
15/// - Raw JSON pointer: `"/illustration/properties/product_benefit/properties/riders"`
16/// - Dot notation: `"illustration.product_benefit.riders"`
17///
18/// Accepted formats for the **index** suffix (stripped before lookup):
19/// - Trailing dot-index: `"…riders.1"`
20/// - Trailing slash-index: `"…riders/1"`
21/// - Bracket array index: `"…riders[1]"` or `"…riders[1]."`
22///
23/// Returns `(canonical_base_path, optional_index)`.
24fn resolve_subform_path(path: &str) -> (String, Option<usize>) {
25 // --- Step 1: strip a trailing bracket array index, e.g. "riders[2]" or "riders[2]."
26 let path = path.trim_end_matches('.');
27 let (path, bracket_idx) = if let Some(bracket_start) = path.rfind('[') {
28 let after = &path[bracket_start + 1..];
29 if let Some(bracket_end) = after.find(']') {
30 let idx_str = &after[..bracket_end];
31 if let Ok(idx) = idx_str.parse::<usize>() {
32 // strip everything from '[' onward (including any trailing '.')
33 let base = path[..bracket_start].trim_end_matches('.');
34 (base, Some(idx))
35 } else {
36 (path, None)
37 }
38 } else {
39 (path, None)
40 }
41 } else {
42 (path, None)
43 };
44
45 // --- Step 2: strip a trailing numeric segment (dot or slash separated)
46 let (base_raw, trailing_idx) = if bracket_idx.is_none() {
47 // Check dot-notation trailing index: "foo.bar.2"
48 if let Some(dot_pos) = path.rfind('.') {
49 let suffix = &path[dot_pos + 1..];
50 if let Ok(idx) = suffix.parse::<usize>() {
51 (&path[..dot_pos], Some(idx))
52 } else {
53 (path, None)
54 }
55 }
56 // Check JSON-pointer trailing index: "#/foo/bar/0" or "/foo/bar/0"
57 else if let Some(slash_pos) = path.rfind('/') {
58 let suffix = &path[slash_pos + 1..];
59 if let Ok(idx) = suffix.parse::<usize>() {
60 (&path[..slash_pos], Some(idx))
61 } else {
62 (path, None)
63 }
64 } else {
65 (path, None)
66 }
67 } else {
68 (path, None)
69 };
70
71 let final_idx = bracket_idx.or(trailing_idx);
72
73 // --- Step 3: normalize base_raw to a canonical schema pointer
74 let canonical = normalize_to_subform_key(base_raw);
75
76 (canonical, final_idx)
77}
78
79/// Normalize any path format to the canonical subform registry key.
80///
81/// The registry stores keys as `"#/field/properties/subfield/properties/…"` — exactly
82/// as produced by the schema `walk()` function. This function converts all supported
83/// formats into that form.
84fn normalize_to_subform_key(path: &str) -> String {
85 // Already a schema pointer — return as-is
86 if path.starts_with("#/") {
87 return path.to_string();
88 }
89
90 // Raw JSON pointer "/foo/properties/bar" → prefix with '#'
91 if path.starts_with('/') {
92 return format!("#{}", path);
93 }
94
95 // Dot-notation: "illustration.product_benefit.riders"
96 // → "#/illustration/properties/product_benefit/properties/riders"
97 crate::jsoneval::path_utils::dot_notation_to_schema_pointer(path)
98}
99
100impl JSONEval {
101 /// Resolves the subform path, allowing aliases like "riders" to match the full
102 /// schema pointer "#/illustration/properties/product_benefit/properties/riders".
103 /// This ensures alias paths and full paths share the same underlying subform store and cache.
104 pub(crate) fn resolve_subform_path_alias(&self, path: &str) -> (String, Option<usize>) {
105 let (mut canonical, idx) = resolve_subform_path(path);
106
107 if !self.subforms.contains_key(&canonical) {
108 let search_suffix = if canonical.starts_with("#/") {
109 format!("/properties/{}", &canonical[2..])
110 } else {
111 format!("/properties/{}", canonical)
112 };
113
114 for k in self.subforms.keys() {
115 if k.ends_with(&search_suffix) || k == &canonical {
116 canonical = k.to_string();
117 break;
118 }
119 }
120 }
121
122 (canonical, idx)
123 }
124
125 /// Execute `f` on the subform at `base_path[idx]` with the parent cache swapped in.
126 ///
127 /// Lifecycle:
128 /// 1. Set `data_value` + `context_value` on the subform's `eval_data`.
129 /// 2. Compute item-level diff for `field_key` → bump `subform_caches[idx].data_versions`.
130 /// 3. `mem::take` parent cache → set `active_item_index = Some(idx)` → swap into subform.
131 /// 4. Execute `f(subform)` → collect result.
132 /// 5. Swap parent cache back out → restore `self.eval_cache`.
133 ///
134 /// This ensures all three operations (evaluate / validate / evaluate_dependents)
135 /// share parent-form Tier-2 cache entries, without duplicating the swap boilerplate.
136 fn with_item_cache_swap<F, T>(
137 &mut self,
138 base_path: &str,
139 idx: usize,
140 data_value: Value,
141 context_value: Value,
142 f: F,
143 ) -> Result<T, String>
144 where
145 F: FnOnce(&mut JSONEval) -> Result<T, String>,
146 {
147 let original_field_key = base_path
148 .split('/')
149 .next_back()
150 .unwrap_or(base_path)
151 .to_string();
152
153 let schema_pointer = if base_path.starts_with("#/") {
154 &base_path[1..]
155 } else if base_path.starts_with('#') {
156 &base_path[1..]
157 } else {
158 base_path
159 };
160
161 let root_key =
162 crate::jsoneval::path_utils::get_value_by_pointer(&self.schema, schema_pointer)
163 .and_then(|node| node.get("itemsRootKey"))
164 .and_then(|v| v.as_str())
165 .unwrap_or(&original_field_key)
166 .to_string();
167
168 let array_path =
169 crate::jsoneval::path_utils::schema_path_to_data_pointer(base_path).into_owned();
170 let item_path = format!("{}/{}", array_path, idx);
171 let full_parent_payload = data_value.pointer(&array_path).is_some();
172 let payload_has_parent_context = data_value
173 .as_object()
174 .map(|map| map.keys().any(|key| key != &root_key))
175 .unwrap_or(false);
176
177 // Normalize both public payload shapes into one active item before scope setup.
178 let normalized_item = if full_parent_payload {
179 data_value
180 .pointer(&item_path)
181 .cloned()
182 .or_else(|| data_value.get(&root_key).cloned())
183 } else {
184 data_value.get(&root_key).cloned()
185 }
186 .ok_or_else(|| {
187 format!(
188 "Invalid indexed subform payload for {base_path}[{idx}]: expected active item at {item_path} or wrapper root {root_key}"
189 )
190 })?;
191
192 // Step 1: update subform data and extract item snapshot for targeted diff.
193 // Scoped block releases the mutable borrow on `self.subforms` before we touch
194 // `self.eval_cache` (they are disjoint fields, but keep it explicit).
195 let (old_item_snapshot, new_item_val, subform_item_cache_opt) = {
196 let subform = self
197 .subforms
198 .get_mut(base_path)
199 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
200
201 let old_item_snapshot = subform
202 .eval_cache
203 .subform_caches
204 .get(&idx)
205 .map(|c| c.item_snapshot.clone())
206 .unwrap_or(Value::Null);
207
208 // Retain parent evaluator's schema-owned `$params`, then merge a full
209 // payload's parent fields over it. Item wrappers supply only active item.
210 let mut scoped_data = EvalData::new(self.eval_data.snapshot_data_clone());
211 if full_parent_payload || payload_has_parent_context {
212 scoped_data.replace_data_and_context(data_value.clone(), context_value.clone());
213 }
214 scoped_data.set(&item_path, normalized_item.clone());
215 let canonical_parent = scoped_data.snapshot_data_clone();
216 let scope = crate::jsoneval::subform_scope::SubformScope::new(
217 base_path,
218 &array_path,
219 Some(idx),
220 );
221 let mut scoped_view = scope.evaluation_view(&canonical_parent);
222 if let Some(view) = scoped_view.as_object_mut() {
223 view.insert("$context".to_string(), context_value.clone());
224 }
225 subform.eval_data = EvalData::new(scoped_view);
226 let new_item_val = normalized_item.clone();
227
228 // Pull out any existing item-scoped entries from the subform's own cache
229 // so they can be merged into the parent cache below.
230 let existing = subform.eval_cache.subform_caches.remove(&idx);
231 (old_item_snapshot, new_item_val, existing)
232 }; // subform borrow released here
233
234 // Unified store fallback: if the subform's own per-item cache has no snapshot for this
235 // index (e.g. this is the first evaluate_subform call after a full evaluate()), treat the
236 // parent's eval_data slot as the canonical baseline. The parent always holds the most
237 // recent array data written by evaluate() or evaluate_dependents(), so using it avoids
238 // treating an already-evaluated item as brand-new and forcing full table re-evaluation.
239 let parent_item = self.eval_data.get(&item_path).cloned();
240 let old_item_snapshot = if old_item_snapshot == Value::Null {
241 parent_item.clone().unwrap_or(Value::Null)
242 } else {
243 old_item_snapshot
244 };
245
246 // An item is "new" only when the parent's eval_data has no entry at the item path.
247 // Using the subform's own snapshot cache as the authority (old_item_snapshot == Null)
248 // is not correct after Step 6 persistence re-seeds the cache: a rider that was
249 // previously evaluate_subform'd would have a snapshot but may still be absent from
250 // the parent array (e.g. new rider scenario after evaluate_dependents_subform).
251 let is_new_item = parent_item.is_none();
252
253 let mut parent_cache = std::mem::take(&mut self.eval_cache);
254 if full_parent_payload {
255 let old_parent_data = self.eval_data.snapshot_data_clone();
256 self.eval_data
257 .replace_data_and_context(data_value.clone(), context_value.clone());
258 let new_parent_data = self.eval_data.snapshot_data_clone();
259 crate::jsoneval::eval_cache::diff_and_update_versions(
260 &mut parent_cache.data_versions,
261 "",
262 &old_parent_data,
263 &new_parent_data,
264 "sync_full_subform_payload",
265 );
266 }
267 parent_cache.ensure_active_item_cache(idx);
268
269 if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
270 // Parent dependency paths use absolute form pointers, while item formulas use
271 // `/riders/...`; merging parent versions invalidates parent-driven conditions without
272 // cross-item contamination. Item-local bumps remain isolated under `/riders/...`.
273 c.data_versions
274 .merge_excluding_prefix(&parent_cache.data_versions, &format!("/{root_key}/"));
275 c.data_versions
276 .merge_from_params(&parent_cache.params_versions);
277
278 // Merge the durable item history before diffing. Diffing first can reuse a version
279 // number held by a cached entry (false → true both appear as version 1), leaving an
280 // outdated condition/value cache entry valid. Historical versions form the diff base.
281 if let Some(subform_item_cache) = &subform_item_cache_opt {
282 c.data_versions
283 .merge_from(&subform_item_cache.data_versions);
284 }
285 }
286
287 // Snapshot item versions BEFORE the diff so we can detect only NEW bumps below.
288 // `any_bumped_with_prefix(v > 0)` would return true for historical bumps from prior
289 // calls, causing invalidate_params_tables_for_item to fire on every evaluate_subform
290 // even when no rider data actually changed.
291 let pre_diff_item_versions = parent_cache
292 .subform_caches
293 .get(&idx)
294 .map(|c| c.data_versions.clone());
295
296 if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
297 // Diff only the item field to find what changed (skips the 5 MB parent tree).
298 crate::jsoneval::eval_cache::diff_and_update_versions(
299 &mut c.data_versions,
300 &format!("/{}", root_key),
301 &old_item_snapshot,
302 &new_item_val,
303 "with_item_cache_swap_diff_and_update_versions",
304 );
305 c.item_snapshot = new_item_val.clone();
306 }
307
308 // Propagate paths NEWLY bumped by this diff into parent_cache.data_versions so that
309 // check_table_cache (which validates T2 global entries against self.data_versions only)
310 // correctly detects changes to rider fields like `sa`, `code`, etc.
311 //
312 // Without this, a field changed via evaluate_dependents_subform (e.g. sa: 0 → 200M)
313 // only bumps the per-item tracker. The T2 entry for RIDER_ZLOB_TABLE (cached with sa=0)
314 // still looks valid when validated against self.data_versions → stale rows → first_prem=0.
315 //
316 // We use pre_diff_item_versions as the baseline so only NEW bumps from THIS diff pass
317 // are propagated, NOT historical bumps accumulated by prior evaluate_subform calls.
318 // This prevents the regression where run_subform_pass sees stale per-rider bumps
319 // and erroneously re-evaluates expensive tables (RIDER_ZLOB_TABLE etc.) for every rider.
320 {
321 let item_field_prefix = format!("/{}/", root_key);
322 if let (Some(ref pre), Some(c)) = (
323 &pre_diff_item_versions,
324 parent_cache.subform_caches.get(&idx),
325 ) {
326 let newly_bumped: Vec<String> = c
327 .data_versions
328 .versions()
329 .filter(|(k, &v)| k.starts_with(&item_field_prefix) && v > pre.get(k))
330 .map(|(k, _)| k.to_string())
331 .collect();
332 if !newly_bumped.is_empty() {
333 for k in newly_bumped {
334 parent_cache
335 .data_versions
336 .bump(&k, "propagate_newly_bumped");
337 }
338 parent_cache.eval_generation += 1;
339 }
340 }
341 }
342
343 parent_cache.active_item_index = Some(idx);
344
345 // Restore cached entries that lived in the subform's own per-item cache.
346 // Only restore entries whose dependency versions still match the current item
347 // data_versions: if a field changed (e.g. sa bumped), entries that depended on
348 // that field are stale and must not be re-inserted (they would cause false T1 hits).
349 if let Some(subform_item_cache) = subform_item_cache_opt {
350 if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
351 // Merge historical data_versions from the prior subform item cache BEFORE
352 // computing current_dv. The fresh item cache (ensure_active_item_cache) only
353 // has paths bumped by the current diff. Historical bumps (e.g. /riders/sa=1
354 // from prior calls) live in subform_item_cache.data_versions. Without this
355 // merge, current_dv["/riders/sa"]=0 while T1 entries store dep_ver=1, so all
356 // T1 entries are evicted and every table falls through to the T2 path.
357 // After the merge, current_dv reflects the full accumulated state; the diff
358 // above already bumped any newly-changed fields further, so stale entries that
359 // depended on those fields are still correctly evicted.
360 let current_dv = c.data_versions.clone();
361 for (k, v) in subform_item_cache.entries {
362 // Skip if entry already exists (parent-form run may have added a fresher result).
363 if c.entries.contains_key(&k) {
364 continue;
365 }
366 // Validate all dep versions against the current item data_versions.
367 let still_valid = v.dep_versions.iter().all(|(dep_path, &cached_ver)| {
368 let current_ver = if dep_path.starts_with("/$params") {
369 parent_cache.params_versions.get(dep_path)
370 } else {
371 current_dv.get(dep_path)
372 };
373 current_ver == cached_ver
374 });
375 if still_valid {
376 c.entries.insert(k, v);
377 }
378 }
379 }
380 }
381
382 // Insert into the parent eval_data as well (to make the item visible to global formulas on main evaluate).
383 // Only write (and bump version) when the value actually changed: prevents spurious riders-array
384 // version increments on repeated evaluate_subform calls where the rider data is unchanged.
385 let current_at_item_path = self.eval_data.get(&item_path).cloned();
386 if current_at_item_path.as_ref() != Some(&new_item_val) {
387 self.eval_data.set(&item_path, new_item_val.clone());
388 if is_new_item {
389 parent_cache.bump_data_version(&array_path);
390 }
391 }
392
393 // Re-evaluate `$params` tables that depend on subform item paths that changed.
394 // This is required not just for brand-new items, but also whenever a tracked field
395 // (like `riders.sa`) changes value: tables like RIDER_ZLOB_TABLE depend on rider.sa
396 // and must produce updated rows that reflect the new sa before the subform's own
397 // formula evaluation runs (otherwise cached old rows are reused).
398 //
399 // Gate: only re-evaluate tables when at least one item-level path was NEWLY bumped
400 // in this diff pass. Using any_bumped_with_prefix(v > 0) would return true for
401 // historical bumps from prior calls, causing spurious table invalidation every time.
402 let field_prefix = format!("/{}/", root_key);
403 let item_paths_bumped = match &pre_diff_item_versions {
404 None => {
405 // No pre-diff snapshot = cache slot was just created, treat as new
406 parent_cache
407 .subform_caches
408 .get(&idx)
409 .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
410 .unwrap_or(false)
411 }
412 Some(pre) => {
413 // Only count bumps that occurred during this specific diff pass
414 parent_cache
415 .subform_caches
416 .get(&idx)
417 .map(|c| {
418 c.data_versions
419 .any_newly_bumped_with_prefix(&field_prefix, pre)
420 })
421 .unwrap_or(false)
422 }
423 };
424
425 if is_new_item || item_paths_bumped {
426 // Collect which rider data paths were NEWLY bumped in this diff pass.
427 // When item_paths_bumped = true, the diff detected changes — but we only want to
428 // invalidate tables that ACTUALLY depend on those changed paths. Tables like
429 // ILST_TABLE / RIDER_ZLOB_TABLE don't depend on computed outputs (wop_rider_premi,
430 // first_prem), so bumping them forces unnecessary re-evaluation and increments
431 // eval_generation, preventing the generation-based skip in evaluate_internal_pre_diffed.
432 let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
433 let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
434 parent_cache.subform_caches.get(&idx).map(|c| {
435 c.data_versions
436 .versions()
437 .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
438 .map(|(k, _)| {
439 // Convert data-version path (e.g. /riders/wop_rider_premi) to schema dep
440 // format (e.g. #/riders/properties/wop_rider_premi) for dep matching.
441 let sub = k.trim_start_matches(&field_prefix);
442 format!("#/{}/properties/{}", root_key, sub)
443 })
444 .collect::<Vec<_>>()
445 })
446 });
447 paths
448 } else {
449 None
450 };
451
452 let params_table_keys: Vec<String> = self
453 .table_metadata
454 .keys()
455 .filter(|k| k.starts_with("#/$params"))
456 .filter(|k| {
457 if is_new_item {
458 return true; // new rider: invalidate all tables
459 }
460 // Only invalidate tables whose declared deps overlap the changed paths.
461 // If newly_bumped_paths is None (shouldn't happen when item_paths_bumped=true),
462 // fall back to invalidating all.
463 let Some(ref bumped) = newly_bumped_paths else {
464 return true;
465 };
466 if bumped.is_empty() {
467 return false;
468 }
469 self.dependencies
470 .get(*k)
471 .map(|deps| {
472 deps.iter().any(|dep| {
473 bumped
474 .iter()
475 .any(|b| dep == b || dep.starts_with(b.as_str()))
476 })
477 })
478 .unwrap_or(false)
479 })
480 .cloned()
481 .collect();
482 if !params_table_keys.is_empty() {
483 parent_cache.invalidate_params_tables_for_item(idx, ¶ms_table_keys);
484
485 let eval_data_snapshot = self.eval_data.snapshot_data();
486 for key in ¶ms_table_keys {
487 // CRITICAL FIX: Only evaluate global tables on the parent if they do NOT
488 // depend on subform-specific item paths (like `#/riders/...`).
489 // Tables like WOP_ZLOB_PREMI_TABLE contain formulas like `#/riders/properties/code`
490 // and MUST be evaluated by the subform engine to see the subform's current data.
491 // Tables like WOP_RIDERS contain formulas like `#/illustration/product_benefit/riders`
492 // and MUST be evaluated by the parent engine to see the full parent array.
493 let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
494 let subform_dep_prefix = format!("#/{}/properties/", root_key);
495 let subform_dep_prefix_short = format!("#/{}/", root_key);
496 deps.iter().any(|dep| {
497 dep.starts_with(&subform_dep_prefix)
498 || dep.starts_with(&subform_dep_prefix_short)
499 })
500 } else {
501 false
502 };
503
504 if depends_on_subform_item {
505 continue;
506 }
507
508 // Evaluate the table using parent's updated data
509 if let Ok((rows, external_deps_opt)) =
510 crate::jsoneval::table_evaluate::evaluate_table(
511 self,
512 key,
513 &EvalData::from_arc(std::sync::Arc::clone(&eval_data_snapshot)),
514 None,
515 )
516 {
517 if crate::utils::is_debug_cache_enabled() {
518 println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows.len());
519 }
520 let result_val = serde_json::Value::Array(rows);
521
522 if let Some(external_deps) = external_deps_opt {
523 // We must temporarily clear active_item_index so store_cache puts this in T2 (global)
524 // Then the subform can hit it via T2 fallback check.
525 parent_cache.active_item_index = None;
526 parent_cache.store_cache(key, &external_deps, result_val);
527 parent_cache.active_item_index = Some(idx);
528 }
529 } else {
530 if crate::utils::is_debug_cache_enabled() {
531 println!("PARENT EVALUATED TABLE {} -> ERROR", key);
532 }
533 }
534 }
535 }
536 }
537
538 // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
539 {
540 let subform = self.subforms.get_mut(base_path).unwrap();
541 std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
542 }
543
544 // Step 4: run the caller-supplied operation.
545 let result = {
546 let subform = self.subforms.get_mut(base_path).unwrap();
547 f(subform)
548 };
549
550 // Step 5: restore parent cache.
551 {
552 let subform = self.subforms.get_mut(base_path).unwrap();
553 std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
554 }
555 parent_cache.active_item_index = None;
556 self.eval_cache = parent_cache;
557
558 // Step 6: persist the updated T1 item cache (snapshot + entries) back into the subform's
559 // own per-item cache. Without this, the next evaluate_subform call for the same idx reads
560 // old_item_snapshot = Null from the subform cache (it was removed at line 183) and treats
561 // the rider as brand-new, forcing a full re-diff and invalidating all T1 entries.
562 // Also store the subform's evaluated_schema snapshot (written by evaluate_internal above)
563 // so get_evaluated_schema_subform can return per-item values with an O(1) cache read.
564 {
565 let subform = self.subforms.get_mut(base_path).unwrap();
566 if let Some(item_cache) = self.eval_cache.subform_caches.get_mut(&idx) {
567 item_cache.evaluated_schema = Some(subform.evaluated_schema.clone());
568 subform
569 .eval_cache
570 .subform_caches
571 .insert(idx, item_cache.clone());
572 }
573 }
574
575 result
576 }
577
578 /// Evaluate a subform identified by `subform_path`.
579 ///
580 /// The path may include a trailing item index to bind the evaluation to a specific
581 /// array element and enable the two-tier cache-swap strategy automatically:
582 ///
583 /// ```text
584 /// // Evaluate riders item 1 with index-aware cache
585 /// eval.evaluate_subform("illustration.product_benefit.riders.1", data, ctx, None, None)?;
586 /// ```
587 ///
588 /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
589 pub fn evaluate_subform(
590 &mut self,
591 subform_path: &str,
592 data: &str,
593 context: Option<&str>,
594 paths: Option<&[String]>,
595 token: Option<&CancellationToken>,
596 ) -> Result<(), String> {
597 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
598 if let Some(idx) = idx_opt {
599 self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
600 } else {
601 let subform = self
602 .subforms
603 .get_mut(base_path.as_ref() as &str)
604 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
605 subform.evaluate(data, context, paths, token)
606 }
607 }
608
609 /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
610 fn evaluate_subform_item(
611 &mut self,
612 base_path: &str,
613 idx: usize,
614 data: &str,
615 context: Option<&str>,
616 paths: Option<&[String]>,
617 token: Option<&CancellationToken>,
618 ) -> Result<(), String> {
619 let data_value = crate::jsoneval::json_parser::parse_json_str(data)
620 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
621 let context_value = if let Some(ctx) = context {
622 crate::jsoneval::json_parser::parse_json_str(ctx)
623 .map_err(|e| format!("Failed to parse subform context: {}", e))?
624 } else {
625 Value::Object(serde_json::Map::new())
626 };
627
628 self.with_item_cache_swap(base_path, idx, data_value, context_value, |sf| {
629 // Match main-form lifecycle: resolve visibility, hydrate missing visible static
630 // defaults and their dependents, then re-evaluate only when data was written.
631 sf.evaluate_internal_pre_diffed(paths, token)?;
632 if sf.apply_visible_static_defaults_with_dependents(token)? {
633 sf.evaluate_internal_pre_diffed(paths, token)?;
634 }
635 Ok(())
636 })
637 }
638
639 /// Validate subform data against its schema rules.
640 ///
641 /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
642 /// is present the parent cache is swapped in first, ensuring rule evaluations that
643 /// depend on `$params` tables share already-computed parent-form results.
644 pub fn validate_subform(
645 &mut self,
646 subform_path: &str,
647 data: &str,
648 context: Option<&str>,
649 paths: Option<&[String]>,
650 token: Option<&CancellationToken>,
651 ) -> Result<crate::ValidationResult, String> {
652 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
653 if let Some(idx) = idx_opt {
654 let data_value = crate::jsoneval::json_parser::parse_json_str(data)
655 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
656 let context_value = if let Some(ctx) = context {
657 crate::jsoneval::json_parser::parse_json_str(ctx)
658 .map_err(|e| format!("Failed to parse subform context: {}", e))?
659 } else {
660 Value::Object(serde_json::Map::new())
661 };
662 let data_for_validation = data_value.clone();
663 self.with_item_cache_swap(
664 base_path.as_ref(),
665 idx,
666 data_value,
667 context_value,
668 move |sf| {
669 // Warm the evaluation cache before running rule checks.
670 sf.evaluate_internal_pre_diffed(paths, token)?;
671 sf.validate_pre_set(data_for_validation, paths, token)
672 },
673 )
674 } else {
675 let subform = self
676 .subforms
677 .get_mut(base_path.as_ref() as &str)
678 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
679 subform.validate(data, context, paths, token)
680 }
681 }
682
683 /// Evaluate dependents in a subform when a field changes.
684 ///
685 /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
686 /// is present the parent cache is swapped in, so dependent evaluation runs with
687 /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
688 pub fn evaluate_dependents_subform(
689 &mut self,
690 subform_path: &str,
691 changed_paths: &[String],
692 data: Option<&str>,
693 context: Option<&str>,
694 re_evaluate: bool,
695 token: Option<&CancellationToken>,
696 canceled_paths: Option<&mut Vec<String>>,
697 include_subforms: bool,
698 ) -> Result<Value, String> {
699 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
700 if let Some(idx) = idx_opt {
701 // Parse or snapshot data for the swap / diff computation.
702 let (data_value, context_value) = if let Some(data_str) = data {
703 let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
704 .map_err(|e| format!("Failed to parse subform data: {}", e))?;
705 let cv = if let Some(ctx) = context {
706 crate::jsoneval::json_parser::parse_json_str(ctx)
707 .map_err(|e| format!("Failed to parse subform context: {}", e))?
708 } else {
709 Value::Object(serde_json::Map::new())
710 };
711 (dv, cv)
712 } else {
713 // No new data provided — snapshot current subform state so diff is a no-op.
714 let subform = self
715 .subforms
716 .get(base_path.as_ref() as &str)
717 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
718 let dv = subform.eval_data.snapshot_data_clone();
719 (dv, Value::Object(serde_json::Map::new()))
720 };
721 let changes = self.with_item_cache_swap(
722 base_path.as_ref(),
723 idx,
724 data_value,
725 context_value,
726 |sf| {
727 // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
728 sf.evaluate_dependents(
729 changed_paths,
730 None,
731 None,
732 re_evaluate,
733 token,
734 None,
735 include_subforms,
736 )
737 },
738 )?;
739 // Public indexed-subform patches retain subform-local refs so callers can apply
740 // them directly to their `{ riders: item }` editing payload. Internal dependent
741 // evaluation stays canonical; only active-item result refs are projected here.
742 let subform_dot_path =
743 crate::jsoneval::path_utils::pointer_to_dot_notation(base_path.as_ref())
744 .replace(".properties.", ".");
745 let canonical_prefix = format!("{subform_dot_path}.{idx}");
746 let root_key = base_path.rsplit('/').next().unwrap_or(base_path.as_ref());
747 let changes = match changes {
748 Value::Array(changes) => Value::Array(
749 changes
750 .into_iter()
751 .map(|change| {
752 let Some(change_map) = change.as_object() else {
753 return change;
754 };
755 let Some(Value::String(reference)) = change_map.get("$ref") else {
756 return change;
757 };
758 let Some(suffix) = reference.strip_prefix(&canonical_prefix) else {
759 return change;
760 };
761 if !suffix.is_empty() && !suffix.starts_with('.') {
762 return change;
763 }
764 let mut mapped = change_map.clone();
765 mapped.insert(
766 "$ref".to_string(),
767 Value::String(format!("{root_key}{suffix}")),
768 );
769 Value::Object(mapped)
770 })
771 .collect(),
772 ),
773 value => value,
774 };
775 Ok(changes)
776 } else {
777 let subform = self
778 .subforms
779 .get_mut(base_path.as_ref() as &str)
780 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
781 subform.evaluate_dependents(
782 changed_paths,
783 data,
784 context,
785 re_evaluate,
786 token,
787 canceled_paths,
788 include_subforms,
789 )
790 }
791 }
792
793 /// Resolve layout for subform, returning overlay entries.
794 pub fn resolve_layout_subform(
795 &mut self,
796 subform_path: &str,
797 evaluate: bool,
798 ) -> Result<ResolvedLayoutResult, String> {
799 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
800 let subform = self
801 .subforms
802 .get_mut(base_path.as_ref() as &str)
803 .ok_or_else(|| format!("Subform not found: {}", base_path))?;
804 subform.resolve_layout(evaluate)
805 }
806
807 /// Get evaluated schema from subform.
808 pub fn get_evaluated_schema_subform(&mut self, subform_path: &str) -> Value {
809 let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
810
811 if let Some(idx) = idx_opt {
812 if let Some(schema) = self
813 .eval_cache
814 .subform_caches
815 .get(&idx)
816 .and_then(|c| c.evaluated_schema.clone())
817 {
818 return schema;
819 }
820 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
821 subform.get_evaluated_schema()
822 } else {
823 Value::Null
824 }
825 } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
826 subform.get_evaluated_schema()
827 } else {
828 Value::Null
829 }
830 }
831
832 /// Get schema value from subform in nested object format (all .value fields).
833 ///
834 /// Indexed evaluation stores an absolute parent-array wrapper in the subform's
835 /// eval data for cross-item formulas. This endpoint exposes only fields declared
836 /// by its isolated subform schema, never that implementation wrapper.
837 pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
838 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
839 let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) else {
840 return Value::Null;
841 };
842
843 let values = subform.get_schema_value();
844 let Some(values) = values.as_object() else {
845 return values;
846 };
847
848 let schema_root_keys: Vec<&str> = subform
849 .schema
850 .as_object()
851 .into_iter()
852 .flat_map(|schema| schema.keys())
853 .filter(|key| !key.starts_with('$'))
854 .map(String::as_str)
855 .collect();
856
857 Value::Object(
858 schema_root_keys
859 .into_iter()
860 .filter_map(|key| {
861 values
862 .get(key)
863 .cloned()
864 .map(|value| (key.to_string(), value))
865 })
866 .collect(),
867 )
868 }
869
870 /// Get schema values from subform as a flat array of path-value pairs.
871 pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
872 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
873 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
874 subform.get_schema_value_array()
875 } else {
876 Value::Array(vec![])
877 }
878 }
879
880 /// Get schema values from subform as a flat object with dotted path keys.
881 pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
882 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
883 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
884 subform.get_schema_value_object()
885 } else {
886 Value::Object(serde_json::Map::new())
887 }
888 }
889
890 /// Get evaluated schema without $params from subform.
891 pub fn get_evaluated_schema_without_params_subform(&mut self, subform_path: &str) -> Value {
892 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
893 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
894 subform.get_evaluated_schema_without_params()
895 } else {
896 Value::Null
897 }
898 }
899
900 /// Get evaluated schema by specific path from subform.
901 pub fn get_evaluated_schema_by_path_subform(
902 &mut self,
903 subform_path: &str,
904 schema_path: &str,
905 ) -> Option<Value> {
906 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
907 self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
908 sf.get_evaluated_schema_by_paths(&[schema_path.to_string()], Some(ReturnFormat::Nested))
909 })
910 }
911
912 /// Get evaluated schema by multiple paths from subform.
913 pub fn get_evaluated_schema_by_paths_subform(
914 &mut self,
915 subform_path: &str,
916 schema_paths: &[String],
917 format: Option<crate::ReturnFormat>,
918 ) -> Value {
919 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
920 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
921 subform.get_evaluated_schema_by_paths(
922 schema_paths,
923 Some(format.unwrap_or(ReturnFormat::Flat)),
924 )
925 } else {
926 match format.unwrap_or_default() {
927 crate::ReturnFormat::Array => Value::Array(vec![]),
928 _ => Value::Object(serde_json::Map::new()),
929 }
930 }
931 }
932
933 /// Get schema by specific path from subform.
934 pub fn get_schema_by_path_subform(
935 &self,
936 subform_path: &str,
937 schema_path: &str,
938 ) -> Option<Value> {
939 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
940 self.subforms
941 .get(base_path.as_ref() as &str)
942 .and_then(|sf| sf.get_schema_by_path(schema_path))
943 }
944
945 /// Get schema by multiple paths from subform.
946 pub fn get_schema_by_paths_subform(
947 &self,
948 subform_path: &str,
949 schema_paths: &[String],
950 format: Option<crate::ReturnFormat>,
951 ) -> Value {
952 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
953 if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
954 subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
955 } else {
956 match format.unwrap_or_default() {
957 crate::ReturnFormat::Array => Value::Array(vec![]),
958 _ => Value::Object(serde_json::Map::new()),
959 }
960 }
961 }
962
963 /// Get resolved layout overlay entries for subform.
964 pub fn get_resolved_layout_subform(&mut self, subform_path: &str) -> ResolvedLayoutResult {
965 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
966 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
967 subform.get_resolved_layout()
968 } else {
969 ResolvedLayoutResult::default()
970 }
971 }
972
973 /// Get evaluated schema with layout fully resolved for subform.
974 pub fn get_evaluated_schema_resolved_subform(&mut self, subform_path: &str) -> Value {
975 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
976 if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
977 subform.get_evaluated_schema_resolved()
978 } else {
979 Value::Null
980 }
981 }
982
983 /// Get list of available subform paths.
984 pub fn get_subform_paths(&self) -> Vec<String> {
985 self.subforms.keys().cloned().collect()
986 }
987
988 /// Check if a subform exists at the given path.
989 pub fn has_subform(&self, subform_path: &str) -> bool {
990 let (base_path, _) = self.resolve_subform_path_alias(subform_path);
991 self.subforms.contains_key(base_path.as_ref() as &str)
992 }
993}