#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Kind {
Query,
Commit,
Rollback,
Change { delete: bool, current_of: Option<String> },
Refused(&'static str),
Other,
Unacceptable,
Unknown,
}
fn words(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' || c == '"' {
while let Some(q) = chars.next() {
if q == c && chars.next_if_eq(&c).is_none() {
break;
}
}
out.push("'".into());
} else if c.is_alphanumeric() || matches!(c, '_' | '#' | '@' | '$') {
let mut word = c.to_uppercase().collect::<String>();
while let Some(n) = chars.next_if(|&n| n.is_alphanumeric() || matches!(n, '_' | '#' | '@' | '$' | '-')) {
word.extend(n.to_uppercase());
}
out.push(word);
} else if !c.is_whitespace() {
out.push(c.to_string());
}
}
out
}
pub(super) fn kind(text: &str) -> Kind {
let words = words(text);
let word = |i: usize| words.get(i).map_or("", String::as_str);
let n = words.len();
let work = n == 1 || (n == 2 && word(1) == "WORK");
let to_savepoint = |at: usize| word(at) == "TO" && word(at + 1) == "SAVEPOINT";
match word(0) {
"SELECT" | "WITH" | "VALUES" | "(" => Kind::Query,
"COMMIT" if work => Kind::Commit,
"ROLLBACK" if work => Kind::Rollback,
"ROLLBACK" if to_savepoint(1) || (word(1) == "WORK" && to_savepoint(2)) => Kind::Refused("ROLLBACK TO SAVEPOINT"),
"SAVEPOINT" => Kind::Refused("SAVEPOINT"),
"RELEASE" if word(1) == "SAVEPOINT" || to_savepoint(1) => Kind::Refused("RELEASE SAVEPOINT"),
verb @ ("INSERT" | "UPDATE" | "DELETE") => {
let positioned = n >= 5 && word(n - 4) == "WHERE" && word(n - 3) == "CURRENT" && word(n - 2) == "OF" && word(n - 1) != "'";
Kind::Change { delete: verb == "DELETE", current_of: positioned.then(|| word(n - 1).to_owned()) }
}
"DECLARE" if (word(1), word(2), word(3)) == ("GLOBAL", "TEMPORARY", "TABLE") => Kind::Other,
"SET" if !matches!(word(1), "CONNECTION" | ":") => Kind::Other,
"" | "BEGIN" | "CALL" | "CLOSE" | "CONNECT" | "DECLARE" | "DESCRIBE" | "DISCONNECT" | "END" | "EXECUTE" | "FETCH" | "GET" | "INCLUDE" | "OPEN" | "PREPARE" | "RELEASE" | "SET" | "WHENEVER" => {
Kind::Unacceptable
}
"ALLOCATE" | "ALTER" | "ASSOCIATE" | "COMMENT" | "CREATE" | "DROP" | "EXPLAIN" | "FREE" | "GRANT" | "HOLD" | "LABEL" | "LOCK" | "MERGE" | "REFRESH" | "RENAME" | "REVOKE" | "SIGNAL" | "TRANSFER" | "TRUNCATE" => Kind::Other,
_ => Kind::Unknown,
}
}
pub(super) fn markers(text: &str) -> usize {
words(text).iter().filter(|w| *w == "?").count()
}
pub(super) fn verb(text: &str) -> String {
words(text).into_iter().next().filter(|w| w != "'").unwrap_or_default()
}
pub(super) fn normalise(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let (mut rest, mut space) = (text, false);
while let Some(c) = rest.chars().next() {
let skipped = if c.is_whitespace() {
Some(c.len_utf8())
} else if rest.starts_with("--") {
Some(rest.find(['\n', '\r', '\u{85}']).unwrap_or(rest.len()))
} else if rest.starts_with("/*") {
bracketed(rest)
} else {
None
};
if let Some(n) = skipped {
(rest, space) = (&rest[n..], true);
continue;
}
if space && !out.is_empty() {
out.push(' ');
}
let n = if c == '\'' || c == '"' { quoted(rest, c) } else { c.len_utf8() };
out.push_str(&rest[..n]);
(rest, space) = (&rest[n..], false);
}
out
}
fn quoted(text: &str, quote: char) -> usize {
let mut chars = text.char_indices().skip(1).peekable();
while let Some((at, c)) = chars.next() {
if c == quote && chars.next_if(|&(_, n)| n == quote).is_none() {
return at + c.len_utf8();
}
}
text.len()
}
fn bracketed(text: &str) -> Option<usize> {
let (mut depth, mut at) = (0usize, 0);
while at < text.len() {
if text[at..].starts_with("/*") {
(depth, at) = (depth + 1, at + 2);
} else if text[at..].starts_with("*/") {
(depth, at) = (depth - 1, at + 2);
if depth == 0 {
return Some(at);
}
} else {
at += text[at..].chars().next().map_or(1, char::len_utf8);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn statements_are_kinds_by_their_first_words() {
assert_eq!(kind("select a from t where b = ?"), Kind::Query);
assert_eq!(kind("WITH X AS (SELECT 1 FROM T) SELECT * FROM X"), Kind::Query);
assert_eq!(kind("(SELECT A FROM T) UNION (SELECT B FROM U)"), Kind::Query);
assert_eq!(kind("COMMIT WORK"), Kind::Commit);
assert_eq!(kind("ROLLBACK"), Kind::Rollback);
assert_eq!(kind("ROLLBACK TO SAVEPOINT A"), Kind::Refused("ROLLBACK TO SAVEPOINT"));
assert_eq!(kind("SAVEPOINT A ON ROLLBACK RETAIN CURSORS"), Kind::Refused("SAVEPOINT"));
assert_eq!(kind("RELEASE SAVEPOINT A"), Kind::Refused("RELEASE SAVEPOINT"));
assert_eq!(kind("UPDATE D01.MAA AS MAA SET X = 1 WHERE K = ?"), Kind::Change { delete: false, current_of: None });
assert_eq!(kind("DELETE FROM T WHERE CURRENT OF CSR-1"), Kind::Change { delete: true, current_of: Some("CSR-1".into()) });
assert_eq!(kind("UPDATE T SET A = 'WHERE CURRENT OF C'"), Kind::Change { delete: false, current_of: None });
assert_eq!(kind("CREATE TABLE T (A INT)"), Kind::Other);
assert_eq!(kind("DECLARE GLOBAL TEMPORARY TABLE SESSION.T (A INT)"), Kind::Other);
assert_eq!(kind("SET CURRENT SQLID = 'X'"), Kind::Other);
assert_eq!(kind("SET CONNECTION LOC1"), Kind::Unacceptable);
assert_eq!(kind("CONNECT TO LOC1"), Kind::Unacceptable);
assert_eq!(kind("DECLARE C1 CURSOR FOR S1"), Kind::Unacceptable);
assert_eq!(kind("CALL PROC1(1)"), Kind::Unacceptable);
assert_eq!(kind("RELEASE LOC1"), Kind::Unacceptable);
assert_eq!(kind(""), Kind::Unacceptable);
assert_eq!(kind("ROLLBACK WORK TO SAVEPOINT"), Kind::Refused("ROLLBACK TO SAVEPOINT"));
assert_eq!(kind("TRUNCATE TABLE T IMMEDIATE"), Kind::Other);
assert_eq!(kind("LOCK TABLE T IN SHARE MODE"), Kind::Other);
for unknown in ["SELEC A FROM T", "COMMIT AND CHAIN", "ROLLBACK AND CHAIN", "ABORT", "START TRANSACTION", "DEALLOCATE ALL", "DISCARD ALL", "TABLE T"] {
assert_eq!(kind(unknown), Kind::Unknown, "{unknown}");
}
}
#[test]
fn markers_count_question_marks_outside_strings() {
assert_eq!(markers("INSERT INTO T VALUES (?, ?, '?', \"?\")"), 2);
assert_eq!(markers("UPDATE T SET A = 'IT''S ?' WHERE B = ?"), 1);
assert_eq!(markers("DELETE FROM T"), 0);
}
#[test]
fn comments_are_not_part_of_the_statement() {
assert_eq!(normalise("DELETE FROM T -- closed ones\n WHERE STATUS = 'C'"), "DELETE FROM T WHERE STATUS = 'C'");
assert_eq!(normalise("SELECT A /* the key /* nested */ */ FROM T"), "SELECT A FROM T");
assert_eq!(normalise("SELECT '--' , \"/*\" FROM T -- end"), "SELECT '--' , \"/*\" FROM T");
assert_eq!(normalise("SELECT A FROM T /* never closed"), "SELECT A FROM T /* never closed");
assert_eq!(kind(&normalise("/* list */ SELECT A FROM T")), Kind::Query);
assert_eq!(kind(&normalise("-- why\nSELECT A FROM T")), Kind::Query);
assert_eq!(markers(&normalise("UPDATE T SET A = ? -- isn't it?\n WHERE B = ?")), 2);
let positioned = normalise("DELETE FROM T WHERE CURRENT OF C1 -- done");
assert_eq!(kind(&positioned), Kind::Change { delete: true, current_of: Some("C1".into()) });
}
#[test]
fn normalising_folds_white_space_outside_strings() {
assert_eq!(normalise(" UPDATE T\n SET A = 'X Y'\t WHERE B = 1 "), "UPDATE T SET A = 'X Y' WHERE B = 1");
assert_eq!(normalise("SELECT 'A'' B' FROM T"), "SELECT 'A'' B' FROM T");
assert_eq!(verb(" insert into t values (1)"), "INSERT");
assert_eq!(verb("'x'"), "");
}
}