#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqlStatement {
pub sql: String,
}
impl SqlStatement {
#[must_use]
pub fn single(sql: impl Into<String>) -> Self {
let sql = sql.into();
Self {
sql: sql.trim().to_owned(),
}
}
#[must_use]
pub fn split(script: &str) -> Vec<Self> {
let mut statements = Vec::new();
let mut current = String::new();
let bytes = script.as_bytes();
let mut index = 0;
while index < bytes.len() {
let rest = &script[index..];
if rest.starts_with("--") {
let end = rest.find('\n').map(|n| n + 1).unwrap_or(rest.len());
index += end;
continue;
}
if rest.starts_with("/*") {
let end = rest.find("*/").map(|n| n + 2).unwrap_or(rest.len());
index += end;
continue;
}
let bytes_rest = &bytes[index..];
if bytes_rest.first().is_some_and(|&b| b == b'\'') {
current.push('\'');
index += 1;
while index < bytes.len() {
let b = bytes[index];
current.push(b as char);
index += 1;
if b == b'\'' {
if index < bytes.len() && bytes[index] == b'\'' {
current.push('\'');
index += 1;
continue;
}
break;
}
}
continue;
}
if bytes_rest.first().is_some_and(|&b| b == b'"') {
current.push('"');
index += 1;
while index < bytes.len() {
let b = bytes[index];
current.push(b as char);
index += 1;
if b == b'"' {
if index < bytes.len() && bytes[index] == b'"' {
current.push('"');
index += 1;
continue;
}
break;
}
}
continue;
}
if bytes_rest.first().is_some_and(|&b| b == b';') {
let trimmed = current.trim();
if !trimmed.is_empty() {
statements.push(Self {
sql: trimmed.to_owned(),
});
}
current.clear();
index += 1;
continue;
}
let ch = bytes[index] as char;
current.push(ch);
index += 1;
}
let trimmed = current.trim();
if !trimmed.is_empty() {
statements.push(Self {
sql: trimmed.to_owned(),
});
}
statements
}
}
#[cfg(test)]
mod tests {
use super::SqlStatement;
#[test]
fn splits_simple_statements() {
let stmts = SqlStatement::split("SELECT 1; SELECT 2;");
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0].sql, "SELECT 1");
assert_eq!(stmts[1].sql, "SELECT 2");
}
#[test]
fn strips_line_and_block_comments() {
let stmts =
SqlStatement::split("-- comment\nSELECT 1; /* block */ SELECT 2 -- trailing\n;");
assert_eq!(stmts.len(), 2);
assert_eq!(stmts[0].sql, "SELECT 1");
assert_eq!(stmts[1].sql, "SELECT 2");
}
#[test]
fn does_not_split_on_semicolon_in_string() {
let stmts = SqlStatement::split("INSERT INTO t VALUES ('a;b');");
assert_eq!(stmts.len(), 1);
assert_eq!(stmts[0].sql, "INSERT INTO t VALUES ('a;b')");
}
#[test]
fn handles_escaped_quotes_in_string() {
let stmts = SqlStatement::split("INSERT INTO t VALUES ('it''s ok');");
assert_eq!(stmts.len(), 1);
assert!(stmts[0].sql.contains("it''s ok"));
}
#[test]
fn handles_unterminated_string_without_panicking() {
let stmts = SqlStatement::split("INSERT INTO t VALUES ('unterminated");
assert_eq!(stmts.len(), 1);
assert!(stmts[0].sql.contains("unterminated"));
}
#[test]
fn handles_quoted_identifiers() {
let stmts = SqlStatement::split("ALTER TABLE \"my;table\" ADD COLUMN x int;");
assert_eq!(stmts.len(), 1);
}
#[test]
fn empty_input_yields_no_statements() {
assert!(SqlStatement::split("").is_empty());
assert!(SqlStatement::split("-- only a comment").is_empty());
assert!(SqlStatement::split(" ; ; ").is_empty());
}
}