Skip to main content

sql_ddl_to_json_schema/
parser.rs

1//! The public `Parser`: streaming statement splitter plus statement parsing.
2
3use serde_json::{json, Value};
4
5use crate::compact;
6use crate::grammar::parse_statement;
7use crate::json_schema;
8
9/// Error returned by parser operations.
10///
11/// Marked `#[non_exhaustive]` so new variants can be added without a breaking
12/// change. Match arms on this type must include a wildcard.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum Error {
16    /// An unsupported SQL dialect was given to the constructor.
17    UnsupportedDialect(String),
18    /// A statement failed to parse. The message contains the line number in
19    /// stream coordinates.
20    Parse(String),
21    /// A formatter received JSON that was not a valid parse tree root.
22    Format(String),
23}
24
25impl std::fmt::Display for Error {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Error::UnsupportedDialect(d) => write!(
29                f,
30                "Unsupported SQL dialect given to parser: '{}'. Please provide 'mysql', 'mariadb' or none to use default.",
31                d
32            ),
33            Error::Parse(m) => write!(f, "{}", m),
34            Error::Format(m) => write!(f, "{}", m),
35        }
36    }
37}
38
39impl std::error::Error for Error {}
40
41/// Options for JSON Schema output.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct JsonSchemaOptions {
44    /// Whether to keep column schemas under `definitions` and reference them
45    /// with `$ref`. When false, schemas are flattened into `properties`.
46    pub use_ref: bool,
47}
48
49impl Default for JsonSchemaOptions {
50    fn default() -> Self {
51        JsonSchemaOptions { use_ref: true }
52    }
53}
54
55/// Parser for MySQL and MariaDB DDL.
56///
57/// Feed SQL in one or more chunks, then read the parse tree, the compact table
58/// model, or JSON Schema documents.
59///
60/// ```
61/// use sql_ddl_to_json_schema::Parser;
62///
63/// let mut parser = Parser::new("mysql").unwrap();
64/// parser.feed("CREATE TABLE t (id INT PRIMARY KEY);");
65/// let tables = parser.parse_compact().unwrap();
66/// assert_eq!(tables[0]["name"], "t");
67/// ```
68pub struct Parser {
69    statements: Vec<String>,
70    remains: String,
71    escaped: bool,
72    quoted: Option<char>,
73}
74
75impl Parser {
76    /// Build a parser for the given dialect.
77    ///
78    /// An empty string, `"mysql"`, or `"mariadb"` selects the MySQL grammar.
79    /// Any other value returns [`Error::UnsupportedDialect`].
80    pub fn new(dialect: &str) -> Result<Self, Error> {
81        if dialect.is_empty() || dialect == "mysql" || dialect == "mariadb" {
82            Ok(Parser {
83                statements: Vec::new(),
84                remains: String::new(),
85                escaped: false,
86                quoted: None,
87            })
88        } else {
89            Err(Error::UnsupportedDialect(dialect.to_string()))
90        }
91    }
92
93    /// Feed a chunk of SQL. Splits input into statements at unquoted, unescaped
94    /// semicolons and keeps cross-chunk state. Returns `&mut Self` for chaining.
95    pub fn feed(&mut self, chunk: &str) -> &mut Self {
96        // Operate on UTF-16 code units to match the source string indexing.
97        let units: Vec<u16> = chunk.encode_utf16().collect();
98        let mut parsed: Vec<u16> = Vec::with_capacity(units.len());
99        let mut last_statement_index = 0usize;
100
101        for (i, &unit) in units.iter().enumerate() {
102            parsed.push(unit);
103            let ch = char::from_u32(unit as u32);
104
105            if ch == Some('\\') {
106                self.escaped = !self.escaped;
107            } else {
108                if !self.escaped && is_quote_char(ch) {
109                    let c = ch.unwrap();
110                    match self.quoted {
111                        Some(open) => {
112                            if open == c {
113                                self.quoted = None;
114                            }
115                        }
116                        None => self.quoted = Some(c),
117                    }
118                } else if ch == Some(';') && self.quoted.is_none() {
119                    let slice = &parsed[last_statement_index..i + 1];
120                    let statement = format!("{}{}", self.remains, utf16_to_string(slice));
121                    self.statements.push(statement);
122                    self.remains.clear();
123                    last_statement_index = i + 1;
124                }
125                self.escaped = false;
126            }
127        }
128
129        let tail = &parsed[last_statement_index..];
130        self.remains.push_str(&utf16_to_string(tail));
131        self
132    }
133
134    /// Drain the buffered statements and parse each into a `P_DDS` node.
135    ///
136    /// Returns the parse tree root `{ id: "MAIN", def: [...] }`. This consumes
137    /// the pending statements, so a second call with no new input returns an
138    /// empty tree. On a parse error the line number is reported in stream
139    /// coordinates and all state resets.
140    ///
141    /// ```
142    /// use sql_ddl_to_json_schema::Parser;
143    ///
144    /// let mut parser = Parser::new("mysql").unwrap();
145    /// parser.feed("CREATE TABLE t (id INT);");
146    /// let first = parser.parse().unwrap();
147    /// assert_eq!(first["def"].as_array().unwrap().len(), 1);
148    /// // The statements are drained. A second call sees nothing.
149    /// let second = parser.parse().unwrap();
150    /// assert!(second["def"].as_array().unwrap().is_empty());
151    /// ```
152    pub fn parse(&mut self) -> Result<Value, Error> {
153        let mut line_count = 1usize;
154        let mut results = Vec::new();
155
156        let statements = std::mem::take(&mut self.statements);
157        for statement in statements {
158            if statement.is_empty() {
159                break;
160            }
161            match parse_statement(&statement) {
162                Ok(node) => {
163                    line_count += count_line_breaks(&statement);
164                    results.push(node);
165                }
166                Err(e) => {
167                    let error_line = e.line;
168                    let new_count = line_count + error_line - 1;
169                    // Reset all state so a failed parse leaves a clean parser.
170                    self.statements.clear();
171                    self.remains.clear();
172                    self.escaped = false;
173                    self.quoted = None;
174                    return Err(Error::Parse(format!(
175                        "invalid syntax at line {}",
176                        new_count
177                    )));
178                }
179            }
180        }
181
182        self.remains.clear();
183        self.escaped = false;
184        self.quoted = None;
185
186        Ok(json!({ "id": "MAIN", "def": results }))
187    }
188
189    /// Drain the buffered statements and build the compact table model.
190    ///
191    /// Consumes the pending statements. To format a tree you already hold, use
192    /// [`compact_from_tree`].
193    pub fn parse_compact(&mut self) -> Result<Vec<Value>, Error> {
194        let tree = self.parse()?;
195        compact_from_tree(&tree)
196    }
197
198    /// Drain the buffered statements and build JSON Schema draft-07 documents,
199    /// one per table.
200    ///
201    /// Consumes the pending statements. Pass [`JsonSchemaOptions::default`] for
202    /// the default `{ use_ref: true }`. To format tables you already hold, use
203    /// [`json_schema_from_tables`].
204    pub fn parse_json_schema(&mut self, options: JsonSchemaOptions) -> Result<Vec<Value>, Error> {
205        let tables = self.parse_compact()?;
206        Ok(json_schema_from_tables(&tables, options))
207    }
208}
209
210/// Build the compact table model from a `MAIN` parse tree.
211///
212/// Use this to format a tree you already hold, for example one returned by
213/// [`Parser::parse`]. Returns [`Error::Format`] if the root id is not `MAIN`.
214pub fn compact_from_tree(tree: &Value) -> Result<Vec<Value>, Error> {
215    compact::format(tree).map_err(Error::Format)
216}
217
218/// Build JSON Schema draft-07 documents from compact tables, one per table.
219///
220/// Use this to format tables you already hold, for example those returned by
221/// [`Parser::parse_compact`].
222#[must_use]
223pub fn json_schema_from_tables(tables: &[Value], options: JsonSchemaOptions) -> Vec<Value> {
224    json_schema::format(tables, options)
225}
226
227/// Whether a char opens or closes a quoted region.
228fn is_quote_char(ch: Option<char>) -> bool {
229    matches!(ch, Some('"') | Some('\'') | Some('`'))
230}
231
232/// Count `\r\n`, `\r`, and `\n` line breaks the way the source regex does.
233fn count_line_breaks(s: &str) -> usize {
234    let bytes = s.as_bytes();
235    let mut count = 0;
236    let mut i = 0;
237    while i < bytes.len() {
238        match bytes[i] {
239            b'\r' => {
240                if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
241                    i += 2;
242                } else {
243                    i += 1;
244                }
245                count += 1;
246            }
247            b'\n' => {
248                count += 1;
249                i += 1;
250            }
251            _ => i += 1,
252        }
253    }
254    count
255}
256
257/// Decode a UTF-16 slice into a string. Invalid units become the replacement
258/// character, which does not occur for real DDL input.
259fn utf16_to_string(units: &[u16]) -> String {
260    String::from_utf16_lossy(units)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    // The preparser tracks escape state, the open quote, and the count of split
268    // statements. These fields are private, so the checks live here.
269
270    #[test]
271    fn tracks_escape_quote_and_statements() {
272        let mut p = Parser::new("mysql").unwrap();
273
274        // Single char.
275        p.feed("a");
276        assert!(!p.escaped);
277        assert_eq!(p.quoted, None);
278
279        // Start escaping.
280        p.feed("\\");
281        assert!(p.escaped);
282        assert_eq!(p.quoted, None);
283
284        // Finish escaping.
285        p.feed("\\");
286        assert!(!p.escaped);
287        assert_eq!(p.quoted, None);
288
289        p.feed("\\");
290        assert!(p.escaped);
291        p.feed("n");
292        assert!(!p.escaped);
293        assert_eq!(p.quoted, None);
294
295        // Double quotes without escape.
296        p.feed("\"");
297        assert!(!p.escaped);
298        assert_eq!(p.quoted, Some('"'));
299        p.feed("\"");
300        assert_eq!(p.quoted, None);
301        p.feed("\"a");
302        assert_eq!(p.quoted, Some('"'));
303        p.feed("a\"");
304        assert_eq!(p.quoted, None);
305
306        // Single quotes without escape.
307        p.feed("'");
308        assert_eq!(p.quoted, Some('\''));
309        p.feed("'");
310        assert_eq!(p.quoted, None);
311        p.feed("'a");
312        assert_eq!(p.quoted, Some('\''));
313        p.feed("a'");
314        assert_eq!(p.quoted, None);
315
316        // Backticks without escape.
317        p.feed("`");
318        assert_eq!(p.quoted, Some('`'));
319        p.feed("`");
320        assert_eq!(p.quoted, None);
321        p.feed("`a");
322        assert_eq!(p.quoted, Some('`'));
323        p.feed("a`");
324        assert_eq!(p.quoted, None);
325
326        // Quoting with escape.
327        p.feed("`\\`");
328        assert!(!p.escaped);
329        assert_eq!(p.quoted, Some('`'));
330        p.feed("\\\\`");
331        assert_eq!(p.quoted, None);
332
333        p.feed("\"\\");
334        assert!(p.escaped);
335        assert_eq!(p.quoted, Some('"'));
336        p.feed("\"a`");
337        assert_eq!(p.quoted, Some('"'));
338        p.feed("'\\'");
339        assert_eq!(p.quoted, Some('"'));
340        p.feed("\"");
341        assert_eq!(p.quoted, None);
342
343        // Escaped semicolon does not split. The next char ends the escape.
344        p.feed("\\;");
345        assert!(!p.escaped);
346        assert_eq!(p.quoted, None);
347        assert_eq!(p.statements.len(), 1);
348
349        // Unescaped semicolon splits.
350        p.feed("a;");
351        assert_eq!(p.statements.len(), 2);
352
353        // Semicolon inside quotes does not split.
354        p.feed("a\";");
355        assert_eq!(p.quoted, Some('"'));
356        assert_eq!(p.statements.len(), 2);
357
358        p.feed("a\";");
359        assert_eq!(p.quoted, None);
360        assert_eq!(p.statements.len(), 3);
361    }
362
363    #[test]
364    fn splits_on_utf16_code_units() {
365        // Statement splitting counts UTF-16 code units. An astral char is two
366        // units, and it must not derail the split at the following semicolon.
367        let mut p = Parser::new("mysql").unwrap();
368        p.feed("USE \u{1F600};USE b;");
369        assert_eq!(p.statements.len(), 2);
370    }
371}