Skip to main content

velesdb_core/velesql/
json_path.rs

1//! JSON Path parser for nested field access (EPIC-052 US-005).
2//!
3//! Supports dot notation (`metadata.source`) and array indexing (`items[0].sku`)
4//! for GROUP BY on nested JSON fields.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// Error type for JSON path parsing.
10#[derive(Debug, Clone, PartialEq)]
11#[non_exhaustive]
12pub enum JsonPathError {
13    /// Empty path provided.
14    EmptyPath,
15    /// Invalid array index (not a number).
16    InvalidArrayIndex(String),
17    /// Unclosed bracket.
18    UnclosedBracket,
19    /// Empty segment (double dot).
20    EmptySegment,
21}
22
23impl std::fmt::Display for JsonPathError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::EmptyPath => write!(f, "Empty JSON path"),
27            Self::InvalidArrayIndex(s) => write!(f, "Invalid array index: '{s}'"),
28            Self::UnclosedBracket => write!(f, "Unclosed bracket in JSON path"),
29            Self::EmptySegment => write!(f, "Empty segment in JSON path (double dot)"),
30        }
31    }
32}
33
34impl std::error::Error for JsonPathError {}
35
36/// A segment in a JSON path.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[non_exhaustive]
39pub enum PathSegment {
40    /// Object property access: `.field`
41    Property(String),
42    /// Array index access: `[0]`
43    Index(usize),
44}
45
46/// Parsed JSON path for nested field access.
47///
48/// # Examples
49///
50/// ```rust
51/// use velesdb_core::velesql::json_path::JsonPath;
52///
53/// let path = JsonPath::parse("metadata.source").unwrap();
54/// assert_eq!(path.segments.len(), 2);
55///
56/// let path = JsonPath::parse("items[0].sku").unwrap();
57/// assert_eq!(path.segments.len(), 3);
58/// ```
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct JsonPath {
61    /// The segments of the path.
62    pub segments: Vec<PathSegment>,
63}
64
65impl JsonPath {
66    /// Creates a new empty `JsonPath`.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            segments: Vec::new(),
71        }
72    }
73
74    /// Creates a `JsonPath` from a single property name.
75    #[must_use]
76    pub fn from_property(name: &str) -> Self {
77        Self {
78            segments: vec![PathSegment::Property(name.to_string())],
79        }
80    }
81
82    /// Parses a JSON path string like `"metadata.source"` or `"items[0].sku"`.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the path is malformed.
87    pub fn parse(input: &str) -> Result<Self, JsonPathError> {
88        let input = input.trim();
89        if input.is_empty() {
90            return Err(JsonPathError::EmptyPath);
91        }
92
93        let mut segments = Vec::new();
94        let mut current = String::new();
95        let mut chars = input.chars().peekable();
96        let mut last_was_index = false;
97
98        while let Some(c) = chars.next() {
99            match c {
100                '.' => Self::handle_dot(&mut segments, &mut current, &mut last_was_index)?,
101                '[' => {
102                    Self::flush_property(&mut segments, &mut current);
103                    let index = Self::parse_bracket_index(&mut chars)?;
104                    segments.push(PathSegment::Index(index));
105                    last_was_index = true;
106                }
107                _ => {
108                    current.push(c);
109                    last_was_index = false;
110                }
111            }
112        }
113
114        if !current.is_empty() {
115            segments.push(PathSegment::Property(current));
116        }
117
118        if segments.is_empty() {
119            return Err(JsonPathError::EmptyPath);
120        }
121
122        Ok(JsonPath { segments })
123    }
124
125    /// Pushes the accumulated `current` buffer as a property segment and clears it.
126    fn flush_property(segments: &mut Vec<PathSegment>, current: &mut String) {
127        if !current.is_empty() {
128            segments.push(PathSegment::Property(std::mem::take(current)));
129        }
130    }
131
132    /// Handles a `.` separator: validates against empty segments, then flushes.
133    fn handle_dot(
134        segments: &mut Vec<PathSegment>,
135        current: &mut String,
136        last_was_index: &mut bool,
137    ) -> Result<(), JsonPathError> {
138        // After an index like [0], a dot is valid and just separates.
139        if current.is_empty() && !*last_was_index && !segments.is_empty() {
140            return Err(JsonPathError::EmptySegment);
141        }
142        Self::flush_property(segments, current);
143        *last_was_index = false;
144        Ok(())
145    }
146
147    /// Parses the contents of a `[...]` array index after the opening `[`.
148    fn parse_bracket_index(
149        chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
150    ) -> Result<usize, JsonPathError> {
151        let mut idx_str = String::new();
152        let mut closed = false;
153        for ch in chars.by_ref() {
154            if ch == ']' {
155                closed = true;
156                break;
157            }
158            idx_str.push(ch);
159        }
160        if !closed {
161            return Err(JsonPathError::UnclosedBracket);
162        }
163        idx_str
164            .trim()
165            .parse()
166            .map_err(|_| JsonPathError::InvalidArrayIndex(idx_str))
167    }
168
169    /// Returns true if this is a simple (non-nested) path with a single property.
170    #[must_use]
171    pub fn is_simple(&self) -> bool {
172        self.segments.len() == 1 && matches!(self.segments.first(), Some(PathSegment::Property(_)))
173    }
174
175    /// Returns the root property name, if the path starts with a property.
176    #[must_use]
177    pub fn root_property(&self) -> Option<&str> {
178        match self.segments.first() {
179            Some(PathSegment::Property(name)) => Some(name),
180            _ => None,
181        }
182    }
183
184    /// Returns a sub-path excluding the first segment.
185    #[must_use]
186    pub fn tail(&self) -> Self {
187        Self {
188            segments: self.segments.iter().skip(1).cloned().collect(),
189        }
190    }
191
192    /// Extracts a value from a JSON document following this path.
193    ///
194    /// Returns `None` if any segment doesn't match.
195    #[must_use]
196    pub fn extract<'a>(&self, doc: &'a Value) -> Option<&'a Value> {
197        let mut current = doc;
198
199        for segment in &self.segments {
200            current = match segment {
201                PathSegment::Property(key) => current.get(key)?,
202                PathSegment::Index(idx) => current.get(idx)?,
203            };
204        }
205
206        Some(current)
207    }
208
209    /// Extracts a value and clones it, returning `Value::Null` if not found.
210    #[must_use]
211    pub fn extract_or_null(&self, doc: &Value) -> Value {
212        self.extract(doc).cloned().unwrap_or(Value::Null)
213    }
214}
215
216impl Default for JsonPath {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222impl std::fmt::Display for JsonPath {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        let mut first = true;
225        for segment in &self.segments {
226            match segment {
227                PathSegment::Property(name) => {
228                    if first {
229                        write!(f, "{name}")?;
230                    } else {
231                        write!(f, ".{name}")?;
232                    }
233                }
234                PathSegment::Index(idx) => {
235                    write!(f, "[{idx}]")?;
236                }
237            }
238            first = false;
239        }
240        Ok(())
241    }
242}
243
244// Tests moved to json_path_tests.rs per project rules