use crate::{Transform, TransformError, TransformerCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SqlFormatter;
impl Transform for SqlFormatter {
fn name(&self) -> &'static str {
"SQL Formatter"
}
fn id(&self) -> &'static str {
"sqlformatter"
}
fn description(&self) -> &'static str {
"Formats SQL queries with proper indentation and spacing"
}
fn category(&self) -> TransformerCategory {
TransformerCategory::Formatter
}
fn default_test_input(&self) -> &'static str {
"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());
}
format_sql(input)
}
}
enum SqlTokenType {
Keyword,
Identifier,
String,
Number,
Operator,
Punctuation,
Whitespace,
Parenthesis,
}
const NEWLINE_KEYWORDS: [&str; 16] = [
"FROM",
"WHERE",
"LEFT JOIN",
"RIGHT JOIN",
"INNER JOIN",
"OUTER JOIN",
"FULL JOIN",
"CROSS JOIN",
"JOIN",
"GROUP BY",
"HAVING",
"ORDER BY",
"LIMIT",
"UNION",
"UNION ALL",
"INTERSECT",
];
const MAJOR_KEYWORDS: [&str; 7] = [
"SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP",
];
fn format_sql(input: &str) -> Result<String, TransformError> {
let mut result = String::with_capacity(input.len() * 2);
let mut input_chars = input.chars().peekable();
let mut indent_level: usize = 0;
let mut at_beginning_of_line = true;
let mut previous_token_type = SqlTokenType::Whitespace;
let mut buffer = String::new();
let mut in_string = false;
let mut string_quote_char = '"';
let mut in_comment = false;
let mut in_multiline_comment = false;
let mut pending_whitespace = false;
while let Some(c) = input_chars.next() {
if (c == '\'' || c == '"') && !in_comment && !in_multiline_comment {
if !in_string {
in_string = true;
string_quote_char = c;
if !matches!(
previous_token_type,
SqlTokenType::Whitespace | SqlTokenType::Operator | SqlTokenType::Parenthesis
) {
result.push(' ');
}
result.push(c);
} else if c == string_quote_char {
if input_chars.peek() == Some(&c) {
result.push(c);
input_chars.next(); result.push(c);
} else {
in_string = false;
result.push(c);
}
} else {
result.push(c);
}
previous_token_type = SqlTokenType::String;
continue;
}
if in_string {
result.push(c);
continue;
}
if c == '-' && input_chars.peek() == Some(&'-') && !in_multiline_comment {
in_comment = true;
if !at_beginning_of_line {
result.push(' ');
}
result.push(c);
continue;
}
if in_comment {
result.push(c);
if c == '\n' {
in_comment = false;
at_beginning_of_line = true;
result.push_str(&" ".repeat(indent_level));
}
continue;
}
if c == '/' && input_chars.peek() == Some(&'*') && !in_comment {
in_multiline_comment = true;
if !at_beginning_of_line {
result.push(' ');
}
result.push(c);
continue;
}
if in_multiline_comment {
result.push(c);
if c == '*' && input_chars.peek() == Some(&'/') {
input_chars.next(); result.push('/');
in_multiline_comment = false;
}
continue;
}
if c.is_whitespace() {
if at_beginning_of_line && c != '\n' {
continue;
}
if c == '\n' {
if !at_beginning_of_line {
result.push('\n');
at_beginning_of_line = true;
result.push_str(&" ".repeat(indent_level));
}
} else if !at_beginning_of_line {
pending_whitespace = true;
}
previous_token_type = SqlTokenType::Whitespace;
continue;
}
if c == '(' {
if pending_whitespace && !at_beginning_of_line {
result.push(' ');
}
pending_whitespace = false;
result.push(c);
indent_level += 1;
result.push('\n');
result.push_str(&" ".repeat(indent_level));
at_beginning_of_line = true;
previous_token_type = SqlTokenType::Parenthesis;
continue;
}
if c == ')' {
pending_whitespace = false;
if !at_beginning_of_line {
result.push('\n');
}
indent_level = indent_level.saturating_sub(1);
if at_beginning_of_line {
result.truncate(result.rfind('\n').map(|pos| pos + 1).unwrap_or(0));
}
result.push_str(&" ".repeat(indent_level));
result.push(c);
previous_token_type = SqlTokenType::Parenthesis;
at_beginning_of_line = false;
continue;
}
if c == ',' {
result.push(c);
result.push('\n');
at_beginning_of_line = true;
result.push_str(&" ".repeat(indent_level));
previous_token_type = SqlTokenType::Punctuation;
continue;
}
if "+-*/=%<>!|&".contains(c) {
if pending_whitespace {
result.push(' ');
}
pending_whitespace = false;
result.push(c);
if !matches!(input_chars.peek(), Some(&'=') | Some(&'>') | Some(&'<')) {
result.push(' ');
}
previous_token_type = SqlTokenType::Operator;
at_beginning_of_line = false;
continue;
}
if c.is_alphabetic() || c == '_' || c == '@' || c == '#' || c == '$' {
buffer.clear();
buffer.push(c);
while let Some(&next_c) = input_chars.peek() {
if next_c.is_alphanumeric()
|| next_c == '_'
|| next_c == '@'
|| next_c == '#'
|| next_c == '$'
{
buffer.push(next_c);
input_chars.next();
} else {
break;
}
}
let upper_buffer = buffer.to_uppercase();
let is_keyword = is_sql_keyword(&upper_buffer);
if is_keyword {
let needs_newline = NEWLINE_KEYWORDS.contains(&upper_buffer.as_str())
|| (MAJOR_KEYWORDS.contains(&upper_buffer.as_str()) && !at_beginning_of_line);
if needs_newline && !at_beginning_of_line {
result.push('\n');
result.push_str(&" ".repeat(indent_level));
} else if pending_whitespace && !at_beginning_of_line {
result.push(' ');
}
pending_whitespace = false;
result.push_str(&upper_buffer);
result.push(' ');
previous_token_type = SqlTokenType::Keyword;
} else {
if pending_whitespace && !at_beginning_of_line {
result.push(' ');
}
pending_whitespace = false;
result.push_str(&buffer);
previous_token_type = SqlTokenType::Identifier;
}
at_beginning_of_line = false;
continue;
}
if c.is_numeric() || (c == '.' && input_chars.peek().is_some_and(|p| p.is_numeric())) {
if pending_whitespace && !at_beginning_of_line {
result.push(' ');
}
pending_whitespace = false;
result.push(c);
while let Some(&next_c) = input_chars.peek() {
if next_c.is_numeric() || next_c == '.' {
result.push(next_c);
input_chars.next();
} else {
break;
}
}
previous_token_type = SqlTokenType::Number;
at_beginning_of_line = false;
continue;
}
if pending_whitespace && !at_beginning_of_line {
result.push(' ');
}
pending_whitespace = false;
result.push(c);
at_beginning_of_line = false;
previous_token_type = SqlTokenType::Punctuation;
}
Ok(result)
}
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_formatter_empty() {
let transformer = SqlFormatter;
assert_eq!(transformer.transform("").unwrap(), "");
assert_eq!(transformer.transform(" ").unwrap(), "");
}
#[test]
fn test_sql_formatter_simple_select() {
let transformer = SqlFormatter;
let input = "SELECT id, name, email FROM users WHERE active = true ORDER BY name";
let expected =
"SELECT id,\nname,\nemail\nFROM users\nWHERE active = true ORDER BY name";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_sql_formatter_joins() {
let transformer = SqlFormatter;
let input = "SELECT u.id, u.name, o.order_date FROM users u JOIN orders o ON u.id = o.user_id WHERE o.total > 100";
let expected = "SELECT u.id,\nu.name,\no.order_date\nFROM users u\nJOIN orders o ON u.id = o.user_id\nWHERE o.total > 100";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_sql_formatter_nested_queries() {
let transformer = SqlFormatter;
let input = "SELECT * FROM (SELECT id, COUNT(*) as count FROM orders GROUP BY id) AS subquery WHERE count > 5";
let expected = "SELECT * \nFROM (\n SELECT id,\n COUNT(\n * \n ) AS count\n FROM orders GROUP BY id\n) AS subquery\nWHERE count > 5";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_sql_formatter_string_literals() {
let transformer = SqlFormatter;
let input = "SELECT * FROM users WHERE name = 'John''s' AND department = \"Sales\"";
let expected =
"SELECT * \nFROM users\nWHERE name = 'John''s' AND department = \"Sales\"";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
}