Skip to main content

pg2any_lib/storage/
sql_parser.rs

1//! Streaming SQL Parser Module
2//!
3//! This module implements a byte-by-byte streaming SQL parser that:
4//! - Parses SQL statements without accumulating all of them in memory
5//! - Uses a state machine for quote handling
6//! - Maintains correctness with quote types, escapes, and multi-line statements
7//!
8//! ## Design Goals
9//! - **Memory Efficiency**: O(1) memory per statement instead of O(n)
10//! - **Correctness**: Handle all quote types, escapes, and multi-line statements
11//! - **Performance**: Single-pass parsing with minimal allocations
12//! - **Compatibility**: Can be used as a drop-in replacement for existing parser
13
14use crate::error::{CdcError, Result};
15use std::path::Path;
16use tokio::fs::File;
17use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
18
19/// Parser state for tracking quote context
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21enum ParseState {
22    /// Normal SQL parsing (outside quotes)
23    Normal,
24    /// Inside single-quoted string
25    SingleQuote,
26    /// Inside double-quoted identifier
27    DoubleQuote,
28    /// Inside backtick-quoted identifier (MySQL)
29    Backtick,
30    /// Inside bracket-quoted identifier (SQL Server)
31    Bracket,
32}
33
34/// Streaming SQL statement parser
35///
36/// Parses SQL statements from a byte stream without loading all statements into memory.
37/// Uses a state machine to correctly handle quotes, escapes, and comments.
38pub struct SqlStreamParser {
39    /// Current parsing state
40    state: ParseState,
41    /// Buffer for accumulating current statement
42    statement_buffer: Vec<u8>,
43    /// Total statements parsed
44    statement_count: usize,
45    /// Non-whitespace byte count of the in-progress statement, for count-only
46    /// parsing (see `count_line`). Parallel to `statement_buffer` but allocation-free.
47    count_nonws: usize,
48}
49
50impl SqlStreamParser {
51    /// Create a new streaming parser
52    pub fn new() -> Self {
53        Self {
54            state: ParseState::Normal,
55            statement_buffer: Vec::with_capacity(512),
56            statement_count: 0,
57            count_nonws: 0,
58        }
59    }
60
61    /// Parse SQL statements from a file, starting from a specific index
62    ///
63    /// This is a convenience method that collects all statements into a Vec.
64    /// For true streaming without memory accumulation, use parse_stream with a custom callback.
65    pub async fn parse_file_from_index_collect(
66        &mut self,
67        file_path: &Path,
68        start_index: usize,
69    ) -> Result<Vec<String>> {
70        let file = File::open(file_path)
71            .await
72            .map_err(|e| CdcError::generic(format!("Failed to open file {file_path:?}: {e}")))?;
73
74        let reader = BufReader::with_capacity(65536, file);
75
76        self.parse_stream_collect(reader, start_index).await
77    }
78
79    /// Parse SQL statements from a reader with callback
80    ///
81    /// Internal method kept for potential future streaming use cases
82    /// Parse SQL statements from any async reader, collecting into a Vec
83    pub async fn parse_stream_collect<R>(
84        &mut self,
85        reader: R,
86        start_index: usize,
87    ) -> Result<Vec<String>>
88    where
89        R: AsyncRead + Unpin,
90    {
91        let mut statements: Vec<String> = Vec::new();
92        let buf_reader = BufReader::new(reader);
93        let mut lines = buf_reader.lines();
94
95        self.statement_count = 0;
96        self.statement_buffer.clear();
97        self.state = ParseState::Normal;
98
99        let mut line_statements: Vec<String> = Vec::new();
100        while let Some(line) = lines
101            .next_line()
102            .await
103            .map_err(|e| CdcError::generic(format!("Failed to read line: {e}")))?
104        {
105            line_statements.clear();
106            self.parse_line(&line, &mut line_statements)?;
107            for stmt in line_statements.drain(..) {
108                if self.statement_count >= start_index {
109                    statements.push(stmt);
110                }
111                self.statement_count += 1;
112            }
113        }
114
115        if let Some(stmt) = self.finish_statement() {
116            if self.statement_count >= start_index {
117                statements.push(stmt);
118            }
119            self.statement_count += 1;
120        }
121
122        Ok(statements)
123    }
124
125    /// Parse a single line and push any completed statements into `out`.
126    pub fn parse_line(&mut self, line: &str, out: &mut Vec<String>) -> Result<()> {
127        let bytes = line.as_bytes();
128        let mut i = 0;
129
130        while i < bytes.len() {
131            let byte = bytes[i];
132
133            match self.state {
134                ParseState::Normal => match byte {
135                    b'\'' => {
136                        self.statement_buffer.push(byte);
137                        self.state = ParseState::SingleQuote;
138                    }
139                    b'"' => {
140                        self.statement_buffer.push(byte);
141                        self.state = ParseState::DoubleQuote;
142                    }
143                    b'`' => {
144                        self.statement_buffer.push(byte);
145                        self.state = ParseState::Backtick;
146                    }
147                    b'[' => {
148                        self.statement_buffer.push(byte);
149                        self.state = ParseState::Bracket;
150                    }
151                    b';' => {
152                        if let Some(stmt) = self.take_trimmed_statement() {
153                            out.push(stmt);
154                        }
155                        self.statement_buffer.clear();
156                    }
157                    _ => {
158                        self.statement_buffer.push(byte);
159                    }
160                },
161                ParseState::SingleQuote => {
162                    self.statement_buffer.push(byte);
163                    if byte == b'\'' {
164                        if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
165                            i += 1;
166                            self.statement_buffer.push(bytes[i]);
167                        } else {
168                            self.state = ParseState::Normal;
169                        }
170                    }
171                }
172                ParseState::DoubleQuote => {
173                    self.statement_buffer.push(byte);
174                    if byte == b'"' {
175                        if i + 1 < bytes.len() && bytes[i + 1] == b'"' {
176                            i += 1;
177                            self.statement_buffer.push(bytes[i]);
178                        } else {
179                            self.state = ParseState::Normal;
180                        }
181                    }
182                }
183                ParseState::Backtick => {
184                    self.statement_buffer.push(byte);
185                    if byte == b'`' {
186                        if i + 1 < bytes.len() && bytes[i + 1] == b'`' {
187                            i += 1;
188                            self.statement_buffer.push(bytes[i]);
189                        } else {
190                            self.state = ParseState::Normal;
191                        }
192                    }
193                }
194                ParseState::Bracket => {
195                    self.statement_buffer.push(byte);
196                    if byte == b']' {
197                        self.state = ParseState::Normal;
198                    }
199                }
200            }
201
202            i += 1;
203        }
204
205        self.statement_buffer.push(b'\n');
206
207        Ok(())
208    }
209
210    /// Count completed statements in `line` WITHOUT allocating per statement.
211    /// Mirrors `parse_line`'s state machine exactly (quotes/brackets/escapes),
212    /// but instead of buffering bytes it tracks whether the in-progress
213    /// statement contains any non-whitespace byte, so a trailing `;` on a
214    /// blank/whitespace-only buffer is not counted (parity with
215    /// `take_trimmed_statement` returning None on empty).
216    pub fn count_line(&mut self, line: &str) -> usize {
217        let mut completed = 0usize;
218        // Iterate by `char` (not byte) so emptiness uses the SAME Unicode
219        // whitespace definition as `str::trim` (`char::is_whitespace`) on the
220        // read path. All delimiters/quote chars dispatched on below are ASCII
221        // single-byte, so matching on a `char` is identical to matching a byte
222        // for those cases. `peekable` lets us look ahead for doubled-quote
223        // escapes, mirroring `parse_line`'s `bytes[i + 1]` lookahead.
224        let mut chars = line.chars().peekable();
225        while let Some(ch) = chars.next() {
226            match self.state {
227                ParseState::Normal => match ch {
228                    '\'' => {
229                        self.state = ParseState::SingleQuote;
230                        self.count_nonws += 1;
231                    }
232                    '"' => {
233                        self.state = ParseState::DoubleQuote;
234                        self.count_nonws += 1;
235                    }
236                    '`' => {
237                        self.state = ParseState::Backtick;
238                        self.count_nonws += 1;
239                    }
240                    '[' => {
241                        self.state = ParseState::Bracket;
242                        self.count_nonws += 1;
243                    }
244                    ';' => {
245                        if self.count_nonws > 0 {
246                            completed += 1;
247                        }
248                        self.count_nonws = 0;
249                    }
250                    _ => {
251                        if !ch.is_whitespace() {
252                            self.count_nonws += 1;
253                        }
254                    }
255                },
256                ParseState::SingleQuote => {
257                    self.count_nonws += 1;
258                    if ch == '\'' {
259                        if chars.peek() == Some(&'\'') {
260                            chars.next();
261                            self.count_nonws += 1;
262                        } else {
263                            self.state = ParseState::Normal;
264                        }
265                    }
266                }
267                ParseState::DoubleQuote => {
268                    self.count_nonws += 1;
269                    if ch == '"' {
270                        if chars.peek() == Some(&'"') {
271                            chars.next();
272                            self.count_nonws += 1;
273                        } else {
274                            self.state = ParseState::Normal;
275                        }
276                    }
277                }
278                ParseState::Backtick => {
279                    self.count_nonws += 1;
280                    if ch == '`' {
281                        if chars.peek() == Some(&'`') {
282                            chars.next();
283                            self.count_nonws += 1;
284                        } else {
285                            self.state = ParseState::Normal;
286                        }
287                    }
288                }
289                ParseState::Bracket => {
290                    self.count_nonws += 1;
291                    if ch == ']' {
292                        self.state = ParseState::Normal;
293                    }
294                }
295            }
296        }
297        // parse_line pushes a trailing '\n' into the buffer; '\n' is whitespace
298        // so it does not affect count_nonws. Nothing to do here.
299        completed
300    }
301
302    /// Mirror of `finish_statement().is_some()` for the count-only path.
303    pub fn finish_count(&mut self) -> bool {
304        let has = self.count_nonws > 0;
305        self.count_nonws = 0;
306        has
307    }
308
309    /// Finalize parsing at EOF and return the remaining statement, if any
310    pub fn finish_statement(&mut self) -> Option<String> {
311        if self.statement_buffer.is_empty() {
312            return None;
313        }
314
315        let stmt = self.take_trimmed_statement();
316        self.statement_buffer.clear();
317        stmt
318    }
319
320    /// Trim whitespace from the current statement buffer and return it as an
321    /// owned `String`. Returns `None` when the trimmed contents are empty.
322    ///
323    /// Takes ownership of the internal buffer via `mem::take` so the common
324    /// valid-UTF-8 path avoids copying the bytes — we just reinterpret the
325    /// `Vec<u8>` as a `String` and trim in place. Invalid UTF-8 falls back
326    /// to `from_utf8_lossy` to preserve prior semantics (never drop a
327    /// statement on an encoding error). The caller's subsequent `.clear()`
328    /// is a cheap no-op on the now-empty buffer.
329    fn take_trimmed_statement(&mut self) -> Option<String> {
330        let buf = std::mem::take(&mut self.statement_buffer);
331        let mut s = match String::from_utf8(buf) {
332            Ok(s) => s,
333            Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
334        };
335
336        let trimmed_end = s.trim_end().len();
337        s.truncate(trimmed_end);
338        let leading = s.len() - s.trim_start().len();
339        if leading > 0 {
340            s.drain(..leading);
341        }
342
343        if s.is_empty() {
344            None
345        } else {
346            Some(s)
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use std::path::PathBuf;
355    use tokio::io::AsyncWriteExt;
356
357    async fn create_test_file(content: &str) -> (String, PathBuf) {
358        let temp_dir = std::env::temp_dir().join(format!("pg2any_test_{}", std::process::id()));
359        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
360        let file_path = temp_dir.join(format!(
361            "test_{}.sql",
362            std::time::SystemTime::now()
363                .duration_since(std::time::UNIX_EPOCH)
364                .unwrap()
365                .as_nanos()
366        ));
367        let mut file = tokio::fs::File::create(&file_path).await.unwrap();
368        file.write_all(content.as_bytes()).await.unwrap();
369        file.flush().await.unwrap();
370        (file_path.to_string_lossy().to_string(), temp_dir)
371    }
372
373    #[test]
374    fn test_count_line_matches_parse_line_statement_count() {
375        // Compute the reference statement count via the real read path
376        // (parse_line + finish_statement) — two independent paths, not a
377        // tautology.
378        fn ref_count(case: &str) -> usize {
379            let mut p_ref = SqlStreamParser::new();
380            let mut n = 0usize;
381            for physical in case.split('\n') {
382                let mut o = Vec::new();
383                p_ref.parse_line(physical, &mut o).unwrap();
384                n += o.len();
385            }
386            if p_ref.finish_statement().is_some() {
387                n += 1;
388            }
389            n
390        }
391
392        fn new_count(case: &str) -> usize {
393            let mut p2 = SqlStreamParser::new();
394            let mut n = 0usize;
395            for physical in case.split('\n') {
396                n += p2.count_line(physical);
397            }
398            if p2.finish_count() {
399                n += 1;
400            }
401            n
402        }
403
404        // Cases that must agree, including a semicolon inside a quoted literal
405        // (must NOT be counted as a terminator) and a multi-statement line.
406        let cases = [
407            "INSERT INTO t (a) VALUES (1);",
408            "INSERT INTO t (a) VALUES ('a;b');", // ; inside quotes -> 1 stmt
409            "TRUNCATE TABLE a;\nTRUNCATE TABLE b;\nTRUNCATE TABLE c;", // 3 stmts
410            "UPDATE t SET a = '' WHERE id = 2;",
411            "", // 0 stmts
412            // Unicode-whitespace cases: NBSP / ideographic space are whitespace
413            // under str::trim (char::is_whitespace) but NOT is_ascii_whitespace.
414            "\u{a0};",        // all-whitespace before ; -> 0 stmts
415            "\u{3000}",       // hits finish_count, all-whitespace -> 0 stmts
416            "\u{a0}x\u{a0};", // interior content -> non-empty -> 1 stmt
417            "  \u{a0}  ;",    // mixed all-whitespace -> 0 stmts
418        ];
419        for case in cases {
420            assert_eq!(
421                new_count(case),
422                ref_count(case),
423                "count mismatch for case: {case:?}"
424            );
425        }
426
427        // Deterministic fuzz over an alphabet that INCLUDES a multibyte
428        // whitespace char (\u{a0}). Generate every string up to length 4 and
429        // assert the count-only path equals the read path. This FAILS before
430        // the Unicode-aware fix and PASSES after.
431        let alphabet = ['a', ';', '\'', '"', '`', '[', ']', ' ', '\t', '\u{a0}'];
432        let n = alphabet.len();
433        for len in 0..=4usize {
434            let total = n.pow(len as u32);
435            for mut idx in 0..total {
436                let mut s = String::new();
437                for _ in 0..len {
438                    s.push(alphabet[idx % n]);
439                    idx /= n;
440                }
441                assert_eq!(
442                    new_count(&s),
443                    ref_count(&s),
444                    "fuzz count mismatch for input: {s:?}"
445                );
446            }
447        }
448    }
449
450    #[tokio::test]
451    async fn test_simple_statements() {
452        let content =
453            "INSERT INTO users VALUES (1, 'Alice');\nINSERT INTO users VALUES (2, 'Bob');\n";
454        let (file_path, _temp_dir) = create_test_file(content).await;
455
456        let mut parser = SqlStreamParser::new();
457
458        let statements = parser
459            .parse_file_from_index_collect(Path::new(&file_path), 0)
460            .await
461            .unwrap();
462
463        assert_eq!(statements.len(), 2);
464        assert_eq!(statements[0], "INSERT INTO users VALUES (1, 'Alice')");
465        assert_eq!(statements[1], "INSERT INTO users VALUES (2, 'Bob')");
466    }
467
468    #[tokio::test]
469    async fn test_escaped_quotes() {
470        let content = "INSERT INTO users VALUES (1, 'O''Neil');\n";
471        let (file_path, _temp_dir) = create_test_file(content).await;
472
473        let mut parser = SqlStreamParser::new();
474
475        let statements = parser
476            .parse_file_from_index_collect(Path::new(&file_path), 0)
477            .await
478            .unwrap();
479
480        assert_eq!(statements.len(), 1);
481        assert_eq!(statements[0], "INSERT INTO users VALUES (1, 'O''Neil')");
482    }
483
484    #[tokio::test]
485    async fn test_multi_line_statements() {
486        let content = "INSERT INTO users\nVALUES (\n  1,\n  'Alice'\n);\n";
487        let (file_path, _temp_dir) = create_test_file(content).await;
488
489        let mut parser = SqlStreamParser::new();
490
491        let statements = parser
492            .parse_file_from_index_collect(Path::new(&file_path), 0)
493            .await
494            .unwrap();
495
496        assert_eq!(statements.len(), 1);
497        assert!(statements[0].contains("INSERT INTO users"));
498        assert!(statements[0].contains("Alice"));
499    }
500
501    #[tokio::test]
502    async fn test_start_index() {
503        let content = "INSERT INTO users VALUES (1, 'Alice');\nINSERT INTO users VALUES (2, 'Bob');\nINSERT INTO users VALUES (3, 'Charlie');\n";
504        let (file_path, _temp_dir) = create_test_file(content).await;
505
506        let mut parser = SqlStreamParser::new();
507
508        let statements = parser
509            .parse_file_from_index_collect(
510                Path::new(&file_path),
511                1, // Start from index 1 (skip first statement)
512            )
513            .await
514            .unwrap();
515
516        assert_eq!(statements.len(), 2); // Only collected from index 1
517        assert_eq!(statements[0], "INSERT INTO users VALUES (2, 'Bob')");
518        assert_eq!(statements[1], "INSERT INTO users VALUES (3, 'Charlie')");
519    }
520
521    #[tokio::test]
522    async fn test_cancellation() {
523        // This test now just ensures the function works correctly
524        // since cancellation token is created internally
525        let content = "INSERT INTO users VALUES (1, 'Alice');\nINSERT INTO users VALUES (2, 'Bob');\nINSERT INTO users VALUES (3, 'Charlie');\n";
526        let (file_path, _temp_dir) = create_test_file(content).await;
527
528        let mut parser = SqlStreamParser::new();
529
530        let statements = parser
531            .parse_file_from_index_collect(Path::new(&file_path), 0)
532            .await
533            .unwrap();
534
535        // Should process all statements
536        assert_eq!(statements.len(), 3);
537    }
538}