Skip to main content

sql_cli/sql/
script_parser.rs

1// Script parser for handling multi-statement SQL scripts with GO separator
2// Similar to SQL Server's batch execution model
3
4use anyhow::Result;
5
6/// Directives that can be attached to a script statement
7#[derive(Debug, Clone, PartialEq)]
8pub enum ScriptDirective {
9    /// Skip execution of this statement
10    Skip,
11}
12
13/// Type of script statement
14#[derive(Debug, Clone, PartialEq)]
15pub enum ScriptStatementType {
16    /// Regular SQL query
17    Query(String),
18    /// EXIT statement - stops script execution
19    /// Optional exit code (defaults to 0 for success)
20    Exit(Option<i32>),
21}
22
23/// A parsed script statement with optional directives
24#[derive(Debug, Clone)]
25pub struct ScriptStatement {
26    /// The type of statement (Query or Exit)
27    pub statement_type: ScriptStatementType,
28    /// Directives attached to this statement (from comments above it)
29    pub directives: Vec<ScriptDirective>,
30}
31
32impl ScriptStatement {
33    /// Check if this statement should be skipped
34    pub fn should_skip(&self) -> bool {
35        self.directives.contains(&ScriptDirective::Skip)
36    }
37
38    /// Check if this is an EXIT statement
39    pub fn is_exit(&self) -> bool {
40        matches!(self.statement_type, ScriptStatementType::Exit(_))
41    }
42
43    /// Get exit code if this is an EXIT statement
44    pub fn get_exit_code(&self) -> Option<i32> {
45        match &self.statement_type {
46            ScriptStatementType::Exit(code) => Some(code.unwrap_or(0)),
47            _ => None,
48        }
49    }
50
51    /// Get the SQL query if this is a query statement
52    pub fn get_query(&self) -> Option<&str> {
53        match &self.statement_type {
54            ScriptStatementType::Query(sql) => Some(sql),
55            ScriptStatementType::Exit(_) => None,
56        }
57    }
58}
59
60/// Parses SQL scripts into individual statements using GO as separator
61pub struct ScriptParser {
62    content: String,
63    data_file_hint: Option<String>,
64}
65
66impl ScriptParser {
67    /// Create a new script parser with the given content
68    pub fn new(content: &str) -> Self {
69        let data_file_hint = Self::extract_data_file_hint(content);
70        Self {
71            content: content.to_string(),
72            data_file_hint,
73        }
74    }
75
76    /// Extract data file hint from script comments
77    /// Looks for patterns like:
78    /// -- #!data: path/to/file.csv
79    /// -- #!datafile: path/to/file.csv  
80    /// -- #! /path/to/file.csv
81    fn extract_data_file_hint(content: &str) -> Option<String> {
82        for line in content.lines() {
83            let trimmed = line.trim();
84
85            // Skip non-comment lines
86            if !trimmed.starts_with("--") {
87                continue;
88            }
89
90            // Remove the comment prefix
91            let comment_content = trimmed.strip_prefix("--").unwrap().trim();
92
93            // Check for data file hint patterns
94            if let Some(path) = comment_content.strip_prefix("#!data:") {
95                return Some(path.trim().to_string());
96            }
97            if let Some(path) = comment_content.strip_prefix("#!datafile:") {
98                return Some(path.trim().to_string());
99            }
100            if let Some(path) = comment_content.strip_prefix("#!") {
101                let path = path.trim();
102                // Check if it looks like a file path
103                if path.contains('.') || path.contains('/') || path.contains('\\') {
104                    return Some(path.to_string());
105                }
106            }
107        }
108        None
109    }
110
111    /// Get the data file hint if present
112    pub fn data_file_hint(&self) -> Option<&str> {
113        self.data_file_hint.as_deref()
114    }
115
116    /// Parse directives from comment lines
117    /// Looks for patterns like: -- [SKIP], -- [TODO], etc.
118    fn parse_directives(comment_lines: &[String]) -> Vec<ScriptDirective> {
119        let mut directives = Vec::new();
120
121        for line in comment_lines {
122            let trimmed = line.trim();
123            if !trimmed.starts_with("--") {
124                continue;
125            }
126
127            let comment_content = trimmed.strip_prefix("--").unwrap().trim();
128
129            // Check for directive patterns: [SKIP], [IGNORE]
130            if comment_content.eq_ignore_ascii_case("[skip]")
131                || comment_content.eq_ignore_ascii_case("[ignore]")
132            {
133                directives.push(ScriptDirective::Skip);
134            }
135        }
136
137        directives
138    }
139
140    /// Split a `GO` batch into individual statements on top-level `;`.
141    ///
142    /// `GO` remains the batch separator it has always been — nothing about
143    /// existing scripts changes shape. This only recovers statements that were
144    /// previously glued together into one string, where the parser would parse
145    /// the first and **silently discard the rest** (P13). `prime_numbers.sql`
146    /// had a whole `SELECT` that never ran for exactly this reason.
147    ///
148    /// Quote- and comment-aware, so a `;` inside a string literal, a quoted
149    /// identifier, a line comment or a block comment does not split. Doubled
150    /// quotes (`'O''Brien'`) are handled as the escape they are rather than as
151    /// a close-then-reopen.
152    ///
153    /// Statement *scope* is unaffected: the script executor builds one
154    /// `ExecutionContext` for the whole file, so `SELECT ... INTO #tmp` stays
155    /// visible to later statements whether they are separated by `;` or `GO`.
156    fn split_on_semicolons(batch: &str) -> Vec<String> {
157        let mut out = Vec::new();
158        let mut current = String::new();
159        let mut chars = batch.chars().peekable();
160        let mut in_single = false;
161        let mut in_double = false;
162        let mut in_line_comment = false;
163        let mut in_block_comment = false;
164
165        while let Some(ch) = chars.next() {
166            if in_line_comment {
167                current.push(ch);
168                if ch == '\n' {
169                    in_line_comment = false;
170                }
171                continue;
172            }
173            if in_block_comment {
174                current.push(ch);
175                if ch == '*' && chars.peek() == Some(&'/') {
176                    current.push(chars.next().unwrap());
177                    in_block_comment = false;
178                }
179                continue;
180            }
181            if in_single || in_double {
182                let quote = if in_single { '\'' } else { '"' };
183                current.push(ch);
184                if ch == quote {
185                    if chars.peek() == Some(&quote) {
186                        current.push(chars.next().unwrap()); // escaped quote
187                    } else if in_single {
188                        in_single = false;
189                    } else {
190                        in_double = false;
191                    }
192                }
193                continue;
194            }
195
196            match ch {
197                '\'' => {
198                    in_single = true;
199                    current.push(ch);
200                }
201                '"' => {
202                    in_double = true;
203                    current.push(ch);
204                }
205                '-' if chars.peek() == Some(&'-') => {
206                    in_line_comment = true;
207                    current.push(ch);
208                }
209                '/' if chars.peek() == Some(&'*') => {
210                    in_block_comment = true;
211                    current.push(ch);
212                }
213                ';' => {
214                    let stmt = current.trim().to_string();
215                    if !stmt.is_empty() {
216                        out.push(stmt);
217                    }
218                    current.clear();
219                }
220                _ => current.push(ch),
221            }
222        }
223
224        let last = current.trim().to_string();
225        if !last.is_empty() {
226            out.push(last);
227        }
228        out
229    }
230
231    /// True if the text holds more than one top-level statement, i.e. it needs
232    /// the script executor even though it has no `GO` separator.
233    ///
234    /// Without this a `;`-separated file routes to the single-query path, which
235    /// parses the whole thing as one statement — historically running only the
236    /// first and silently dropping the rest (P13).
237    #[must_use]
238    pub fn is_multi_statement(sql: &str) -> bool {
239        Self::split_on_semicolons(sql)
240            .iter()
241            .filter(|s| !Self::is_comment_only(s))
242            .count()
243            > 1
244    }
245
246    /// Turn one accumulated batch into zero or more `ScriptStatement`s.
247    /// Batch-level directives (e.g. `-- [SKIP]`) apply to every statement in it.
248    fn push_batch(batch: &str, pending_comments: &[String], statements: &mut Vec<ScriptStatement>) {
249        let batch = batch.trim();
250        if batch.is_empty() || Self::is_comment_only(batch) {
251            return;
252        }
253
254        let directives = Self::parse_directives(pending_comments);
255
256        for stmt in Self::split_on_semicolons(batch) {
257            if Self::is_comment_only(&stmt) {
258                continue;
259            }
260            let statement_type =
261                Self::parse_exit_statement(&stmt).unwrap_or(ScriptStatementType::Query(stmt));
262            statements.push(ScriptStatement {
263                statement_type,
264                directives: directives.clone(),
265            });
266        }
267    }
268
269    /// Parse the script into ScriptStatements with directives
270    /// GO must be on its own line (case-insensitive)
271    pub fn parse_script_statements(&self) -> Vec<ScriptStatement> {
272        let mut statements = Vec::new();
273        let mut current_statement = String::new();
274        let mut pending_comments = Vec::new();
275
276        for line in self.content.lines() {
277            let trimmed = line.trim();
278
279            // Check if this line is just "GO" (case-insensitive)
280            if trimmed.eq_ignore_ascii_case("go") {
281                Self::push_batch(&current_statement, &pending_comments, &mut statements);
282                current_statement.clear();
283                pending_comments.clear();
284            } else if trimmed.starts_with("--") {
285                // This is a comment line - save it for directive parsing
286                pending_comments.push(line.to_string());
287                // Also add to current statement
288                if !current_statement.is_empty() {
289                    current_statement.push('\n');
290                }
291                current_statement.push_str(line);
292            } else {
293                // Regular line - add to current statement
294                if !current_statement.is_empty() {
295                    current_statement.push('\n');
296                }
297                current_statement.push_str(line);
298            }
299        }
300
301        // Don't forget the last batch if there's no trailing GO
302        Self::push_batch(&current_statement, &pending_comments, &mut statements);
303
304        statements
305    }
306
307    /// Try to parse an EXIT statement with optional exit code
308    /// Supports: EXIT, EXIT;, EXIT 0, EXIT 1;, etc.
309    /// Strips comments before checking
310    fn parse_exit_statement(statement: &str) -> Option<ScriptStatementType> {
311        // Extract non-comment content
312        let mut non_comment_lines = Vec::new();
313        for line in statement.lines() {
314            let trimmed = line.trim();
315            if !trimmed.is_empty() && !trimmed.starts_with("--") {
316                non_comment_lines.push(trimmed);
317            }
318        }
319
320        if non_comment_lines.is_empty() {
321            return None;
322        }
323
324        // Join non-comment lines and check if it's EXIT
325        let content = non_comment_lines.join(" ");
326        let trimmed = content.trim().trim_end_matches(';').trim();
327
328        if trimmed.eq_ignore_ascii_case("exit") {
329            return Some(ScriptStatementType::Exit(None));
330        }
331
332        // Check for EXIT with a number: EXIT 0, EXIT 1, etc.
333        let parts: Vec<&str> = trimmed.split_whitespace().collect();
334        if parts.len() == 2 && parts[0].eq_ignore_ascii_case("exit") {
335            if let Ok(code) = parts[1].parse::<i32>() {
336                return Some(ScriptStatementType::Exit(Some(code)));
337            }
338        }
339
340        None
341    }
342
343    /// Parse the script into individual SQL statements (legacy method)
344    /// GO must be on its own line (case-insensitive)
345    /// Returns a vector of SQL statements to execute
346    pub fn parse_statements(&self) -> Vec<String> {
347        self.parse_script_statements()
348            .into_iter()
349            .filter_map(|stmt| match stmt.statement_type {
350                ScriptStatementType::Query(sql) => Some(sql),
351                ScriptStatementType::Exit(_) => None,
352            })
353            .collect()
354    }
355
356    /// Check if a statement contains only comments (no actual SQL)
357    fn is_comment_only(statement: &str) -> bool {
358        for line in statement.lines() {
359            let trimmed = line.trim();
360            // Skip empty lines and comments
361            if trimmed.is_empty() || trimmed.starts_with("--") {
362                continue;
363            }
364            // If we find any non-comment content, it's not comment-only
365            return false;
366        }
367        // All lines were comments or empty
368        true
369    }
370
371    /// Parse and validate that all statements are valid SQL
372    /// Returns the statements or an error if any are invalid
373    pub fn parse_and_validate(&self) -> Result<Vec<String>> {
374        let statements = self.parse_statements();
375
376        if statements.is_empty() {
377            anyhow::bail!("No SQL statements found in script");
378        }
379
380        // Basic validation - ensure no statement is just whitespace
381        for (i, stmt) in statements.iter().enumerate() {
382            if stmt.trim().is_empty() {
383                anyhow::bail!("Empty statement at position {}", i + 1);
384            }
385        }
386
387        Ok(statements)
388    }
389}
390
391/// Result of executing a single statement in a script
392#[derive(Debug)]
393pub struct StatementResult {
394    pub statement_number: usize,
395    pub sql: String,
396    pub success: bool,
397    pub rows_affected: usize,
398    pub error_message: Option<String>,
399    pub execution_time_ms: f64,
400}
401
402/// Result of executing an entire script
403#[derive(Debug)]
404pub struct ScriptResult {
405    pub total_statements: usize,
406    pub successful_statements: usize,
407    pub failed_statements: usize,
408    pub total_execution_time_ms: f64,
409    pub statement_results: Vec<StatementResult>,
410}
411
412impl ScriptResult {
413    pub fn new() -> Self {
414        Self {
415            total_statements: 0,
416            successful_statements: 0,
417            failed_statements: 0,
418            total_execution_time_ms: 0.0,
419            statement_results: Vec::new(),
420        }
421    }
422
423    pub fn add_success(&mut self, statement_number: usize, sql: String, rows: usize, time_ms: f64) {
424        self.total_statements += 1;
425        self.successful_statements += 1;
426        self.total_execution_time_ms += time_ms;
427
428        self.statement_results.push(StatementResult {
429            statement_number,
430            sql,
431            success: true,
432            rows_affected: rows,
433            error_message: None,
434            execution_time_ms: time_ms,
435        });
436    }
437
438    pub fn add_failure(
439        &mut self,
440        statement_number: usize,
441        sql: String,
442        error: String,
443        time_ms: f64,
444    ) {
445        self.total_statements += 1;
446        self.failed_statements += 1;
447        self.total_execution_time_ms += time_ms;
448
449        self.statement_results.push(StatementResult {
450            statement_number,
451            sql,
452            success: false,
453            rows_affected: 0,
454            error_message: Some(error),
455            execution_time_ms: time_ms,
456        });
457    }
458
459    pub fn all_successful(&self) -> bool {
460        self.failed_statements == 0
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_parse_single_statement() {
470        let script = "SELECT * FROM users";
471        let parser = ScriptParser::new(script);
472        let statements = parser.parse_statements();
473
474        assert_eq!(statements.len(), 1);
475        assert_eq!(statements[0], "SELECT * FROM users");
476    }
477
478    #[test]
479    fn test_parse_multiple_statements_with_go() {
480        let script = r"
481SELECT * FROM users
482GO
483SELECT * FROM orders
484GO
485SELECT * FROM products
486";
487        let parser = ScriptParser::new(script);
488        let statements = parser.parse_statements();
489
490        assert_eq!(statements.len(), 3);
491        assert_eq!(statements[0].trim(), "SELECT * FROM users");
492        assert_eq!(statements[1].trim(), "SELECT * FROM orders");
493        assert_eq!(statements[2].trim(), "SELECT * FROM products");
494    }
495
496    #[test]
497    fn test_go_case_insensitive() {
498        let script = r"
499SELECT 1
500go
501SELECT 2
502Go
503SELECT 3
504GO
505";
506        let parser = ScriptParser::new(script);
507        let statements = parser.parse_statements();
508
509        assert_eq!(statements.len(), 3);
510    }
511
512    #[test]
513    fn test_go_in_string_not_separator() {
514        let script = r"
515SELECT 'This string contains GO but should not split' as test
516GO
517SELECT 'Another statement' as test2
518";
519        let parser = ScriptParser::new(script);
520        let statements = parser.parse_statements();
521
522        assert_eq!(statements.len(), 2);
523        assert!(statements[0].contains("GO but should not split"));
524    }
525
526    #[test]
527    fn test_multiline_statements() {
528        let script = r"
529SELECT 
530    id,
531    name,
532    email
533FROM users
534WHERE active = true
535GO
536SELECT COUNT(*) 
537FROM orders
538";
539        let parser = ScriptParser::new(script);
540        let statements = parser.parse_statements();
541
542        assert_eq!(statements.len(), 2);
543        assert!(statements[0].contains("WHERE active = true"));
544    }
545
546    #[test]
547    fn test_empty_statements_filtered() {
548        let script = r"
549GO
550SELECT 1
551GO
552GO
553SELECT 2
554GO
555";
556        let parser = ScriptParser::new(script);
557        let statements = parser.parse_statements();
558
559        assert_eq!(statements.len(), 2);
560        assert_eq!(statements[0].trim(), "SELECT 1");
561        assert_eq!(statements[1].trim(), "SELECT 2");
562    }
563}