Skip to main content

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. `"#/properties/users/properties/items"`).
12///
13/// Accepted formats for the **base** portion:
14/// - Schema pointer:    `"#/properties/users/properties/items"`
15/// - Raw JSON pointer:  `"/properties/users/properties/items"`
16/// - Dot notation:      `"users.items"`
17///
18/// Accepted formats for the **index** suffix (stripped before lookup):
19/// - Trailing dot-index:     `"…items.1"`
20/// - Trailing slash-index:   `"…items/1"`
21/// - Bracket array index:    `"…items[1]"` or `"…items[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. "items[2]" or "items[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: "form.subform"
96    // → "#/properties/form/properties/subform"
97    crate::jsoneval::path_utils::dot_notation_to_schema_pointer(path)
98}
99
100impl JSONEval {
101    /// Resolves the subform path, allowing aliases like "subform" to match the full
102    /// schema pointer "#/properties/form/properties/subform".
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    pub(crate) 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        persist_evaluated_schema: bool,
143        f: F,
144    ) -> Result<T, String>
145    where
146        F: FnOnce(&mut JSONEval) -> Result<T, String>,
147    {
148        let original_field_key = base_path
149            .split('/')
150            .next_back()
151            .unwrap_or(base_path)
152            .to_string();
153
154        let schema_pointer = if base_path.starts_with("#/") {
155            &base_path[1..]
156        } else if base_path.starts_with('#') {
157            &base_path[1..]
158        } else {
159            base_path
160        };
161
162        let root_key =
163            crate::jsoneval::path_utils::get_value_by_pointer(&self.schema, schema_pointer)
164                .and_then(|node| node.get("itemsRootKey"))
165                .and_then(|v| v.as_str())
166                .unwrap_or(&original_field_key)
167                .to_string();
168
169        let array_path =
170            crate::jsoneval::path_utils::schema_path_to_data_pointer(base_path).into_owned();
171        let item_path = format!("{}/{}", array_path, idx);
172        let full_parent_payload = data_value.pointer(&array_path).is_some();
173        let payload_has_parent_context = data_value
174            .as_object()
175            .map(|map| map.keys().any(|key| key != &root_key))
176            .unwrap_or(false);
177
178        // Normalize both public payload shapes into one active item before scope setup.
179        let normalized_item = if full_parent_payload {
180            data_value
181                .pointer(&item_path)
182                .cloned()
183                .or_else(|| data_value.get(&root_key).cloned())
184        } else {
185            data_value.get(&root_key).cloned()
186        }
187        .ok_or_else(|| {
188            format!(
189                "Invalid indexed subform payload for {base_path}[{idx}]: expected active item at {item_path} or wrapper root {root_key}"
190            )
191        })?;
192
193        // Prepare item data and cache state.
194        let (old_item_snapshot, new_item_val, subform_item_cache_opt) = {
195            let subform = self
196                .subforms
197                .get_mut(base_path)
198                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
199
200            let old_item_snapshot = subform
201                .eval_cache
202                .subform_caches
203                .get(&idx)
204                .map(|c| c.item_snapshot.clone())
205                .unwrap_or(Value::Null);
206
207            let scope = crate::jsoneval::subform_scope::SubformScope::new(
208                base_path,
209                &array_path,
210                Some(idx),
211            );
212
213            let mut scoped_view = if self.eval_data.get(&item_path) == Some(&normalized_item) {
214                scope.evaluation_view(self.eval_data.data())
215            } else {
216                let mut scoped_data = self.eval_data.snapshot_data_clone();
217                if full_parent_payload || payload_has_parent_context {
218                    if let Some(obj) = scoped_data.as_object_mut() {
219                        if let Some(input_obj) = data_value.as_object() {
220                            for (k, v) in input_obj {
221                                obj.insert(k.clone(), v.clone());
222                            }
223                        }
224                    }
225                }
226                crate::jsoneval::eval_data::EvalData::set_by_pointer(
227                    &mut scoped_data,
228                    &item_path,
229                    normalized_item.clone(),
230                );
231                scope.evaluation_view(&scoped_data)
232            };
233
234            if let Some(view) = scoped_view.as_object_mut() {
235                view.insert("$context".to_string(), context_value.clone());
236            }
237            subform.eval_data = EvalData::new(scoped_view);
238            let new_item_val = normalized_item.clone();
239
240            // Move item cache into parent cache.
241            let existing = subform.eval_cache.subform_caches.remove(&idx);
242            (old_item_snapshot, new_item_val, existing)
243        }; // subform borrow released here
244
245        // Fall back to parent item snapshot.
246        let parent_item = self.eval_data.get(&item_path).cloned();
247        let old_item_snapshot = if old_item_snapshot == Value::Null {
248            parent_item.clone().unwrap_or(Value::Null)
249        } else {
250            old_item_snapshot
251        };
252
253        // Parent data determines whether item is new.
254        let is_new_item = parent_item.is_none();
255
256        let mut parent_cache = std::mem::take(&mut self.eval_cache);
257        if full_parent_payload {
258            let needs_parent_sync = if let Some(input_obj) = data_value.as_object() {
259                let current = self.eval_data.data();
260                input_obj.iter().any(|(k, v)| current.get(k) != Some(v))
261                    || current.get("$context") != Some(&context_value)
262            } else {
263                *self.eval_data.snapshot_data() != data_value
264            };
265            if needs_parent_sync {
266                let old_parent_data = self.eval_data.snapshot_data();
267                self.eval_data
268                    .replace_data_and_context(data_value.clone(), context_value.clone());
269                let new_parent_data = self.eval_data.snapshot_data();
270                crate::jsoneval::eval_cache::diff_and_update_versions(
271                    &mut parent_cache.data_versions,
272                    "",
273                    &old_parent_data,
274                    &new_parent_data,
275                    "sync_full_subform_payload",
276                );
277            }
278        }
279
280        parent_cache.ensure_active_item_cache(idx);
281
282        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
283            // Merge parent versions without item-local paths.
284            c.data_versions
285                .merge_excluding_prefix(&parent_cache.data_versions, &format!("/{root_key}/"));
286            c.data_versions
287                .merge_from_params(&parent_cache.params_versions);
288
289            // Merge item version history before diffing.
290            if let Some(subform_item_cache) = &subform_item_cache_opt {
291                c.data_versions
292                    .merge_from(&subform_item_cache.data_versions);
293            }
294        }
295
296        // Keep baseline to detect new version bumps.
297        let pre_diff_item_versions = parent_cache
298            .subform_caches
299            .get(&idx)
300            .map(|c| c.data_versions.clone());
301
302        if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
303            // Diff only the item field to find what changed (skips the 5 MB parent tree).
304            crate::jsoneval::eval_cache::diff_and_update_versions(
305                &mut c.data_versions,
306                &format!("/{}", root_key),
307                &old_item_snapshot,
308                &new_item_val,
309                "with_item_cache_swap_diff_and_update_versions",
310            );
311            c.item_snapshot = new_item_val.clone();
312        }
313
314        // Propagate new item changes to parent T2 versions.
315        {
316            let item_field_prefix = format!("/{}/", root_key);
317            if let (Some(ref pre), Some(c)) = (
318                &pre_diff_item_versions,
319                parent_cache.subform_caches.get(&idx),
320            ) {
321                let newly_bumped: Vec<String> = c
322                    .data_versions
323                    .versions()
324                    .filter(|(k, &v)| k.starts_with(&item_field_prefix) && v > pre.get(k))
325                    .map(|(k, _)| k.to_string())
326                    .collect();
327                if !newly_bumped.is_empty() {
328                    for k in newly_bumped {
329                        parent_cache
330                            .data_versions
331                            .bump(&k, "propagate_newly_bumped");
332                    }
333                    parent_cache.eval_generation += 1;
334                }
335            }
336        }
337
338        parent_cache.active_item_index = Some(idx);
339
340        // Restore valid item cache entries.
341        if let Some(subform_item_cache) = subform_item_cache_opt {
342            if let Some(c) = parent_cache.subform_caches.get_mut(&idx) {
343                // Merge historical item versions before validation.
344                let current_dv = c.data_versions.clone();
345                for (k, v) in subform_item_cache.entries {
346                    // Skip if entry already exists (parent-form run may have added a fresher result).
347                    if c.entries.contains_key(&k) {
348                        continue;
349                    }
350                    // Validate all dep versions against the current item data_versions.
351                    let still_valid =
352                        v.dep_versions
353                            .iter()
354                            .all(|(dep_path, &cached_ver): (&String, &u64)| {
355                                let current_ver = if dep_path.starts_with("/$params") {
356                                    parent_cache.params_versions.get(dep_path)
357                                } else {
358                                    current_dv.get(dep_path)
359                                };
360                                current_ver == cached_ver
361                            });
362                    if still_valid {
363                        c.entries.insert(k, v);
364                    }
365                }
366            }
367        }
368
369        // Sync changed item into parent data.
370        let current_at_item_path = self.eval_data.get(&item_path).cloned();
371        if current_at_item_path.as_ref() != Some(&new_item_val) {
372            self.eval_data.set(&item_path, new_item_val.clone());
373            if is_new_item {
374                parent_cache.bump_data_version(&array_path);
375            }
376        }
377
378        // Refresh affected $params tables after new item changes.
379        let field_prefix = format!("/{}/", root_key);
380        let item_paths_bumped = match &pre_diff_item_versions {
381            None => {
382                // No pre-diff snapshot = cache slot was just created, treat as new
383                parent_cache
384                    .subform_caches
385                    .get(&idx)
386                    .map(|c| c.data_versions.any_bumped_with_prefix(&field_prefix))
387                    .unwrap_or(false)
388            }
389            Some(pre) => {
390                // Only count bumps that occurred during this specific diff pass
391                parent_cache
392                    .subform_caches
393                    .get(&idx)
394                    .map(|c| {
395                        c.data_versions
396                            .any_newly_bumped_with_prefix(&field_prefix, pre)
397                    })
398                    .unwrap_or(false)
399            }
400        };
401
402        if is_new_item || item_paths_bumped {
403            // Collect newly changed item paths.
404            let newly_bumped_paths: Option<Vec<String>> = if item_paths_bumped {
405                let paths = pre_diff_item_versions.as_ref().and_then(|pre| {
406                    parent_cache.subform_caches.get(&idx).map(|c| {
407                        c.data_versions
408                            .versions()
409                            .filter(|(k, &v)| k.starts_with(&field_prefix) && v > pre.get(k))
410                            .map(|(k, _)| {
411                                // Convert data path to schema path.
412                                let sub = k.trim_start_matches(&field_prefix);
413                                format!("#/{}/properties/{}", root_key, sub)
414                            })
415                            .collect::<Vec<_>>()
416                    })
417                });
418                paths
419            } else {
420                None
421            };
422
423            let params_table_keys: Vec<String> = self
424                .table_metadata
425                .keys()
426                .filter(|k| k.starts_with("#/$params"))
427                .filter(|k| {
428                    if is_new_item {
429                        return true; // new item: invalidate all tables
430                    }
431                    // Invalidate tables with changed dependencies.
432                    let Some(ref bumped) = newly_bumped_paths else {
433                        return true;
434                    };
435                    if bumped.is_empty() {
436                        return false;
437                    }
438                    self.dependencies
439                        .get(*k)
440                        .map(|deps| {
441                            deps.iter().any(|dep| {
442                                bumped
443                                    .iter()
444                                    .any(|b| dep == b || dep.starts_with(b.as_str()))
445                            })
446                        })
447                        .unwrap_or(false)
448                })
449                .cloned()
450                .collect();
451            if !params_table_keys.is_empty() {
452                parent_cache.invalidate_params_tables_for_item(idx, &params_table_keys);
453
454                let eval_data_snapshot = self.eval_data.snapshot_data();
455                for key in &params_table_keys {
456                    // Item-dependent tables run in subform scope.
457                    let depends_on_subform_item = if let Some(deps) = self.dependencies.get(key) {
458                        let subform_dep_prefix = format!("#/{}/properties/", root_key);
459                        let subform_dep_prefix_short = format!("#/{}/", root_key);
460                        deps.iter().any(|dep| {
461                            dep.starts_with(&subform_dep_prefix)
462                                || dep.starts_with(&subform_dep_prefix_short)
463                        })
464                    } else {
465                        false
466                    };
467
468                    if depends_on_subform_item {
469                        continue;
470                    }
471
472                    // Evaluate the table using parent's updated data
473                    if let Ok((arc_val, external_deps_opt)) =
474                        crate::jsoneval::table_evaluate::evaluate_table(
475                            self,
476                            key,
477                            &EvalData::from_arc(std::sync::Arc::clone(&eval_data_snapshot)),
478                            None,
479                        )
480                    {
481                        if crate::utils::is_debug_cache_enabled() {
482                            let rows_len = arc_val.as_array().map(|a| a.len()).unwrap_or(0);
483                            println!("PARENT EVALUATED TABLE {} -> {} rows", key, rows_len);
484                        }
485
486                        if let Some(external_deps) = external_deps_opt {
487                            // Store parent result in T2.
488                            parent_cache.active_item_index = None;
489                            parent_cache.store_cache_arc(key, &external_deps, arc_val);
490                            parent_cache.active_item_index = Some(idx);
491                        }
492                    } else {
493                        if crate::utils::is_debug_cache_enabled() {
494                            println!("PARENT EVALUATED TABLE {} -> ERROR", key);
495                        }
496                    }
497                }
498            }
499        }
500
501        // Step 3: swap parent cache into subform so Tier 1 + Tier 2 entries are visible.
502        {
503            let subform = self.subforms.get_mut(base_path).unwrap();
504            subform.static_arrays = std::sync::Arc::clone(&self.static_arrays);
505            subform
506                .engine
507                .set_static_arrays(std::sync::Arc::clone(&self.static_arrays));
508            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
509        }
510
511        // Step 4: run the caller-supplied operation.
512        let result = {
513            let subform = self.subforms.get_mut(base_path).unwrap();
514            f(subform)
515        };
516
517        // Step 5: restore parent cache.
518        {
519            let subform = self.subforms.get_mut(base_path).unwrap();
520            std::mem::swap(&mut subform.eval_cache, &mut parent_cache);
521        }
522        parent_cache.active_item_index = None;
523        self.eval_cache = parent_cache;
524
525        // Persist item cache and evaluated schema.
526        {
527            let subform = self.subforms.get_mut(base_path).unwrap();
528            if let Some(item_cache) = self.eval_cache.subform_caches.get_mut(&idx) {
529                if persist_evaluated_schema {
530                    item_cache.evaluated_schema = Some(subform.evaluated_schema.clone());
531                }
532                subform.eval_cache.ensure_active_item_cache(idx);
533                if let Some(sub_cache) = subform.eval_cache.subform_caches.get_mut(&idx) {
534                    sub_cache.item_snapshot = item_cache.item_snapshot.clone();
535                }
536            }
537            if !persist_evaluated_schema {
538                subform.eval_data = crate::jsoneval::eval_data::EvalData::new(Value::Null);
539            }
540        }
541
542        result
543    }
544
545    /// Evaluate a subform identified by `subform_path`.
546    ///
547    /// The path may include a trailing item index to bind the evaluation to a specific
548    /// array element and enable the two-tier cache-swap strategy automatically:
549    ///
550    /// ```text
551    /// // Evaluate subform item 1 with index-aware cache
552    /// eval.evaluate_subform("users.items.1", data, ctx, None, None)?;
553    /// ```
554    ///
555    /// Without a trailing index, the subform is evaluated in isolation (no cache swap).
556    pub fn evaluate_subform(
557        &mut self,
558        subform_path: &str,
559        data: &str,
560        context: Option<&str>,
561        paths: Option<&[String]>,
562        token: Option<&CancellationToken>,
563    ) -> Result<(), String> {
564        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
565        if let Some(idx) = idx_opt {
566            self.evaluate_subform_item(&base_path, idx, data, context, paths, token)
567        } else {
568            let subform = self
569                .subforms
570                .get_mut(base_path.as_ref() as &str)
571                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
572            subform.evaluate(data, context, paths, token)
573        }
574    }
575
576    /// Internal: evaluate a single subform item at `idx` using the cache-swap strategy.
577    fn evaluate_subform_item(
578        &mut self,
579        base_path: &str,
580        idx: usize,
581        data: &str,
582        context: Option<&str>,
583        paths: Option<&[String]>,
584        token: Option<&CancellationToken>,
585    ) -> Result<(), String> {
586        let data_value = crate::jsoneval::json_parser::parse_json_str(data)
587            .map_err(|e| format!("Failed to parse subform data: {}", e))?;
588        let context_value = if let Some(ctx) = context {
589            crate::jsoneval::json_parser::parse_json_str(ctx)
590                .map_err(|e| format!("Failed to parse subform context: {}", e))?
591        } else {
592            Value::Object(serde_json::Map::new())
593        };
594
595        self.with_item_cache_swap(base_path, idx, data_value, context_value, true, |sf| {
596            // Match main-form lifecycle: resolve visibility, hydrate missing visible static
597            // defaults and their dependents, then re-evaluate only when data was written.
598            sf.evaluate_internal_pre_diffed(paths, token)?;
599            if sf.apply_visible_static_defaults_with_dependents(token)? {
600                sf.evaluate_internal_pre_diffed(paths, token)?;
601            }
602            Ok(())
603        })
604    }
605
606    /// Validate subform data against its schema rules.
607    ///
608    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
609    /// is present the parent cache is swapped in first, ensuring rule evaluations that
610    /// depend on `$params` tables share already-computed parent-form results.
611    pub fn validate_subform(
612        &mut self,
613        subform_path: &str,
614        data: &str,
615        context: Option<&str>,
616        paths: Option<&[String]>,
617        token: Option<&CancellationToken>,
618        validate_readonly: Option<bool>,
619    ) -> Result<crate::ValidationResult, String> {
620        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
621        if let Some(idx) = idx_opt {
622            let data_value = crate::jsoneval::json_parser::parse_json_str(data)
623                .map_err(|e| format!("Failed to parse subform data: {}", e))?;
624            let context_value = if let Some(ctx) = context {
625                crate::jsoneval::json_parser::parse_json_str(ctx)
626                    .map_err(|e| format!("Failed to parse subform context: {}", e))?
627            } else {
628                Value::Object(serde_json::Map::new())
629            };
630            let data_for_validation = data_value.clone();
631            self.with_item_cache_swap(
632                base_path.as_ref(),
633                idx,
634                data_value,
635                context_value,
636                false,
637                move |sf| {
638                    // Warm the evaluation cache before running rule checks.
639                    sf.evaluate_internal_pre_diffed(paths, token)?;
640                    sf.validate_pre_set(
641                        Some(&data_for_validation),
642                        paths,
643                        token,
644                        validate_readonly,
645                        false,
646                    )
647                },
648            )
649        } else {
650            let subform = self
651                .subforms
652                .get_mut(base_path.as_ref() as &str)
653                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
654            subform.validate(data, context, paths, token, validate_readonly, None)
655        }
656    }
657
658    /// Evaluate dependents in a subform when a field changes.
659    ///
660    /// Supports the same trailing-index path syntax as `evaluate_subform`. When an index
661    /// is present the parent cache is swapped in, so dependent evaluation runs with
662    /// Tier-2 entries visible and item-scoped version bumps propagate to `eval_generation`.
663    pub fn evaluate_dependents_subform(
664        &mut self,
665        subform_path: &str,
666        changed_paths: &[String],
667        data: Option<&str>,
668        context: Option<&str>,
669        re_evaluate: bool,
670        token: Option<&CancellationToken>,
671        canceled_paths: Option<&mut Vec<String>>,
672        include_subforms: bool,
673    ) -> Result<Value, String> {
674        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
675        if let Some(idx) = idx_opt {
676            // Parse or snapshot data for the swap / diff computation.
677            let (data_value, context_value) = if let Some(data_str) = data {
678                let dv = crate::jsoneval::json_parser::parse_json_str(data_str)
679                    .map_err(|e| format!("Failed to parse subform data: {}", e))?;
680                let cv = if let Some(ctx) = context {
681                    crate::jsoneval::json_parser::parse_json_str(ctx)
682                        .map_err(|e| format!("Failed to parse subform context: {}", e))?
683                } else {
684                    Value::Object(serde_json::Map::new())
685                };
686                (dv, cv)
687            } else {
688                // No new data provided — snapshot current state (subform or parent) so diff is a no-op.
689                let subform = self
690                    .subforms
691                    .get(base_path.as_ref() as &str)
692                    .ok_or_else(|| format!("Subform not found: {}", base_path))?;
693                let dv = if !subform.eval_data.data().is_null() {
694                    subform.eval_data.snapshot_data_clone()
695                } else {
696                    self.eval_data.snapshot_data_clone()
697                };
698                let cv = if !subform.eval_data.data().is_null() {
699                    subform
700                        .eval_data
701                        .data()
702                        .get("$context")
703                        .cloned()
704                        .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
705                } else {
706                    self.eval_data
707                        .data()
708                        .get("$context")
709                        .cloned()
710                        .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
711                };
712                (dv, cv)
713            };
714            let changes = self.with_item_cache_swap(
715                base_path.as_ref(),
716                idx,
717                data_value,
718                context_value,
719                false,
720                |sf| {
721                    // Data is already set by with_item_cache_swap; pass None to avoid re-parsing.
722                    sf.evaluate_dependents(
723                        changed_paths,
724                        None,
725                        None,
726                        re_evaluate,
727                        token,
728                        None,
729                        include_subforms,
730                    )
731                },
732            )?;
733            // Public indexed-subform patches retain subform-local refs so callers can apply
734            // them directly to their subform item editing payload. Internal dependent
735            // evaluation stays canonical; only active-item result refs are projected here.
736            let subform_dot_path =
737                crate::jsoneval::path_utils::pointer_to_dot_notation(base_path.as_ref())
738                    .replace(".properties.", ".");
739            let canonical_prefix = format!("{subform_dot_path}.{idx}");
740            let root_key = base_path.rsplit('/').next().unwrap_or(base_path.as_ref());
741            let changes = match changes {
742                Value::Array(changes) => Value::Array(
743                    changes
744                        .into_iter()
745                        .map(|change| {
746                            let Some(change_map) = change.as_object() else {
747                                return change;
748                            };
749                            let Some(Value::String(reference)) = change_map.get("$ref") else {
750                                return change;
751                            };
752                            let Some(suffix) = reference.strip_prefix(&canonical_prefix) else {
753                                return change;
754                            };
755                            if !suffix.is_empty() && !suffix.starts_with('.') {
756                                return change;
757                            }
758                            let mut mapped = change_map.clone();
759                            mapped.insert(
760                                "$ref".to_string(),
761                                Value::String(format!("{root_key}{suffix}")),
762                            );
763                            Value::Object(mapped)
764                        })
765                        .collect(),
766                ),
767                value => value,
768            };
769            Ok(changes)
770        } else {
771            let subform = self
772                .subforms
773                .get_mut(base_path.as_ref() as &str)
774                .ok_or_else(|| format!("Subform not found: {}", base_path))?;
775            subform.evaluate_dependents(
776                changed_paths,
777                data,
778                context,
779                re_evaluate,
780                token,
781                canceled_paths,
782                include_subforms,
783            )
784        }
785    }
786
787    /// Resolve layout for subform, returning overlay entries.
788    pub fn resolve_layout_subform(
789        &mut self,
790        subform_path: &str,
791        evaluate: bool,
792    ) -> Result<ResolvedLayoutResult, String> {
793        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
794        let subform = self
795            .subforms
796            .get_mut(base_path.as_ref() as &str)
797            .ok_or_else(|| format!("Subform not found: {}", base_path))?;
798        subform.resolve_layout(evaluate)
799    }
800
801    /// Get evaluated schema from subform.
802    pub fn get_evaluated_schema_subform(&mut self, subform_path: &str) -> Value {
803        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
804
805        if let Some(idx) = idx_opt {
806            if let Some(schema) = self
807                .eval_cache
808                .subform_caches
809                .get(&idx)
810                .and_then(|c| c.evaluated_schema.clone())
811            {
812                return schema;
813            }
814            if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
815                subform.get_evaluated_schema()
816            } else {
817                Value::Null
818            }
819        } else if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
820            subform.get_evaluated_schema()
821        } else {
822            Value::Null
823        }
824    }
825
826    /// Get schema value from subform in nested object format (all .value fields).
827    ///
828    /// Indexed evaluation stores an absolute parent-array wrapper in the subform's
829    /// eval data for cross-item formulas. This endpoint exposes only fields declared
830    /// by its isolated subform schema, never that implementation wrapper.
831    pub fn get_schema_value_subform(&mut self, subform_path: &str) -> Value {
832        let (base_path, idx_opt) = self.resolve_subform_path_alias(subform_path);
833        if let Some(idx) = idx_opt {
834            let data_value = self.eval_data.snapshot_data_clone();
835            let context_value = self
836                .eval_data
837                .data()
838                .get("$context")
839                .cloned()
840                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
841
842            let res = self.with_item_cache_swap(
843                &base_path,
844                idx,
845                data_value,
846                context_value,
847                false,
848                |sf| {
849                    sf.evaluate_internal_pre_diffed(None, None)?;
850                    if sf.apply_visible_static_defaults_with_dependents(None)? {
851                        sf.evaluate_internal_pre_diffed(None, None)?;
852                    }
853                    Ok(sf.get_schema_value(Some(true)))
854                },
855            );
856
857            if let Ok(values) = res {
858                let schema_root_keys: Vec<String> = self
859                    .subforms
860                    .get(base_path.as_ref() as &str)
861                    .and_then(|sf| sf.schema.as_object())
862                    .into_iter()
863                    .flat_map(|schema| schema.keys())
864                    .filter(|key| !key.starts_with('$'))
865                    .map(|k| k.to_string())
866                    .collect();
867
868                return Value::Object(
869                    schema_root_keys
870                        .into_iter()
871                        .filter_map(|key| values.get(&key).cloned().map(|value| (key, value)))
872                        .collect(),
873                );
874            }
875        }
876
877        let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) else {
878            return Value::Null;
879        };
880
881        let values = subform.get_schema_value(None);
882        let Some(values) = values.as_object() else {
883            return values;
884        };
885
886        let schema_root_keys: Vec<&str> = subform
887            .schema
888            .as_object()
889            .into_iter()
890            .flat_map(|schema| schema.keys())
891            .filter(|key| !key.starts_with('$'))
892            .map(String::as_str)
893            .collect();
894
895        Value::Object(
896            schema_root_keys
897                .into_iter()
898                .filter_map(|key| {
899                    values
900                        .get(key)
901                        .cloned()
902                        .map(|value| (key.to_string(), value))
903                })
904                .collect(),
905        )
906    }
907
908    /// Get schema values from subform as a flat array of path-value pairs.
909    pub fn get_schema_value_array_subform(&self, subform_path: &str) -> Value {
910        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
911        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
912            subform.get_schema_value_array()
913        } else {
914            Value::Array(vec![])
915        }
916    }
917
918    /// Get schema values from subform as a flat object with dotted path keys.
919    pub fn get_schema_value_object_subform(&self, subform_path: &str) -> Value {
920        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
921        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
922            subform.get_schema_value_object()
923        } else {
924            Value::Object(serde_json::Map::new())
925        }
926    }
927
928    /// Get evaluated schema without $params from subform.
929    pub fn get_evaluated_schema_without_params_subform(&mut self, subform_path: &str) -> Value {
930        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
931        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
932            subform.get_evaluated_schema_without_params()
933        } else {
934            Value::Null
935        }
936    }
937
938    /// Get plain $params from subform.
939    pub fn get_plain_params_subform(&self, subform_path: &str) -> Option<Value> {
940        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
941        let subform = self.subforms.get(base_path.as_ref() as &str)?;
942        subform.get_plain_params()
943    }
944
945    /// Get evaluated $params from subform.
946    pub fn get_evaluated_params_subform(
947        &mut self,
948        subform_path: &str,
949        with_static_array: bool,
950    ) -> Option<Value> {
951        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
952        let subform = self.subforms.get_mut(base_path.as_ref() as &str)?;
953        subform.get_evaluated_params(with_static_array)
954    }
955
956    /// Get evaluated schema by specific path from subform.
957    pub fn get_evaluated_schema_by_path_subform(
958        &mut self,
959        subform_path: &str,
960        schema_path: &str,
961    ) -> Option<Value> {
962        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
963        self.subforms.get_mut(base_path.as_ref() as &str).map(|sf| {
964            sf.get_evaluated_schema_by_paths(&[schema_path.to_string()], Some(ReturnFormat::Nested))
965        })
966    }
967
968    /// Get evaluated schema by multiple paths from subform.
969    pub fn get_evaluated_schema_by_paths_subform(
970        &mut self,
971        subform_path: &str,
972        schema_paths: &[String],
973        format: Option<crate::ReturnFormat>,
974    ) -> 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_by_paths(
978                schema_paths,
979                Some(format.unwrap_or(ReturnFormat::Flat)),
980            )
981        } else {
982            match format.unwrap_or_default() {
983                crate::ReturnFormat::Array => Value::Array(vec![]),
984                _ => Value::Object(serde_json::Map::new()),
985            }
986        }
987    }
988
989    /// Get schema by specific path from subform.
990    pub fn get_schema_by_path_subform(
991        &self,
992        subform_path: &str,
993        schema_path: &str,
994    ) -> Option<Value> {
995        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
996        self.subforms
997            .get(base_path.as_ref() as &str)
998            .and_then(|sf| sf.get_schema_by_path(schema_path))
999    }
1000
1001    /// Get schema by multiple paths from subform.
1002    pub fn get_schema_by_paths_subform(
1003        &self,
1004        subform_path: &str,
1005        schema_paths: &[String],
1006        format: Option<crate::ReturnFormat>,
1007    ) -> Value {
1008        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
1009        if let Some(subform) = self.subforms.get(base_path.as_ref() as &str) {
1010            subform.get_schema_by_paths(schema_paths, Some(format.unwrap_or(ReturnFormat::Flat)))
1011        } else {
1012            match format.unwrap_or_default() {
1013                crate::ReturnFormat::Array => Value::Array(vec![]),
1014                _ => Value::Object(serde_json::Map::new()),
1015            }
1016        }
1017    }
1018
1019    /// Get resolved layout overlay entries for subform.
1020    pub fn get_resolved_layout_subform(&mut self, subform_path: &str) -> ResolvedLayoutResult {
1021        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
1022        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
1023            subform.get_resolved_layout()
1024        } else {
1025            ResolvedLayoutResult::default()
1026        }
1027    }
1028
1029    /// Get evaluated schema with layout fully resolved for subform.
1030    pub fn get_evaluated_schema_resolved_subform(&mut self, subform_path: &str) -> Value {
1031        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
1032        if let Some(subform) = self.subforms.get_mut(base_path.as_ref() as &str) {
1033            subform.get_evaluated_schema_resolved()
1034        } else {
1035            Value::Null
1036        }
1037    }
1038
1039    /// Get list of available subform paths.
1040    pub fn get_subform_paths(&self) -> Vec<String> {
1041        self.subforms.keys().cloned().collect()
1042    }
1043
1044    /// Check if a subform exists at the given path.
1045    pub fn has_subform(&self, subform_path: &str) -> bool {
1046        let (base_path, _) = self.resolve_subform_path_alias(subform_path);
1047        self.subforms.contains_key(base_path.as_ref() as &str)
1048    }
1049}