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/")
247        || clean_path.starts_with("properties.")
248        || clean_path.contains("/properties/")
249        || clean_path.contains(".properties.")
250    {
251        let mut s = String::with_capacity(clean_path.len() + 1);
252        s.push('/');
253        for c in clean_path.chars() {
254            if c == '.' {
255                s.push('/');
256            } else {
257                s.push(c);
258            }
259        }
260        if s == path {
261            return Cow::Borrowed(path);
262        } else {
263            return Cow::Owned(s);
264        }
265    }
266
267    // Full decomposition and reconstruction
268    let mut result = String::with_capacity(path.len() * 2);
269    result.push('/');
270
271    let parts = clean_path.split(|c| c == '/' || c == '.');
272    let mut first = true;
273
274    for part in parts {
275        if part.is_empty() || part == "properties" {
276            continue;
277        }
278
279        if !first && !is_system {
280            result.push_str("properties/");
281        }
282        result.push_str(part);
283        result.push('/');
284        first = false;
285    }
286
287    if result.len() > 1 {
288        result.pop(); // Remove trailing slash
289    }
290
291    // If result matches original exactly, return borrowed
292    if result == path {
293        Cow::Borrowed(path)
294    } else {
295        Cow::Owned(result)
296    }
297}
298
299/// Fast JSON pointer-based value access using serde's native implementation
300///
301/// This is significantly faster than manual path traversal for deeply nested objects
302#[inline]
303pub fn get_value_by_pointer<'a>(data: &'a Value, pointer: &str) -> Option<&'a Value> {
304    if pointer.is_empty() {
305        Some(data)
306    } else {
307        data.pointer(pointer)
308    }
309}
310
311#[inline]
312pub fn get_value_by_pointer_without_properties<'a>(
313    data: &'a Value,
314    pointer: &str,
315) -> Option<&'a Value> {
316    if pointer.is_empty() {
317        Some(data)
318    } else {
319        data.pointer(&pointer.replace("properties/", ""))
320    }
321}
322
323/// Batch pointer resolution for multiple paths
324pub fn get_values_by_pointers<'a>(data: &'a Value, pointers: &[String]) -> Vec<Option<&'a Value>> {
325    pointers
326        .iter()
327        .map(|pointer| get_value_by_pointer(data, pointer))
328        .collect()
329}
330
331/// Fast array indexing helper for JSON arrays
332///
333/// Returns None if not an array or index out of bounds
334#[inline]
335pub fn get_array_element<'a>(data: &'a Value, index: usize) -> Option<&'a Value> {
336    data.as_array()?.get(index)
337}
338
339/// Fast array indexing with JSON pointer path
340///
341/// Example: get_array_element_by_pointer(data, "/$params/tables", 0)
342#[inline]
343pub fn get_array_element_by_pointer<'a>(
344    data: &'a Value,
345    pointer: &str,
346    index: usize,
347) -> Option<&'a Value> {
348    get_value_by_pointer(data, pointer)?.as_array()?.get(index)
349}
350
351/// Extract table metadata for fast array operations during schema parsing
352#[derive(Debug, Clone)]
353pub struct ArrayMetadata {
354    /// Pointer to the array location
355    pub pointer: String,
356    /// Array length (cached for fast bounds checking)
357    pub length: usize,
358    /// Column names for object arrays (cached for fast field access)
359    pub column_names: Vec<String>,
360    /// Whether this is a uniform object array (all elements have same structure)
361    pub is_uniform: bool,
362}
363
364impl ArrayMetadata {
365    /// Build metadata for an array at the given pointer
366    pub fn build(data: &Value, pointer: &str) -> Option<Self> {
367        let array = get_value_by_pointer(data, pointer)?.as_array()?;
368
369        let length = array.len();
370        if length == 0 {
371            return Some(ArrayMetadata {
372                pointer: pointer.to_string(),
373                length: 0,
374                column_names: Vec::new(),
375                is_uniform: true,
376            });
377        }
378
379        // Analyze first element to determine structure
380        let first_element = &array[0];
381        let column_names = if let Value::Object(obj) = first_element {
382            obj.keys().cloned().collect()
383        } else {
384            Vec::new()
385        };
386
387        // Check if all elements have the same structure (uniform array)
388        let is_uniform = if !column_names.is_empty() {
389            array.iter().all(|elem| {
390                if let Value::Object(obj) = elem {
391                    obj.keys().len() == column_names.len()
392                        && column_names.iter().all(|col| obj.contains_key(col))
393                } else {
394                    false
395                }
396            })
397        } else {
398            // Non-object arrays are considered uniform if all elements have same type
399            let first_type = std::mem::discriminant(first_element);
400            array
401                .iter()
402                .all(|elem| std::mem::discriminant(elem) == first_type)
403        };
404
405        Some(ArrayMetadata {
406            pointer: pointer.to_string(),
407            length,
408            column_names,
409            is_uniform,
410        })
411    }
412
413    /// Fast column access for uniform object arrays
414    #[inline]
415    pub fn get_column_value<'a>(
416        &self,
417        data: &'a Value,
418        row_index: usize,
419        column: &str,
420    ) -> Option<&'a Value> {
421        if !self.is_uniform || row_index >= self.length {
422            return None;
423        }
424
425        get_array_element_by_pointer(data, &self.pointer, row_index)?
426            .as_object()?
427            .get(column)
428    }
429
430    /// Fast bounds checking
431    #[inline]
432    pub fn is_valid_index(&self, index: usize) -> bool {
433        index < self.length
434    }
435}