use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlFileRequest { pub execution_id: String, pub connection_id: String, pub database: String, pub file_path: String, pub continue_on_error: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlFilePreview { pub file_name: String, pub file_path: String, pub size_bytes: u64, pub preview: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SqlFileStatus { Started, Running, StatementDone, StatementFailed, Done, Error, Cancelled, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlFileProgress { pub execution_id: String, pub status: SqlFileStatus, pub statement_index: usize, pub success_count: usize, pub failure_count: usize, pub affected_rows: u64, pub elapsed_ms: u128, pub statement_summary: String, pub error: Option, } #[derive(Default)] pub struct SqlStatementSplitter { buffer: String, in_single_quote: bool, in_double_quote: bool, in_backtick: bool, in_line_comment: bool, in_block_comment: bool, dollar_quote_tag: Option, previous: Option, } impl SqlStatementSplitter { pub fn push_chunk(&mut self, chunk: &str) -> Vec { let mut statements = Vec::new(); let chars = chunk.chars().collect::>(); let mut i = 0; while i < chars.len() { if let Some(tag) = &self.dollar_quote_tag { let tag_chars = tag.chars().collect::>(); if starts_with_chars(&chars, i, &tag_chars) { for tag_ch in &tag_chars { self.buffer.push(*tag_ch); self.previous = Some(*tag_ch); } i += tag_chars.len(); self.dollar_quote_tag = None; continue; } let ch = chars[i]; self.buffer.push(ch); self.previous = Some(ch); i += 1; continue; } let ch = chars[i]; let next = chars.get(i + 1).copied(); if self.in_line_comment { self.buffer.push(ch); if ch == '\n' { self.in_line_comment = false; } self.previous = Some(ch); i += 1; continue; } if self.in_block_comment { self.buffer.push(ch); if self.previous == Some('*') && ch == '/' { self.in_block_comment = false; } self.previous = Some(ch); i += 1; continue; } if !self.in_single_quote && !self.in_double_quote && !self.in_backtick { if self.previous == Some('-') && ch == '-' { self.in_line_comment = true; self.buffer.push(ch); self.previous = Some(ch); i += 1; continue; } if self.previous == Some('/') && ch == '*' { self.in_block_comment = true; self.buffer.push(ch); self.previous = Some(ch); i += 1; continue; } if ch == '-' && next == Some('-') { self.in_line_comment = true; self.buffer.push(ch); self.previous = Some(ch); i += 1; continue; } if ch == '/' && next == Some('*') { self.in_block_comment = true; self.buffer.push(ch); self.previous = Some(ch); i += 1; continue; } if let Some(tag) = dollar_quote_tag_at(&chars, i) { for tag_ch in tag.chars() { self.buffer.push(tag_ch); self.previous = Some(tag_ch); } i += tag.chars().count(); self.dollar_quote_tag = Some(tag); continue; } } match ch { '\'' if !self.in_double_quote && !self.in_backtick && self.previous != Some('\\') => { self.in_single_quote = !self.in_single_quote; self.buffer.push(ch); } '"' if !self.in_single_quote && !self.in_backtick && self.previous != Some('\\') => { self.in_double_quote = !self.in_double_quote; self.buffer.push(ch); } '`' if !self.in_single_quote && !self.in_double_quote => { self.in_backtick = !self.in_backtick; self.buffer.push(ch); } ';' if !self.in_single_quote && !self.in_double_quote && !self.in_backtick => { self.push_current_statement(&mut statements); } _ => self.buffer.push(ch), } self.previous = Some(ch); i += 1; } statements } pub fn finish(mut self) -> Vec { let mut statements = Vec::new(); self.push_current_statement(&mut statements); statements } fn push_current_statement(&mut self, statements: &mut Vec) { let statement = self.buffer.trim(); if has_executable_sql(statement) { statements.push(statement.to_string()); } self.buffer.clear(); self.previous = None; } } pub fn split_sql_statements(sql: &str) -> Vec { let mut splitter = SqlStatementSplitter::default(); let mut statements = splitter.push_chunk(sql); statements.extend(splitter.finish()); statements } pub fn statement_summary(statement: &str) -> String { const MAX_LEN: usize = 120; let collapsed = statement.split_whitespace().collect::>().join(" "); if collapsed.chars().count() <= MAX_LEN { return collapsed; } collapsed.chars().take(MAX_LEN).collect() } fn starts_with_chars(chars: &[char], start: usize, needle: &[char]) -> bool { start + needle.len() <= chars.len() && chars[start..start + needle.len()] == *needle } fn dollar_quote_tag_at(chars: &[char], start: usize) -> Option { if chars.get(start) != Some(&'$') { return None; } match chars.get(start + 1) { Some('$') => return Some("$$".to_string()), Some(ch) if ch.is_ascii_alphabetic() || *ch == '_' => {} _ => return None, } let mut end = start + 2; while let Some(ch) = chars.get(end) { if *ch == '$' { return Some(chars[start..=end].iter().collect()); } if !ch.is_ascii_alphanumeric() && *ch != '_' { return None; } end += 1; } None } fn has_executable_sql(statement: &str) -> bool { let chars = statement.chars().collect::>(); let mut in_line_comment = false; let mut in_block_comment = false; let mut previous = None; let mut i = 0; while i < chars.len() { let ch = chars[i]; let next = chars.get(i + 1).copied(); if in_line_comment { if ch == '\n' { in_line_comment = false; } previous = Some(ch); i += 1; continue; } if in_block_comment { if previous == Some('*') && ch == '/' { in_block_comment = false; } previous = Some(ch); i += 1; continue; } if ch == '-' && next == Some('-') { in_line_comment = true; previous = Some(ch); i += 1; continue; } if ch == '/' && next == Some('*') { if is_mysql_executable_comment_start(&chars, i) { return true; } in_block_comment = true; previous = Some(ch); i += 1; continue; } if !ch.is_whitespace() { return true; } previous = Some(ch); i += 1; } false } fn is_mysql_executable_comment_start(chars: &[char], start: usize) -> bool { chars.get(start) == Some(&'/') && chars.get(start + 1) == Some(&'*') && (chars.get(start + 2) == Some(&'!') || (chars.get(start + 2) == Some(&'M') && chars.get(start + 3) == Some(&'!'))) } #[cfg(test)] fn split_sql_script(sql: &str) -> Result, String> { Ok(split_sql_statements(sql)) } #[cfg(test)] mod tests { use super::{split_sql_script, SqlStatementSplitter}; #[test] fn splits_semicolon_delimited_statements() { assert_eq!( split_sql_script("CREATE TABLE a(id int); INSERT INTO a VALUES (1);").unwrap(), vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] ); } #[test] fn ignores_semicolons_inside_quotes_and_comments() { let sql = "\ INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`);\n\ -- comment ; ignored\n\ /* block ; ignored */\n\ SELECT 1;"; assert_eq!( split_sql_script(sql).unwrap(), vec![ "INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`)", "-- comment ; ignored\n/* block ; ignored */\nSELECT 1", ] ); } #[test] fn emits_trailing_statement_without_semicolon() { assert_eq!( split_sql_script("CREATE TABLE a(id int);\nINSERT INTO a VALUES (1)").unwrap(), vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] ); } #[test] fn line_comment_openers_can_span_chunks() { let mut splitter = SqlStatementSplitter::default(); assert_eq!(splitter.push_chunk("SELECT 1; -"), vec!["SELECT 1"]); assert_eq!(splitter.push_chunk("- comment ; ignored\nSELECT 2;"), vec!["-- comment ; ignored\nSELECT 2"]); assert_eq!(splitter.finish(), Vec::::new()); } #[test] fn block_comment_openers_can_span_chunks() { let mut splitter = SqlStatementSplitter::default(); assert_eq!(splitter.push_chunk("SELECT 1; /"), vec!["SELECT 1"]); assert_eq!(splitter.push_chunk("* comment ; ignored */\nSELECT 2;"), vec!["/* comment ; ignored */\nSELECT 2"]); assert_eq!(splitter.finish(), Vec::::new()); } #[test] fn skips_comment_only_tail_after_statement() { assert_eq!( split_sql_script("CREATE TABLE a(id int); -- done\n/* no more sql */").unwrap(), vec!["CREATE TABLE a(id int)"] ); } #[test] fn keeps_postgres_dollar_quoted_function_body_together() { let sql = "\ CREATE FUNCTION bump_counter()\n\ RETURNS trigger AS $$\n\ BEGIN\n\ PERFORM 1;\n\ RETURN NEW;\n\ END;\n\ $$ LANGUAGE plpgsql;\n\ SELECT 1;"; assert_eq!( split_sql_script(sql).unwrap(), vec![ "CREATE FUNCTION bump_counter()\nRETURNS trigger AS $$\nBEGIN\nPERFORM 1;\nRETURN NEW;\nEND;\n$$ LANGUAGE plpgsql", "SELECT 1", ] ); } #[test] fn keeps_mysql_executable_comments_as_statements() { assert_eq!( split_sql_script("/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\nSELECT 1;",).unwrap(), vec!["/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */", "SELECT 1",] ); } }