use crate::{Transform, TransformError, TransformerCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SqlMinifier;
impl Transform for SqlMinifier {
fn name(&self) -> &'static str {
"SQL Minifier"
}
fn id(&self) -> &'static str {
"sqlminifier"
}
fn description(&self) -> &'static str {
"Minifies SQL queries by removing unnecessary whitespace and formatting"
}
fn category(&self) -> TransformerCategory {
TransformerCategory::Formatter
}
fn default_test_input(&self) -> &'static str {
r#"SELECT id, username, email
FROM users
WHERE status = 'active'
AND created_at > '2023-01-01'
ORDER BY created_at DESC
LIMIT 10"#
}
fn transform(&self, input: &str) -> Result<String, TransformError> {
if input.trim().is_empty() {
return Ok(String::new());
}
minify_sql(input)
}
}
fn minify_sql(input: &str) -> Result<String, TransformError> {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut string_delimiter = '"';
let mut in_comment = false;
let mut in_multiline_comment = false;
let mut last_char = '\0';
let mut last_token_is_keyword = false;
let mut current_word = String::new();
while let Some(c) = chars.next() {
if (c == '\'' || c == '"') && !in_comment && !in_multiline_comment {
if !in_string {
in_string = true;
string_delimiter = c;
result.push(c);
} else if c == string_delimiter {
if chars.peek() == Some(&c) {
result.push(c);
chars.next(); result.push(c);
} else {
in_string = false;
result.push(c);
}
} else {
result.push(c);
}
last_char = c;
continue;
}
if in_string {
result.push(c);
last_char = c;
continue;
}
if c == '-' && chars.peek() == Some(&'-') && !in_multiline_comment {
in_comment = true;
chars.next();
for next_c in chars.by_ref() {
if next_c == '\n' {
in_comment = false;
break;
}
}
continue;
}
if in_comment {
if c == '\n' {
in_comment = false;
}
continue;
}
if c == '/' && chars.peek() == Some(&'*') && !in_comment {
in_multiline_comment = true;
chars.next();
let mut asterisk_seen = false;
for next_c in chars.by_ref() {
if asterisk_seen && next_c == '/' {
in_multiline_comment = false;
break;
}
asterisk_seen = next_c == '*';
}
continue;
}
if in_multiline_comment {
continue;
}
if c.is_whitespace() {
continue;
}
if c.is_alphabetic() || c == '_' {
current_word.clear();
current_word.push(c);
while let Some(&next_c) = chars.peek() {
if next_c.is_alphanumeric() || next_c == '_' {
current_word.push(next_c);
chars.next();
} else {
break;
}
}
let upper_word = current_word.to_uppercase();
let is_keyword = is_sql_keyword(&upper_word);
let need_space = (is_keyword || last_token_is_keyword)
&& !result.is_empty()
&& !is_separator(last_char);
if need_space {
result.push(' ');
}
if is_keyword {
result.push_str(&upper_word);
last_token_is_keyword = true;
} else {
result.push_str(¤t_word);
last_token_is_keyword = false;
}
last_char = current_word.chars().last().unwrap_or('_');
continue;
}
if is_separator(c) {
if c == ',' {
result.push(c);
last_token_is_keyword = false;
}
else if "=<>!+*/".contains(c) {
result.push(c);
if chars.peek() == Some(&'=') {
result.push('=');
chars.next();
}
last_token_is_keyword = false;
}
else {
result.push(c);
last_token_is_keyword = false;
}
last_char = c;
continue;
}
result.push(c);
last_token_is_keyword = false;
last_char = c;
}
Ok(result)
}
fn is_separator(c: char) -> bool {
"(),;=<>!+-*/".contains(c)
}
fn is_sql_keyword(word: &str) -> bool {
const KEYWORDS: [&str; 59] = [
"SELECT",
"FROM",
"WHERE",
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"CREATE",
"ALTER",
"TABLE",
"VIEW",
"INDEX",
"TRIGGER",
"PROCEDURE",
"FUNCTION",
"DATABASE",
"SCHEMA",
"GRANT",
"REVOKE",
"JOIN",
"INNER",
"OUTER",
"LEFT",
"RIGHT",
"FULL",
"CROSS",
"NATURAL",
"GROUP",
"ORDER",
"BY",
"HAVING",
"UNION",
"ALL",
"INTERSECT",
"EXCEPT",
"INTO",
"VALUES",
"SET",
"AS",
"ON",
"AND",
"OR",
"NOT",
"NULL",
"IS",
"IN",
"BETWEEN",
"LIKE",
"EXISTS",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"ASC",
"DESC",
"LIMIT",
"OFFSET",
"WITH",
];
KEYWORDS.contains(&word)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sql_minifier_empty() {
let transformer = SqlMinifier;
assert_eq!(transformer.transform("").unwrap(), "");
assert_eq!(transformer.transform(" ").unwrap(), "");
}
#[test]
fn test_sql_minifier_simple_select() {
let transformer = SqlMinifier;
let input = transformer.default_test_input();
let actual = transformer.transform(input).unwrap();
assert_eq!(actual, "SELECT id,username,email FROM users WHERE status='active' AND created_at>'2023-01-01' ORDER BY created_at DESC LIMIT10");
}
#[test]
fn test_sql_minifier_complex_query() {
let transformer = SqlMinifier;
let input = r#"
SELECT
u.id,
u.name,
COUNT(o.id) AS order_count
FROM
users u
LEFT JOIN
orders o ON u.id = o.user_id
WHERE
u.status = 'active'
AND u.created_at > '2023-01-01'
GROUP BY
u.id,
u.name
HAVING
COUNT(o.id) > 0
ORDER BY
order_count DESC
LIMIT 20
"#;
let actual = transformer.transform(input).unwrap();
assert_eq!(actual, "SELECT u.id,u.name,COUNT(o.id)AS order_count FROM usersu LEFT JOIN orderso ON u.id=o.user_id WHERE u.status='active' AND u.created_at>'2023-01-01' GROUP BY u.id,u.name HAVING COUNT(o.id)>0 ORDER BY order_count DESC LIMIT20");
}
#[test]
fn test_sql_minifier_preserves_string_literals() {
let transformer = SqlMinifier;
let input = "SELECT * FROM users WHERE name = 'John''s Data' AND department = \"Sales & Marketing\"";
let actual = transformer.transform(input).unwrap();
assert_eq!(
actual,
"SELECT*FROM users WHERE name='John''s Data' AND department=\"Sales & Marketing\""
);
}
#[test]
fn test_sql_minifier_strips_comments() {
let transformer = SqlMinifier;
let input = r#"
SELECT id, name -- This is the user ID and name
FROM users
/* This is a multi-line comment
* that spans multiple lines
*/
WHERE active = 1
"#;
let expected = "SELECT id,name FROM users WHERE active=1";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
}