Skip to main content

nodedb_sql/parser/
object_literal.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Parser for `{ key: value }` object literal syntax.
4
5use std::collections::HashMap;
6
7use nodedb_types::Value;
8
9use crate::error::SqlError;
10
11/// Parse a `{ key: value, ... }` object literal into a field map.
12///
13/// Returns `None` if the input doesn't start with `{` (not an object literal).
14/// Returns `Some(Err(msg))` on parse errors (malformed object literal).
15/// Returns `Some(Ok(fields))` on success.
16pub fn parse_object_literal(s: &str) -> Option<Result<HashMap<String, Value>, SqlError>> {
17    let trimmed = s.trim();
18    if !trimmed.starts_with('{') {
19        return None;
20    }
21    let chars: Vec<char> = trimmed.chars().collect();
22    let mut pos = 0;
23    Some(parse_object(&chars, &mut pos))
24}
25
26/// Parse `[{ ... }, { ... }]` — an array of object literals for batch insert.
27///
28/// Returns `None` if the input doesn't start with `[` (not an array literal).
29/// Returns `Some(Err(msg))` on parse errors.
30/// Returns `Some(Ok(vec))` on success — each element must be an object.
31pub fn parse_object_literal_array(
32    s: &str,
33) -> Option<Result<Vec<HashMap<String, Value>>, SqlError>> {
34    let trimmed = s.trim();
35    if !trimmed.starts_with('[') {
36        return None;
37    }
38    let chars: Vec<char> = trimmed.chars().collect();
39    let mut pos = 0;
40
41    // Consume '['
42    pos += 1;
43    let mut objects = Vec::new();
44    loop {
45        skip_ws(&chars, &mut pos);
46        if pos >= chars.len() {
47            return Some(Err(SqlError::Parse {
48                detail: "unterminated array of objects".to_string(),
49            }));
50        }
51        if chars[pos] == ']' {
52            break;
53        }
54        if chars[pos] == ',' {
55            pos += 1;
56            continue;
57        }
58        if chars[pos] != '{' {
59            return Some(Err(SqlError::Parse {
60                detail: format!("expected '{{' at position {pos}, found '{}'", chars[pos]),
61            }));
62        }
63        match parse_object(&chars, &mut pos) {
64            Ok(obj) => objects.push(obj),
65            Err(e) => return Some(Err(e)),
66        }
67        skip_ws(&chars, &mut pos);
68        if pos < chars.len() && chars[pos] == ',' {
69            pos += 1;
70        }
71    }
72    Some(Ok(objects))
73}
74
75fn skip_ws(chars: &[char], pos: &mut usize) {
76    while *pos < chars.len() && chars[*pos].is_ascii_whitespace() {
77        *pos += 1;
78    }
79}
80
81fn parse_ident(chars: &[char], pos: &mut usize) -> String {
82    let mut s = String::new();
83    while *pos < chars.len() {
84        let c = chars[*pos];
85        if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
86            s.push(c);
87            *pos += 1;
88        } else {
89            break;
90        }
91    }
92    s
93}
94
95fn parse_string(chars: &[char], pos: &mut usize) -> Result<String, SqlError> {
96    // Expect opening single-quote
97    if *pos >= chars.len() || chars[*pos] != '\'' {
98        return Err(SqlError::Parse {
99            detail: format!(
100                "expected single quote at position {}, found {:?}",
101                pos,
102                chars.get(*pos)
103            ),
104        });
105    }
106    *pos += 1; // consume opening quote
107    let mut s = String::new();
108    loop {
109        if *pos >= chars.len() {
110            return Err(SqlError::Parse {
111                detail: "unterminated string literal".to_string(),
112            });
113        }
114        if chars[*pos] == '\'' {
115            *pos += 1; // consume quote
116            // SQL escaped quote: '' → '
117            if *pos < chars.len() && chars[*pos] == '\'' {
118                s.push('\'');
119                *pos += 1;
120            } else {
121                break; // end of string
122            }
123        } else {
124            s.push(chars[*pos]);
125            *pos += 1;
126        }
127    }
128    Ok(s)
129}
130
131fn parse_double_quoted_string(chars: &[char], pos: &mut usize) -> Result<String, SqlError> {
132    if *pos >= chars.len() || chars[*pos] != '"' {
133        return Err(SqlError::Parse {
134            detail: format!(
135                "expected double quote at position {}, found {:?}",
136                pos,
137                chars.get(*pos)
138            ),
139        });
140    }
141    *pos += 1;
142    let mut s = String::new();
143    loop {
144        if *pos >= chars.len() {
145            return Err(SqlError::Parse {
146                detail: "unterminated double-quoted string literal".to_string(),
147            });
148        }
149        match chars[*pos] {
150            '"' => {
151                *pos += 1;
152                if *pos < chars.len() && chars[*pos] == '"' {
153                    s.push('"');
154                    *pos += 1;
155                } else {
156                    break;
157                }
158            }
159            c => {
160                s.push(c);
161                *pos += 1;
162            }
163        }
164    }
165    Ok(s)
166}
167
168fn parse_number(chars: &[char], pos: &mut usize) -> Result<Value, SqlError> {
169    let start = *pos;
170    if *pos < chars.len() && chars[*pos] == '-' {
171        *pos += 1;
172    }
173    while *pos < chars.len() && chars[*pos].is_ascii_digit() {
174        *pos += 1;
175    }
176    let is_float = *pos < chars.len() && chars[*pos] == '.';
177    if is_float {
178        *pos += 1; // consume '.'
179        while *pos < chars.len() && chars[*pos].is_ascii_digit() {
180            *pos += 1;
181        }
182    }
183    let raw: String = chars[start..*pos].iter().collect();
184    if is_float {
185        raw.parse::<f64>()
186            .map(Value::Float)
187            .map_err(|_| SqlError::Parse {
188                detail: format!("invalid float: {raw}"),
189            })
190    } else {
191        raw.parse::<i64>()
192            .map(Value::Integer)
193            .map_err(|_| SqlError::Parse {
194                detail: format!("invalid integer: {raw}"),
195            })
196    }
197}
198
199fn parse_array(chars: &[char], pos: &mut usize) -> Result<Vec<Value>, SqlError> {
200    // Expect '['
201    if *pos >= chars.len() || chars[*pos] != '[' {
202        return Err(SqlError::Parse {
203            detail: format!(
204                "expected '[' at position {pos}, found {:?}",
205                chars.get(*pos)
206            ),
207        });
208    }
209    *pos += 1; // consume '['
210    let mut items = Vec::new();
211    loop {
212        skip_ws(chars, pos);
213        if *pos >= chars.len() {
214            return Err(SqlError::Parse {
215                detail: "unterminated array literal".to_string(),
216            });
217        }
218        if chars[*pos] == ']' {
219            *pos += 1; // consume ']'
220            break;
221        }
222        // trailing comma already consumed; skip it
223        if chars[*pos] == ',' {
224            *pos += 1;
225            continue;
226        }
227        let val = parse_value(chars, pos)?;
228        items.push(val);
229        skip_ws(chars, pos);
230        if *pos < chars.len() && chars[*pos] == ',' {
231            *pos += 1; // consume ','
232        }
233    }
234    Ok(items)
235}
236
237fn parse_object(chars: &[char], pos: &mut usize) -> Result<HashMap<String, Value>, SqlError> {
238    // Expect '{'
239    if *pos >= chars.len() || chars[*pos] != '{' {
240        return Err(SqlError::Parse {
241            detail: format!(
242                "expected '{{' at position {pos}, found {:?}",
243                chars.get(*pos)
244            ),
245        });
246    }
247    *pos += 1; // consume '{'
248    let mut map = HashMap::new();
249    loop {
250        skip_ws(chars, pos);
251        if *pos >= chars.len() {
252            return Err(SqlError::Parse {
253                detail: "unterminated object literal".to_string(),
254            });
255        }
256        if chars[*pos] == '}' {
257            *pos += 1; // consume '}'
258            break;
259        }
260        // Trailing comma: skip and re-check for '}'
261        if chars[*pos] == ',' {
262            *pos += 1;
263            continue;
264        }
265
266        // Parse key (identifier or JSON-style quoted key).
267        skip_ws(chars, pos);
268        if *pos >= chars.len() {
269            return Err(SqlError::Parse {
270                detail: "expected key, reached end of input".to_string(),
271            });
272        }
273        let key = if chars[*pos] == '"' {
274            parse_double_quoted_string(chars, pos)?
275        } else {
276            let first = chars[*pos];
277            if !(first.is_ascii_alphabetic() || first == '_') {
278                return Err(SqlError::Parse {
279                    detail: format!("expected identifier key at position {pos}, found '{first}'"),
280                });
281            }
282            parse_ident(chars, pos)
283        };
284        if key.is_empty() {
285            return Err(SqlError::Parse {
286                detail: format!("expected non-empty key at position {pos}"),
287            });
288        }
289
290        // Expect ':'
291        skip_ws(chars, pos);
292        if *pos >= chars.len() || chars[*pos] != ':' {
293            return Err(SqlError::Parse {
294                detail: format!(
295                    "expected ':' after key '{key}' at position {pos}, found {:?}",
296                    chars.get(*pos)
297                ),
298            });
299        }
300        *pos += 1; // consume ':'
301
302        // Parse value
303        skip_ws(chars, pos);
304        if *pos >= chars.len() {
305            return Err(SqlError::Parse {
306                detail: format!("expected value for key '{key}', reached end of input"),
307            });
308        }
309        if chars[*pos] == '}' || chars[*pos] == ',' {
310            return Err(SqlError::Parse {
311                detail: format!("expected value for key '{key}', found '{}'", chars[*pos]),
312            });
313        }
314        let val = parse_value(chars, pos)?;
315        map.insert(key, val);
316
317        // Optional comma
318        skip_ws(chars, pos);
319        if *pos < chars.len() && chars[*pos] == ',' {
320            *pos += 1;
321        }
322    }
323    Ok(map)
324}
325
326fn parse_value(chars: &[char], pos: &mut usize) -> Result<Value, SqlError> {
327    skip_ws(chars, pos);
328    if *pos >= chars.len() {
329        return Err(SqlError::Parse {
330            detail: "unexpected end of input while parsing value".to_string(),
331        });
332    }
333    match chars[*pos] {
334        '\'' => parse_string(chars, pos).map(Value::String),
335        '{' => parse_object(chars, pos).map(Value::Object),
336        '[' => parse_array(chars, pos).map(Value::Array),
337        '-' | '0'..='9' => parse_number(chars, pos),
338        _ => {
339            // bare word: true / false / null / identifier
340            let word = parse_ident(chars, pos);
341            match word.to_lowercase().as_str() {
342                "true" => Ok(Value::Bool(true)),
343                "false" => Ok(Value::Bool(false)),
344                "null" => Ok(Value::Null),
345                _ if word.is_empty() => Err(SqlError::Parse {
346                    detail: format!("unexpected character '{}' at position {pos}", chars[*pos]),
347                }),
348                _ => Err(SqlError::Parse {
349                    detail: format!("unknown bare word: '{word}'"),
350                }),
351            }
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn parse(s: &str) -> HashMap<String, Value> {
361        parse_object_literal(s).unwrap().unwrap()
362    }
363
364    #[test]
365    fn simple_string_and_int() {
366        let m = parse("{ name: 'Alice', age: 30 }");
367        assert_eq!(m["name"], Value::String("Alice".to_string()));
368        assert_eq!(m["age"], Value::Integer(30));
369    }
370
371    #[test]
372    fn nested_object() {
373        let m = parse("{ addr: { city: 'NYC' } }");
374        let inner = match &m["addr"] {
375            Value::Object(o) => o,
376            _ => panic!("expected Object"),
377        };
378        assert_eq!(inner["city"], Value::String("NYC".to_string()));
379    }
380
381    #[test]
382    fn array_value() {
383        let m = parse("{ tags: ['a', 'b'] }");
384        assert_eq!(
385            m["tags"],
386            Value::Array(vec![
387                Value::String("a".to_string()),
388                Value::String("b".to_string()),
389            ])
390        );
391    }
392
393    #[test]
394    fn mixed_types() {
395        let m = parse("{ a: 'str', b: 42, c: 2.78, d: true, e: false, f: null }");
396        assert_eq!(m["a"], Value::String("str".to_string()));
397        assert_eq!(m["b"], Value::Integer(42));
398        assert_eq!(m["c"], Value::Float(2.78));
399        assert_eq!(m["d"], Value::Bool(true));
400        assert_eq!(m["e"], Value::Bool(false));
401        assert_eq!(m["f"], Value::Null);
402    }
403
404    #[test]
405    fn escaped_quotes() {
406        let m = parse("{ name: 'O''Brien' }");
407        assert_eq!(m["name"], Value::String("O'Brien".to_string()));
408    }
409
410    #[test]
411    fn empty_object() {
412        let m = parse("{ }");
413        assert!(m.is_empty());
414    }
415
416    #[test]
417    fn trailing_comma() {
418        let m = parse("{ name: 'Alice', }");
419        assert_eq!(m["name"], Value::String("Alice".to_string()));
420    }
421
422    #[test]
423    fn not_an_object_returns_none() {
424        assert!(parse_object_literal("not an object").is_none());
425    }
426
427    #[test]
428    fn missing_value_returns_err() {
429        let result = parse_object_literal("{ name: }");
430        assert!(matches!(result, Some(Err(_))));
431    }
432
433    #[test]
434    fn missing_key_returns_err() {
435        let result = parse_object_literal("{ : 'val' }");
436        assert!(matches!(result, Some(Err(_))));
437    }
438
439    #[test]
440    fn negative_numbers() {
441        let m = parse("{ x: -42, y: -2.78 }");
442        assert_eq!(m["x"], Value::Integer(-42));
443        assert_eq!(m["y"], Value::Float(-2.78));
444    }
445
446    #[test]
447    fn nested_array_in_object() {
448        let m = parse("{ data: { items: [1, 2, 3] } }");
449        let inner = match &m["data"] {
450            Value::Object(o) => o,
451            _ => panic!("expected Object"),
452        };
453        assert_eq!(
454            inner["items"],
455            Value::Array(vec![
456                Value::Integer(1),
457                Value::Integer(2),
458                Value::Integer(3),
459            ])
460        );
461    }
462
463    #[test]
464    fn dotted_key() {
465        let m = parse("{ metadata.source: 'web' }");
466        assert_eq!(m["metadata.source"], Value::String("web".to_string()));
467    }
468
469    #[test]
470    fn parse_array_of_objects() {
471        let result = parse_object_literal_array("[{ name: 'Alice' }, { name: 'Bob' }]")
472            .unwrap()
473            .unwrap();
474        assert_eq!(result.len(), 2);
475        assert_eq!(result[0]["name"], Value::String("Alice".to_string()));
476        assert_eq!(result[1]["name"], Value::String("Bob".to_string()));
477    }
478
479    #[test]
480    fn parse_array_empty() {
481        let result = parse_object_literal_array("[]").unwrap().unwrap();
482        assert!(result.is_empty());
483    }
484
485    #[test]
486    fn parse_array_not_array_returns_none() {
487        assert!(parse_object_literal_array("{ name: 'Alice' }").is_none());
488    }
489
490    #[test]
491    fn parse_array_non_object_element_returns_err() {
492        let result = parse_object_literal_array("[42]");
493        assert!(matches!(result, Some(Err(_))));
494    }
495
496    #[test]
497    fn parse_array_trailing_comma() {
498        let result = parse_object_literal_array("[{ a: 1 }, { b: 2 },]")
499            .unwrap()
500            .unwrap();
501        assert_eq!(result.len(), 2);
502    }
503}