json_eval_rs/jsoneval/cache.rs
1use super::JSONEval;
2use crate::jsoneval::eval_cache::{CacheKey, CacheStats};
3use crate::jsoneval::eval_data::EvalData;
4
5
6use indexmap::IndexSet;
7use serde_json::Value;
8
9
10impl JSONEval {
11 /// Check if a dependency should be part of the cache key
12 /// Check if a dependency should be cached
13 /// Caches everything except keys starting with $ (except $context)
14 #[inline]
15 pub fn should_cache_dependency(&self, key: &str) -> bool {
16 if key.starts_with("/$") || key.starts_with('$') {
17 // Only cache $context, exclude other $ keys like $params
18 key == "$context" || key.starts_with("$context.") || key.starts_with("/$context")
19 } else {
20 true
21 }
22 }
23
24 /// Try to get a result from cache
25 /// Helper: Try to get cached result for an evaluation (thread-safe)
26 /// Helper: Try to get cached result (zero-copy via Arc)
27 pub(crate) fn try_get_cached(
28 &self,
29 eval_key: &str,
30 eval_data_snapshot: &EvalData,
31 ) -> Option<Value> {
32 // Skip cache lookup if caching is disabled
33 if !self.cache_enabled {
34 return None;
35 }
36
37 // Get dependencies for this evaluation
38 let deps = self.dependencies.get(eval_key)?;
39
40 // If no dependencies, use simple cache key
41 let cache_key = if deps.is_empty() {
42 CacheKey::simple(eval_key.to_string())
43 } else {
44 // Filter dependencies (exclude $ keys except $context)
45 let filtered_deps: IndexSet<String> = deps
46 .iter()
47 .filter(|dep_key| self.should_cache_dependency(dep_key))
48 .cloned()
49 .collect();
50
51 // Collect dependency values
52 let dep_values: Vec<(String, &Value)> = filtered_deps
53 .iter()
54 .filter_map(|dep_key| eval_data_snapshot.get(dep_key).map(|v| (dep_key.clone(), v)))
55 .collect();
56
57 CacheKey::new(eval_key.to_string(), &filtered_deps, &dep_values)
58 };
59
60 // Try cache lookup (zero-copy via Arc, thread-safe)
61 self.eval_cache
62 .get(&cache_key)
63 .map(|arc_val| (*arc_val).clone())
64 }
65
66 /// Cache a result
67 /// Helper: Store evaluation result in cache (thread-safe)
68 pub(crate) fn cache_result(
69 &self,
70 eval_key: &str,
71 value: Value,
72 eval_data_snapshot: &EvalData,
73 ) {
74 // Skip cache insertion if caching is disabled
75 if !self.cache_enabled {
76 return;
77 }
78
79 // Get dependencies for this evaluation
80 let deps = match self.dependencies.get(eval_key) {
81 Some(d) => d,
82 None => {
83 // No dependencies - use simple cache key
84 let cache_key = CacheKey::simple(eval_key.to_string());
85 self.eval_cache.insert(cache_key, value);
86 return;
87 }
88 };
89
90 // Filter and collect dependency values (exclude $ keys except $context)
91 let filtered_deps: IndexSet<String> = deps
92 .iter()
93 .filter(|dep_key| self.should_cache_dependency(dep_key))
94 .cloned()
95 .collect();
96
97 let dep_values: Vec<(String, &Value)> = filtered_deps
98 .iter()
99 .filter_map(|dep_key| eval_data_snapshot.get(dep_key).map(|v| (dep_key.clone(), v)))
100 .collect();
101
102 let cache_key = CacheKey::new(eval_key.to_string(), &filtered_deps, &dep_values);
103 self.eval_cache.insert(cache_key, value);
104 }
105
106 /// Purge cache entries affected by changed data paths, comparing old and new values
107 /// Selectively purge cache entries that depend on changed data paths
108 /// Only removes cache entries whose dependencies intersect with changed_paths
109 /// Compares old vs new values and only purges if values actually changed
110 pub fn purge_cache_for_changed_data_with_comparison(
111 &self,
112 changed_data_paths: &[String],
113 old_data: &Value,
114 new_data: &Value,
115 ) {
116 if changed_data_paths.is_empty() {
117 return;
118 }
119
120 // Check which paths actually have different values
121 let mut actually_changed_paths = Vec::new();
122 for path in changed_data_paths {
123 let old_val = old_data.pointer(path);
124 let new_val = new_data.pointer(path);
125
126 // Only add to changed list if values differ
127 if old_val != new_val {
128 actually_changed_paths.push(path.clone());
129 }
130 }
131
132 // If no values actually changed, no need to purge
133 if actually_changed_paths.is_empty() {
134 return;
135 }
136
137 // Find all eval_keys that depend on the actually changed data paths
138 let mut affected_eval_keys = IndexSet::new();
139
140 for (eval_key, deps) in self.dependencies.iter() {
141 // Check if this evaluation depends on any of the changed paths
142 let is_affected = deps.iter().any(|dep| {
143 // Check if the dependency matches any changed path
144 actually_changed_paths.iter().any(|changed_path| {
145 // Exact match or prefix match (for nested fields)
146 dep == changed_path
147 || dep.starts_with(&format!("{}/", changed_path))
148 || changed_path.starts_with(&format!("{}/", dep))
149 })
150 });
151
152 if is_affected {
153 affected_eval_keys.insert(eval_key.clone());
154 }
155 }
156
157 // Remove all cache entries for affected eval_keys using retain
158 // Keep entries whose eval_key is NOT in the affected set
159 self.eval_cache
160 .retain(|cache_key, _| !affected_eval_keys.contains(&cache_key.eval_key));
161 }
162
163 /// Selectively purge cache entries that depend on changed data paths
164 /// Finds all eval_keys that depend on the changed paths and removes them
165 /// Selectively purge cache entries that depend on changed data paths
166 /// Simpler version without value comparison for cases where we don't have old data
167 pub fn purge_cache_for_changed_data(&self, changed_data_paths: &[String]) {
168 if changed_data_paths.is_empty() {
169 return;
170 }
171
172 // Find all eval_keys that depend on the changed paths
173 let mut affected_eval_keys = IndexSet::new();
174
175 for (eval_key, deps) in self.dependencies.iter() {
176 // Check if this evaluation depends on any of the changed paths
177 let is_affected = deps.iter().any(|dep| {
178 // Check if dependency path matches any changed data path using flexible matching
179 changed_data_paths.iter().any(|changed_for_purge| {
180 // Check both directions:
181 // 1. Dependency matches changed data (dependency is child of change)
182 // 2. Changed data matches dependency (change is child of dependency)
183 Self::paths_match_flexible(dep, changed_for_purge)
184 || Self::paths_match_flexible(changed_for_purge, dep)
185 })
186 });
187
188 if is_affected {
189 affected_eval_keys.insert(eval_key.clone());
190 }
191 }
192
193 // Remove all cache entries for affected eval_keys using retain
194 // Keep entries whose eval_key is NOT in the affected set
195 self.eval_cache
196 .retain(|cache_key, _| !affected_eval_keys.contains(&cache_key.eval_key));
197 }
198
199 /// Flexible path matching that handles structural schema keywords (e.g. properties, oneOf)
200 /// Returns true if schema_path structurally matches data_path
201 fn paths_match_flexible(schema_path: &str, data_path: &str) -> bool {
202 let s_segs: Vec<&str> = schema_path
203 .trim_start_matches('#')
204 .trim_start_matches('/')
205 .split('/')
206 .filter(|s| !s.is_empty())
207 .collect();
208 let d_segs: Vec<&str> = data_path
209 .trim_start_matches('/')
210 .split('/')
211 .filter(|s| !s.is_empty())
212 .collect();
213
214 let mut d_idx = 0;
215
216 for s_seg in s_segs {
217 // If we matched all data segments, we are good (schema is deeper/parent)
218 if d_idx >= d_segs.len() {
219 return true;
220 }
221
222 let d_seg = d_segs[d_idx];
223
224 if s_seg == d_seg {
225 // Exact match, advance data pointer
226 d_idx += 1;
227 } else if s_seg == "items"
228 || s_seg == "additionalProperties"
229 || s_seg == "patternProperties"
230 {
231 // Wildcard match for arrays/maps - consume data segment if it looks valid
232 // Note: items matches array index (numeric). additionalProperties matches any key.
233 if s_seg == "items" {
234 // Only match if data segment is numeric (array index)
235 if d_seg.chars().all(|c| c.is_ascii_digit()) {
236 d_idx += 1;
237 }
238 } else {
239 // additionalProperties/patternProperties matches any string key
240 d_idx += 1;
241 }
242 } else if Self::is_structural_keyword(s_seg)
243 || s_seg.chars().all(|c| c.is_ascii_digit())
244 {
245 // Skip structural keywords (properties, oneOf, etc) and numeric indices in schema (e.g. oneOf/0)
246 continue;
247 } else {
248 // Mismatch: schema has a named segment that data doesn't have
249 return false;
250 }
251 }
252
253 // Return true if we consumed all data segments
254 true
255 }
256
257 /// Purge cache entries affected by context changes
258 /// Purge cache entries that depend on context
259 pub fn purge_cache_for_context_change(&self) {
260 // Find all eval_keys that depend on $context
261 let mut affected_eval_keys = IndexSet::new();
262
263 for (eval_key, deps) in self.dependencies.iter() {
264 let is_affected = deps.iter().any(|dep| {
265 dep == "$context" || dep.starts_with("$context.") || dep.starts_with("/$context")
266 });
267
268 if is_affected {
269 affected_eval_keys.insert(eval_key.clone());
270 }
271 }
272
273 self.eval_cache
274 .retain(|cache_key, _| !affected_eval_keys.contains(&cache_key.eval_key));
275 }
276
277 /// Get cache statistics
278 pub fn cache_stats(&self) -> CacheStats {
279 self.eval_cache.stats()
280 }
281
282 /// Clear the cache manually
283 pub fn clear_cache(&self) {
284 self.eval_cache.clear();
285 }
286
287 /// Enable caching
288 pub fn enable_cache(&mut self) {
289 self.cache_enabled = true;
290 for subform in self.subforms.values_mut() {
291 subform.enable_cache();
292 }
293 }
294
295 /// Disable caching
296 pub fn disable_cache(&mut self) {
297 self.cache_enabled = false;
298 self.eval_cache.clear();
299 for subform in self.subforms.values_mut() {
300 subform.disable_cache();
301 }
302 }
303
304 /// Check if cache is enabled
305 pub fn is_cache_enabled(&self) -> bool {
306 self.cache_enabled
307 }
308
309 /// Helper to check if a key is a structural JSON Schema keyword
310 /// Helper to check if a key is a structural JSON Schema keyword
311 fn is_structural_keyword(key: &str) -> bool {
312 matches!(
313 key,
314 "properties"
315 | "definitions"
316 | "$defs"
317 | "allOf"
318 | "anyOf"
319 | "oneOf"
320 | "not"
321 | "if"
322 | "then"
323 | "else"
324 | "dependentSchemas"
325 | "$params"
326 | "dependencies"
327 )
328 }
329
330 /// Get cache size
331 pub fn cache_len(&self) -> usize {
332 self.eval_cache.len()
333 }
334}