#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParameterizedSql {
pub template: String,
pub literals: Vec<String>,
}
pub fn parameterize(sql: &str) -> ParameterizedSql {
let bytes = sql.as_bytes();
let n = bytes.len();
let mut template = String::with_capacity(n);
let mut literals: Vec<String> = Vec::new();
let mut i = 0usize;
while i < n {
if i + 1 < n && bytes[i] == b'-' && bytes[i + 1] == b'-' {
while i < n && bytes[i] != b'\n' {
template.push(bytes[i] as char);
i += 1;
}
continue;
}
if i + 1 < n && bytes[i] == b'/' && bytes[i + 1] == b'*' {
template.push('/');
template.push('*');
i += 2;
while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
template.push(bytes[i] as char);
i += 1;
}
if i + 1 < n {
template.push('*');
template.push('/');
i += 2;
}
continue;
}
if bytes[i] == b'\'' {
let start = i;
i += 1;
while i < n {
if bytes[i] == b'\'' {
i += 1;
if i < n && bytes[i] == b'\'' {
i += 1;
} else {
break; }
} else {
i += 1;
}
}
literals.push(sql[start..i].to_string());
template.push('?');
continue;
}
if bytes[i] == b'"' {
template.push('"');
i += 1;
while i < n && bytes[i] != b'"' {
template.push(bytes[i] as char);
i += 1;
}
if i < n {
template.push('"');
i += 1;
}
continue;
}
if keyword_at(bytes, i, b"NULL") {
literals.push("NULL".to_string());
template.push('?');
i += 4;
continue;
}
if keyword_at(bytes, i, b"TRUE") {
literals.push("TRUE".to_string());
template.push('?');
i += 4;
continue;
}
if keyword_at(bytes, i, b"FALSE") {
literals.push("FALSE".to_string());
template.push('?');
i += 5;
continue;
}
if bytes[i].is_ascii_digit() {
let start = i;
i = consume_number(bytes, i);
literals.push(sql[start..i].to_string());
template.push('?');
continue;
}
if bytes[i] == b'-'
&& i + 1 < n
&& bytes[i + 1].is_ascii_digit()
&& in_value_context(&template)
{
let start = i;
i += 1; i = consume_number(bytes, i);
literals.push(sql[start..i].to_string());
template.push('?');
continue;
}
template.push(bytes[i] as char);
i += 1;
}
ParameterizedSql { template, literals }
}
fn consume_number(bytes: &[u8], mut i: usize) -> usize {
let n = bytes.len();
while i < n && bytes[i].is_ascii_digit() {
i += 1;
}
if i < n && bytes[i] == b'.' {
i += 1;
while i < n && bytes[i].is_ascii_digit() {
i += 1;
}
}
if i < n && (bytes[i] == b'e' || bytes[i] == b'E') {
i += 1;
if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
i += 1;
}
while i < n && bytes[i].is_ascii_digit() {
i += 1;
}
}
i
}
fn keyword_at(bytes: &[u8], i: usize, keyword: &[u8]) -> bool {
let n = keyword.len();
if i + n > bytes.len() {
return false;
}
for (j, &k) in keyword.iter().enumerate() {
if bytes[i + j].to_ascii_uppercase() != k {
return false;
}
}
let after = i + n;
if after < bytes.len() && (bytes[after].is_ascii_alphanumeric() || bytes[after] == b'_') {
return false;
}
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
return false;
}
true
}
fn in_value_context(template: &str) -> bool {
let last = template
.bytes()
.rev()
.find(|&b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r'));
matches!(
last,
None | Some(b'=')
| Some(b'<')
| Some(b'>')
| Some(b'(')
| Some(b',')
| Some(b'[')
| Some(b'+')
| Some(b'*')
| Some(b'/')
| Some(b'!')
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_integer_literal() {
let p = parameterize("SELECT * FROM t WHERE id = 42");
assert_eq!(p.template, "SELECT * FROM t WHERE id = ?");
assert_eq!(p.literals, vec!["42"]);
}
#[test]
fn test_string_literal() {
let p = parameterize("SELECT * FROM t WHERE name = 'Alice'");
assert_eq!(p.template, "SELECT * FROM t WHERE name = ?");
assert_eq!(p.literals, vec!["'Alice'"]);
}
#[test]
fn test_float_literal() {
let p = parameterize("WHERE x = 3.14");
assert_eq!(p.template, "WHERE x = ?");
assert_eq!(p.literals, vec!["3.14"]);
}
#[test]
fn test_scientific_notation() {
let p = parameterize("WHERE x = 1.5e10");
assert_eq!(p.template, "WHERE x = ?");
assert_eq!(p.literals, vec!["1.5e10"]);
}
#[test]
fn test_null_keyword() {
let p = parameterize("WHERE x IS NULL");
assert_eq!(p.template, "WHERE x IS ?");
assert_eq!(p.literals, vec!["NULL"]);
}
#[test]
fn test_true_false_keywords() {
let p = parameterize("WHERE active = TRUE AND deleted = FALSE");
assert_eq!(p.template, "WHERE active = ? AND deleted = ?");
assert_eq!(p.literals, vec!["TRUE", "FALSE"]);
}
#[test]
fn test_negative_integer() {
let p = parameterize("WHERE x = -5");
assert_eq!(p.template, "WHERE x = ?");
assert_eq!(p.literals, vec!["-5"]);
}
#[test]
fn test_escaped_quote_in_string() {
let p = parameterize("WHERE name = 'it''s'");
assert_eq!(p.template, "WHERE name = ?");
assert_eq!(p.literals, vec!["'it''s'"]);
}
#[test]
fn test_double_quoted_identifier_not_parameterized() {
let p = parameterize(r#"SELECT "id" FROM t WHERE "id" = 1"#);
assert!(
p.template.contains(r#""id""#),
"double-quoted identifier should be preserved"
);
assert_eq!(p.literals, vec!["1"]);
}
#[test]
fn test_multiple_literals() {
let p = parameterize("SELECT * FROM t WHERE x = 1 AND y = 'foo' AND z = NULL");
assert_eq!(
p.template,
"SELECT * FROM t WHERE x = ? AND y = ? AND z = ?"
);
assert_eq!(p.literals.len(), 3);
}
#[test]
fn test_parameterize_is_fixpoint() {
let sql = "SELECT id FROM t WHERE x = 42 AND name = 'Bob' AND active = TRUE";
let first = parameterize(sql);
let second = parameterize(&first.template);
assert_eq!(
first.template, second.template,
"parameterize should be a fixpoint"
);
}
#[test]
fn test_line_comment_not_parameterized() {
let p = parameterize("SELECT 1 -- this is 42\nFROM t");
assert_eq!(
p.literals.len(),
1,
"only the numeric literal 1 should be replaced"
);
}
#[test]
fn test_subtraction_not_negative_number() {
let p = parameterize("WHERE x - 5 > 0");
assert!(
p.template.contains('-'),
"subtraction operator should not be consumed as negative literal"
);
}
}