use regex_lite::Regex;
use std::sync::LazyLock;
static STRING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"'(?:[^']|'')*'").unwrap());
static PG_PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\d+\b").unwrap());
static NUMBERED_PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\?\d+\b").unwrap());
static NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+(?:\.\d+)?").unwrap());
static IN_LIST_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bIN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)").unwrap());
static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
pub(crate) fn normalize(sql: &str) -> String {
let s = STRING_RE.replace_all(sql, "?");
let s = PG_PLACEHOLDER_RE.replace_all(&s, "?");
let s = NUMBERED_PLACEHOLDER_RE.replace_all(&s, "?");
let s = replace_numbers(&s);
let s = IN_LIST_RE.replace_all(&s, "IN (?)");
let s = WHITESPACE_RE.replace_all(&s, " ");
s.trim().to_string()
}
fn is_ident_char(c: char) -> bool {
!c.is_ascii() || c.is_ascii_alphanumeric() || c == '_'
}
fn replace_numbers(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut last = 0;
for m in NUMBER_RE.find_iter(sql) {
let before = sql[..m.start()].chars().next_back();
let after = sql[m.end()..].chars().next();
let inside_ident = before.is_some_and(is_ident_char) || after.is_some_and(is_ident_char);
out.push_str(&sql[last..m.start()]);
out.push_str(if inside_ident { m.as_str() } else { "?" });
last = m.end();
}
out.push_str(&sql[last..]);
out
}
#[cfg(test)]
mod tests {
use crate::lib_on::sql::normalize::normalize;
#[test]
fn merges_integer_literals() {
assert_eq!(
normalize("SELECT * FROM users WHERE id = 1"),
normalize("SELECT * FROM users WHERE id = 42"),
);
assert_eq!(
normalize("SELECT * FROM users WHERE id = 1"),
"SELECT * FROM users WHERE id = ?",
);
}
#[test]
fn merges_string_literals() {
assert_eq!(
normalize("SELECT * FROM t WHERE name = 'alice'"),
normalize("SELECT * FROM t WHERE name = 'bob'"),
);
}
#[test]
fn merges_numbered_placeholders() {
assert_eq!(
normalize("INSERT INTO users (name, age) VALUES (?1, ?2)"),
"INSERT INTO users (name, age) VALUES (?, ?)",
);
assert_eq!(
normalize("SELECT * FROM users WHERE id = ?1"),
normalize("SELECT * FROM users WHERE id = $1"),
);
}
#[test]
fn collapses_in_lists() {
assert_eq!(
normalize("SELECT * FROM t WHERE id IN (1, 2, 3)"),
normalize("SELECT * FROM t WHERE id IN (9)"),
);
assert_eq!(
normalize("SELECT * FROM t WHERE id IN (1, 2, 3)"),
"SELECT * FROM t WHERE id IN (?)",
);
}
#[test]
fn merges_pg_placeholders() {
assert_eq!(
normalize("INSERT INTO users (name, age) VALUES ($1, $2)"),
"INSERT INTO users (name, age) VALUES (?, ?)",
);
assert_eq!(
normalize("SELECT * FROM t WHERE id IN ($1, $2, $3)"),
"SELECT * FROM t WHERE id IN (?)",
);
}
#[test]
fn collapses_placeholder_in_lists() {
assert_eq!(
normalize("SELECT * FROM t WHERE id IN (?, ?, ?)"),
"SELECT * FROM t WHERE id IN (?)",
);
}
#[test]
fn keeps_digits_inside_identifiers() {
assert_eq!(
normalize("SELECT t1.x FROM t1 WHERE id = 10"),
"SELECT t1.x FROM t1 WHERE id = ?",
);
assert_eq!(normalize("SELECT * FROM 2fa"), "SELECT * FROM 2fa");
}
#[test]
fn keeps_digits_inside_unicode_identifiers() {
assert_eq!(normalize("SELECT café1 FROM t"), "SELECT café1 FROM t");
assert_ne!(
normalize("SELECT café1 FROM t"),
normalize("SELECT café2 FROM t"),
);
}
#[test]
fn keeps_digits_after_combining_marks() {
let decomposed = "SELECT \"cafe\u{301}1\" FROM t";
assert_eq!(normalize(decomposed), decomposed);
assert_ne!(
normalize(decomposed),
normalize("SELECT \"cafe\u{301}2\" FROM t"),
);
}
#[test]
fn squashes_whitespace() {
assert_eq!(normalize("SELECT *\n FROM t"), "SELECT * FROM t",);
}
}