#[must_use]
pub fn normalize_sql(sql: &str) -> String {
let bytes = sql.as_bytes();
let len = bytes.len();
let mut output = Vec::with_capacity(len);
let mut cursor = 0usize;
let mut prev_was_ident_char = false;
while cursor < len {
let byte = bytes[cursor];
match byte {
b'\'' => {
cursor = skip_string_literal(bytes, cursor + 1, len);
output.push(b'?');
prev_was_ident_char = false;
}
byte_val if byte_val.is_ascii_digit() && !prev_was_ident_char => {
cursor = skip_number_literal(bytes, cursor, len);
output.push(b'?');
prev_was_ident_char = false;
}
b'-' | b'+'
if !prev_was_ident_char
&& cursor + 1 < len
&& bytes[cursor + 1].is_ascii_digit() =>
{
cursor = skip_number_literal(bytes, cursor + 1, len);
output.push(b'?');
prev_was_ident_char = false;
}
_ => {
output.push(byte);
prev_was_ident_char = byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$';
cursor += 1;
}
}
}
String::from_utf8(output).expect("normalize_sql produced invalid UTF-8")
}
fn skip_string_literal(bytes: &[u8], start: usize, len: usize) -> usize {
let mut cursor = start;
loop {
let Some(relative_pos) = memchr::memchr(b'\'', &bytes[cursor..]) else {
return len;
};
cursor += relative_pos + 1;
if cursor < len && bytes[cursor] == b'\'' {
cursor += 1;
} else {
return cursor;
}
}
}
fn skip_number_literal(bytes: &[u8], start: usize, len: usize) -> usize {
let mut cursor = start;
while cursor < len && bytes[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor + 1 < len && bytes[cursor] == b'.' && bytes[cursor + 1].is_ascii_digit() {
cursor += 1; while cursor < len && bytes[cursor].is_ascii_digit() {
cursor += 1;
}
}
cursor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_where_number_and_string() {
assert_eq!(
normalize_sql("SELECT * FROM t WHERE id = 42 AND name = 'alice'"),
"SELECT * FROM t WHERE id = ? AND name = ?"
);
}
#[test]
fn test_multiple_numeric_literals() {
assert_eq!(
normalize_sql("INSERT INTO t VALUES (1, 2, 3)"),
"INSERT INTO t VALUES (?, ?, ?)"
);
}
#[test]
fn test_escaped_quote_in_string() {
assert_eq!(normalize_sql("WHERE name = 'O''Brien'"), "WHERE name = ?");
}
#[test]
fn test_no_literals_unchanged() {
let sql_with_placeholder = "SELECT col FROM t WHERE id = ?";
assert_eq!(normalize_sql(sql_with_placeholder), sql_with_placeholder);
let sql_plain = "SELECT col FROM t";
assert_eq!(normalize_sql(sql_plain), sql_plain);
}
#[test]
fn test_insert_multiple_columns_with_float() {
assert_eq!(
normalize_sql("INSERT INTO orders (id, name, amount) VALUES (100, 'test', 3.14)"),
"INSERT INTO orders (id, name, amount) VALUES (?, ?, ?)"
);
}
#[test]
fn test_identifier_with_digits_not_replaced() {
assert_eq!(
normalize_sql("SELECT col1, table2 FROM t WHERE id = 1"),
"SELECT col1, table2 FROM t WHERE id = ?"
);
}
#[test]
fn test_unclosed_string_does_not_panic() {
let result = normalize_sql("SELECT 'unclosed");
assert_eq!(result, "SELECT ?");
}
}