Skip to main content

nodedb_sql/parser/object_literal/
api.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The public entry points: prefix parsing, strict whole-input parsing, and the
4//! leftover reporting that separates them.
5
6use std::collections::HashMap;
7
8use nodedb_types::Value;
9
10use crate::error::SqlError;
11
12use super::scan::{parse_object, skip_ws};
13
14/// Parse a `{ key: value, ... }` object literal at the START of `s`.
15///
16/// **This is a PREFIX parser and stops at the matching `}`.** Anything after
17/// that brace is left unexamined, deliberately: the function-argument rewriter
18/// (`preprocess::function_args`) hands it the entire remainder of an argument
19/// list starting at `{` and resumes from the matching brace itself, so it needs
20/// a parser that reads one literal and stops.
21///
22/// That tolerance is exactly wrong for a caller whose input is supposed to BE
23/// the literal — there, unexamined trailing text is input the caller wrote and
24/// the parser threw away without saying so. Those callers use
25/// [`parse_object_literal_complete`], which reports the leftover instead.
26///
27/// Returns `None` if the input doesn't start with `{` (not an object literal).
28/// Returns `Some(Err(msg))` on parse errors (malformed object literal).
29/// Returns `Some(Ok(fields))` on success.
30pub fn parse_object_literal(s: &str) -> Option<Result<HashMap<String, Value>, SqlError>> {
31    let trimmed = s.trim();
32    if !trimmed.starts_with('{') {
33        return None;
34    }
35    let chars: Vec<char> = trimmed.chars().collect();
36    let mut pos = 0;
37    Some(parse_object(&chars, &mut pos))
38}
39
40/// Parse a `{ key: value, ... }` object literal that must be the WHOLE input.
41///
42/// The strict form of [`parse_object_literal`], for callers whose input is the
43/// literal and nothing else. Trailing text is an error naming what was left
44/// over, so a clause the caller wrote can never be discarded in silence. A
45/// trailing `;` is accepted — it terminates the statement, it is not content.
46pub fn parse_object_literal_complete(s: &str) -> Option<Result<HashMap<String, Value>, SqlError>> {
47    let trimmed = s.trim();
48    if !trimmed.starts_with('{') {
49        return None;
50    }
51    let chars: Vec<char> = trimmed.chars().collect();
52    let mut pos = 0;
53    Some(
54        parse_object(&chars, &mut pos).and_then(|fields| match leftover(&chars, pos) {
55            Some(rest) => Err(trailing_input_error("object literal", &rest)),
56            None => Ok(fields),
57        }),
58    )
59}
60
61/// Parse an array of object literals that must be the WHOLE input.
62///
63/// The strict form of [`parse_object_literal_array`], with the same contract as
64/// [`parse_object_literal_complete`].
65pub fn parse_object_literal_array_complete(
66    s: &str,
67) -> Option<Result<Vec<HashMap<String, Value>>, SqlError>> {
68    let trimmed = s.trim();
69    if !trimmed.starts_with('[') {
70        return None;
71    }
72    let objects = match parse_object_literal_array(trimmed)? {
73        Ok(objects) => objects,
74        Err(error) => return Some(Err(error)),
75    };
76    // The array parser stops at the closing `]`; locate it the same way it
77    // does — by balancing brackets outside quoted strings — so the leftover is
78    // measured against the literal's real end rather than the last `]` in the
79    // input, which a quoted value could contain.
80    let chars: Vec<char> = trimmed.chars().collect();
81    let Some(end) = matching_bracket(&chars) else {
82        return Some(Err(SqlError::Parse {
83            detail: "unterminated array of objects".to_string(),
84        }));
85    };
86    Some(match leftover(&chars, end + 1) {
87        Some(rest) => Err(trailing_input_error("array of object literals", &rest)),
88        None => Ok(objects),
89    })
90}
91
92/// The non-whitespace text remaining from `pos`, ignoring a trailing `;`.
93///
94/// `None` means the literal consumed everything meaningful.
95fn leftover(chars: &[char], pos: usize) -> Option<String> {
96    let rest: String = chars.get(pos..).unwrap_or_default().iter().collect();
97    let rest = rest.trim().trim_end_matches(';').trim();
98    if rest.is_empty() {
99        None
100    } else {
101        Some(rest.to_string())
102    }
103}
104
105/// The error a caller sees when it wrote something the literal form cannot
106/// carry. It names the leftover, because a message that only says "trailing
107/// input" leaves the author guessing which part of their statement was
108/// rejected.
109fn trailing_input_error(what: &str, rest: &str) -> SqlError {
110    SqlError::Parse {
111        detail: format!(
112            "unexpected input after {what}: `{rest}`. The brace form takes no trailing clause — \
113             write `INSERT INTO <collection> (cols) VALUES (...) {rest}` to use it"
114        ),
115    }
116}
117
118/// Index of the `]` closing the array that starts at index 0, skipping
119/// brackets inside single-quoted strings.
120fn matching_bracket(chars: &[char]) -> Option<usize> {
121    let mut depth = 0usize;
122    let mut in_string = false;
123    let mut i = 0;
124    while i < chars.len() {
125        match chars[i] {
126            '\'' if in_string && chars.get(i + 1) == Some(&'\'') => i += 1,
127            '\'' => in_string = !in_string,
128            '[' if !in_string => depth += 1,
129            ']' if !in_string => {
130                depth = depth.saturating_sub(1);
131                if depth == 0 {
132                    return Some(i);
133                }
134            }
135            _ => {}
136        }
137        i += 1;
138    }
139    None
140}
141
142/// Parse `[{ ... }, { ... }]` — an array of object literals for batch insert.
143///
144/// Returns `None` if the input doesn't start with `[` (not an array literal).
145/// Returns `Some(Err(msg))` on parse errors.
146/// Returns `Some(Ok(vec))` on success — each element must be an object.
147pub fn parse_object_literal_array(
148    s: &str,
149) -> Option<Result<Vec<HashMap<String, Value>>, SqlError>> {
150    let trimmed = s.trim();
151    if !trimmed.starts_with('[') {
152        return None;
153    }
154    let chars: Vec<char> = trimmed.chars().collect();
155    let mut pos = 0;
156
157    // Consume '['
158    pos += 1;
159    let mut objects = Vec::new();
160    loop {
161        skip_ws(&chars, &mut pos);
162        if pos >= chars.len() {
163            return Some(Err(SqlError::Parse {
164                detail: "unterminated array of objects".to_string(),
165            }));
166        }
167        if chars[pos] == ']' {
168            break;
169        }
170        if chars[pos] == ',' {
171            pos += 1;
172            continue;
173        }
174        if chars[pos] != '{' {
175            return Some(Err(SqlError::Parse {
176                detail: format!("expected '{{' at position {pos}, found '{}'", chars[pos]),
177            }));
178        }
179        match parse_object(&chars, &mut pos) {
180            Ok(obj) => objects.push(obj),
181            Err(e) => return Some(Err(e)),
182        }
183        skip_ws(&chars, &mut pos);
184        if pos < chars.len() && chars[pos] == ',' {
185            pos += 1;
186        }
187    }
188    Some(Ok(objects))
189}