Skip to main content

json_eval_rs/jsoneval/
path_utils.rs

1use serde_json::Value;
2use std::borrow::Cow;
3
4/// Normalize path to JSON pointer format for efficient native access
5///
6/// Handles various input formats:
7/// - JSON Schema refs: #/$params/constants/DEATH_SA -> /$params/constants/DEATH_SA
8/// - Dotted paths: user.name -> /user/name
9/// - Already normalized paths (no-op)
10/// - Simple field names: field -> /field
11///
12/// Returns `Cow::Borrowed` for already-normalized paths to avoid heap allocation.
13#[inline]
14pub fn normalize_to_json_pointer(path: &str) -> Cow<'_, str> {
15    if path.is_empty() {
16        return Cow::Borrowed("");
17    }
18
19    if path.starts_with("#/") {
20        let stripped = &path[1..];
21        if !stripped.contains("//") {
22            return Cow::Borrowed(stripped);
23        }
24    }
25
26    if path.starts_with('/') && !path.contains("//") {
27        return if path == "/" {
28            Cow::Borrowed("")
29        } else {
30            Cow::Borrowed(path)
31        };
32    }
33
34    let mut normalized = String::with_capacity(path.len() + 1);
35    let source = if path.starts_with("#/") {
36        &path[1..]
37    } else if !path.starts_with('/') {
38        normalized.push('/');
39        path
40    } else {
41        path
42    };
43
44    let mut prev_slash = normalized.ends_with('/');
45    for ch in source.chars() {
46        let c = if ch == '.' && !path.starts_with('/') && !path.starts_with('#') {
47            '/'
48        } else {
49            ch
50        };
51        if c == '/' {
52            if !prev_slash {
53                normalized.push('/');
54            }
55            prev_slash = true;
56        } else {
57            normalized.push(c);
58            prev_slash = false;
59        }
60    }
61
62    if normalized == "/" {
63        Cow::Borrowed("")
64    } else {
65        Cow::Owned(normalized)
66    }
67}
68
69/// Fast conversion from schema path (e.g. `#/foo/properties/bar`)
70/// to data JSON pointer (e.g. `/foo/bar`).
71/// Strips leading `#`, removes `properties` segments, and ensures a leading `/`.
72#[inline]
73pub fn schema_path_to_data_pointer(path: &str) -> Cow<'_, str> {
74    if path.is_empty() {
75        return Cow::Borrowed("");
76    }
77
78    let no_hash = if path.starts_with('#') {
79        &path[1..]
80    } else {
81        path
82    };
83
84    let clean_path = if no_hash.starts_with('/') {
85        &no_hash[1..]
86    } else {
87        no_hash
88    };
89
90    if !clean_path.contains("properties/") && clean_path != "properties" {
91        if path.starts_with('/') && path.len() == clean_path.len() + 1 {
92            return Cow::Borrowed(path);
93        }
94        let mut s = String::with_capacity(clean_path.len() + 1);
95        s.push('/');
96        s.push_str(clean_path);
97        return Cow::Owned(s);
98    }
99
100    let parts = clean_path.split('/');
101    let mut s = String::with_capacity(clean_path.len() + 1);
102    for part in parts {
103        if part.is_empty() || part == "properties" {
104            continue;
105        }
106        s.push('/');
107        s.push_str(part);
108    }
109    
110    if s.is_empty() {
111        Cow::Borrowed("")
112    } else {
113        Cow::Owned(s)
114    }
115}
116
117/// Convert dotted path to JSON Schema pointer format
118///
119/// This is used for schema paths where properties are nested under `/properties/`
120///
121/// Examples:
122/// - "illustration.insured.name" -> "#/illustration/properties/insured/properties/name"
123/// - "header.form_number" -> "#/header/properties/form_number"
124/// - "#/already/formatted" -> "#/already/formatted" (no change)
125#[inline]
126pub fn dot_notation_to_schema_pointer(path: &str) -> String {
127    // If already a JSON pointer (starts with # or /), return as-is
128    if path.starts_with('#') || path.starts_with('/') {
129        return path.to_string();
130    }
131
132    // Check if it's explicitly a dotted schema pointer
133    if path.starts_with("properties.") || path.contains(".properties.") {
134        return format!("#/{}", path.replace('.', "/"));
135    }
136
137    // Split by dots and join with /properties/
138    let parts: Vec<&str> = path.split('.').collect();
139    if parts.is_empty() {
140        return "#/".to_string();
141    }
142
143    // Build schema path: #/part1/properties/part2/properties/part3
144    // First part is root-level field, rest are under /properties/
145    // Don't add /properties/ if path starts with $ (direct JSON pointer)
146    let mut result = String::from("#");
147    for (i, part) in parts.iter().enumerate() {
148        if part.eq(&"properties") {
149            continue;
150        }
151
152        if i > 0 && !path.starts_with('$') {
153            result.push_str("/properties");
154        }
155        result.push_str("/");
156        result.push_str(part);
157    }
158
159    result
160}
161
162/// Convert JSON pointer or schema pointer to dotted notation
163///
164/// This converts various pointer formats back to dotted notation:
165///
166/// Examples:
167/// - "#/illustration/properties/insured/properties/ins_corrname" -> "illustration.properties.insured.properties.ins_corrname"
168/// - "/user/name" -> "user.name"
169/// - "person.name" -> "person.name" (already dotted, no change)
170#[inline]
171pub fn pointer_to_dot_notation(path: &str) -> String {
172    if path.is_empty() {
173        return String::new();
174    }
175
176    // If already dotted notation (no # or / prefix), return as-is
177    if !path.starts_with('#') && !path.starts_with('/') {
178        return path.to_string();
179    }
180
181    // Remove leading # or /
182    let clean_path = if path.starts_with("#/") {
183        &path[2..]
184    } else if path.starts_with('/') {
185        &path[1..]
186    } else if path.starts_with('#') {
187        &path[1..]
188    } else {
189        path
190    };
191
192    // Convert slashes to dots
193    clean_path.replace('/', ".")
194}
195
196/// Canonicalize a path for schema lookups.
197///
198/// This performs a single-pass conversion that:
199/// 1. Normalizes the path to a JSON pointer (starts with /).
200/// 2. Injects `/properties/` segments for data paths (e.g., `a.b.c` -> `/a/properties/b/properties/c`).
201/// 3. Preserves system paths starting with `$` (e.g., `/$params` -> `/$params`).
202/// 4. Handles existing JSON pointers/schema refs by re-canonicalizing them.
203///
204/// Returns `Cow::Borrowed` if the path is already canonical.
205pub fn canonicalize_schema_path(path: &str) -> Cow<'_, str> {
206    if path.is_empty() {
207        return Cow::Borrowed("");
208    }
209
210    // Fast check for already normalized system paths
211    if path.starts_with("/$") && !path.contains('.') && !path.contains("//") {
212        return Cow::Borrowed(path);
213    }
214
215    // Identify system paths early
216    let is_system = path.starts_with('$') || path.starts_with("/$") || path.starts_with("#/$");
217
218    // Clean prefix and detect if we need to do work
219    let clean_path = if path.starts_with("#/") {
220        &path[2..]
221    } else if path.starts_with('/') {
222        &path[1..]
223    } else if path.starts_with('#') {
224        &path[1..]
225    } else {
226        path
227    };
228
229    // If it's a simple top-level field with no dots/slashes, and not system,
230    // we can just prepend / and return borrowed if it was already /field
231    if !is_system
232        && !clean_path.contains('.')
233        && !clean_path.contains('/')
234        && !clean_path.is_empty()
235    {
236        if path.starts_with('/') && path.len() == clean_path.len() + 1 {
237            return Cow::Borrowed(path);
238        }
239        let mut s = String::with_capacity(clean_path.len() + 1);
240        s.push('/');
241        s.push_str(clean_path);
242        return Cow::Owned(s);
243    }
244
245    // If the path explicitly uses schema pointer semantics, just normalize delimiters
246    if clean_path.starts_with("properties/") || clean_path.starts_with("properties.")
247        || clean_path.contains("/properties/") || clean_path.contains(".properties.") {
248        
249        let mut s = String::with_capacity(clean_path.len() + 1);
250        s.push('/');
251        for c in clean_path.chars() {
252            if c == '.' {
253                s.push('/');
254            } else {
255                s.push(c);
256            }
257        }
258        if s == path {
259            return Cow::Borrowed(path);
260        } else {
261            return Cow::Owned(s);
262        }
263    }
264
265    // Full decomposition and reconstruction
266    let mut result = String::with_capacity(path.len() * 2);
267    result.push('/');
268
269    let parts = clean_path.split(|c| c == '/' || c == '.');
270    let mut first = true;
271
272    for part in parts {
273        if part.is_empty() || part == "properties" {
274            continue;
275        }
276
277        if !first && !is_system {
278            result.push_str("properties/");
279        }
280        result.push_str(part);
281        result.push('/');
282        first = false;
283    }
284
285    if result.len() > 1 {
286        result.pop(); // Remove trailing slash
287    }
288
289    // If result matches original exactly, return borrowed
290    if result == path {
291        Cow::Borrowed(path)
292    } else {
293        Cow::Owned(result)
294    }
295}
296
297/// Fast JSON pointer-based value access using serde's native implementation
298///
299/// This is significantly faster than manual path traversal for deeply nested objects
300#[inline]
301pub fn get_value_by_pointer<'a>(data: &'a Value, pointer: &str) -> Option<&'a Value> {
302    if pointer.is_empty() {
303        Some(data)
304    } else {
305        data.pointer(pointer)
306    }
307}
308
309#[inline]
310pub fn get_value_by_pointer_without_properties<'a>(
311    data: &'a Value,
312    pointer: &str,
313) -> Option<&'a Value> {
314    if pointer.is_empty() {
315        Some(data)
316    } else {
317        data.pointer(&pointer.replace("properties/", ""))
318    }
319}
320
321/// Batch pointer resolution for multiple paths
322pub fn get_values_by_pointers<'a>(data: &'a Value, pointers: &[String]) -> Vec<Option<&'a Value>> {
323    pointers
324        .iter()
325        .map(|pointer| get_value_by_pointer(data, pointer))
326        .collect()
327}
328
329/// Fast array indexing helper for JSON arrays
330///
331/// Returns None if not an array or index out of bounds
332#[inline]
333pub fn get_array_element<'a>(data: &'a Value, index: usize) -> Option<&'a Value> {
334    data.as_array()?.get(index)
335}
336
337/// Fast array indexing with JSON pointer path
338///
339/// Example: get_array_element_by_pointer(data, "/$params/tables", 0)
340#[inline]
341pub fn get_array_element_by_pointer<'a>(
342    data: &'a Value,
343    pointer: &str,
344    index: usize,
345) -> Option<&'a Value> {
346    get_value_by_pointer(data, pointer)?.as_array()?.get(index)
347}
348
349/// Extract table metadata for fast array operations during schema parsing
350#[derive(Debug, Clone)]
351pub struct ArrayMetadata {
352    /// Pointer to the array location
353    pub pointer: String,
354    /// Array length (cached for fast bounds checking)
355    pub length: usize,
356    /// Column names for object arrays (cached for fast field access)
357    pub column_names: Vec<String>,
358    /// Whether this is a uniform object array (all elements have same structure)
359    pub is_uniform: bool,
360}
361
362impl ArrayMetadata {
363    /// Build metadata for an array at the given pointer
364    pub fn build(data: &Value, pointer: &str) -> Option<Self> {
365        let array = get_value_by_pointer(data, pointer)?.as_array()?;
366
367        let length = array.len();
368        if length == 0 {
369            return Some(ArrayMetadata {
370                pointer: pointer.to_string(),
371                length: 0,
372                column_names: Vec::new(),
373                is_uniform: true,
374            });
375        }
376
377        // Analyze first element to determine structure
378        let first_element = &array[0];
379        let column_names = if let Value::Object(obj) = first_element {
380            obj.keys().cloned().collect()
381        } else {
382            Vec::new()
383        };
384
385        // Check if all elements have the same structure (uniform array)
386        let is_uniform = if !column_names.is_empty() {
387            array.iter().all(|elem| {
388                if let Value::Object(obj) = elem {
389                    obj.keys().len() == column_names.len()
390                        && column_names.iter().all(|col| obj.contains_key(col))
391                } else {
392                    false
393                }
394            })
395        } else {
396            // Non-object arrays are considered uniform if all elements have same type
397            let first_type = std::mem::discriminant(first_element);
398            array
399                .iter()
400                .all(|elem| std::mem::discriminant(elem) == first_type)
401        };
402
403        Some(ArrayMetadata {
404            pointer: pointer.to_string(),
405            length,
406            column_names,
407            is_uniform,
408        })
409    }
410
411    /// Fast column access for uniform object arrays
412    #[inline]
413    pub fn get_column_value<'a>(
414        &self,
415        data: &'a Value,
416        row_index: usize,
417        column: &str,
418    ) -> Option<&'a Value> {
419        if !self.is_uniform || row_index >= self.length {
420            return None;
421        }
422
423        get_array_element_by_pointer(data, &self.pointer, row_index)?
424            .as_object()?
425            .get(column)
426    }
427
428    /// Fast bounds checking
429    #[inline]
430    pub fn is_valid_index(&self, index: usize) -> bool {
431        index < self.length
432    }
433}