use crate::cli::DatabaseKind;
const SQL_RESERVED: &[&str] = &[
"ABORT",
"ALL",
"ALTER",
"AND",
"ANY",
"AS",
"ASC",
"AUTHORIZATION",
"BEFORE",
"BEGIN",
"BETWEEN",
"BOTH",
"BY",
"CASE",
"CAST",
"CHECK",
"COLLATE",
"COLUMN",
"COMMIT",
"CONSTRAINT",
"CREATE",
"CROSS",
"CURRENT",
"CURRENT_DATE",
"CURRENT_ROLE",
"CURRENT_SCHEMA",
"CURRENT_TIME",
"CURRENT_TIMESTAMP",
"CURRENT_USER",
"DEFAULT",
"DEFERRABLE",
"DELETE",
"DESC",
"DISTINCT",
"DO",
"DROP",
"ELSE",
"END",
"EXCEPT",
"EXISTS",
"FALSE",
"FETCH",
"FOR",
"FOREIGN",
"FROM",
"FULL",
"GRANT",
"GROUP",
"HAVING",
"IF",
"IN",
"INDEX",
"INNER",
"INSERT",
"INTERSECT",
"INTO",
"IS",
"JOIN",
"KEY",
"LATERAL",
"LEADING",
"LEFT",
"LIKE",
"LIMIT",
"LOCALTIME",
"LOCALTIMESTAMP",
"NATURAL",
"NOT",
"NULL",
"OF",
"OFFSET",
"ON",
"ONLY",
"OR",
"ORDER",
"OUTER",
"OVERLAPS",
"PLACING",
"PRIMARY",
"REFERENCES",
"RETURNING",
"RIGHT",
"ROLLBACK",
"SCHEMA",
"SELECT",
"SESSION_USER",
"SET",
"SIMILAR",
"SOME",
"SYMMETRIC",
"TABLE",
"THEN",
"TO",
"TRAILING",
"TRIGGER",
"TRUE",
"UNION",
"UNIQUE",
"UPDATE",
"USER",
"USING",
"VALUES",
"VARIADIC",
"VIEW",
"WHEN",
"WHERE",
"WINDOW",
"WITH",
];
fn is_reserved_keyword(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
SQL_RESERVED.binary_search(&upper.as_str()).is_ok()
}
pub fn is_safe_unquoted(name: &str, _db: DatabaseKind) -> bool {
if name.is_empty() {
return false;
}
let bytes = name.as_bytes();
let first = bytes[0];
let first_ok = first == b'_' || first.is_ascii_lowercase();
if !first_ok {
return false;
}
for &b in &bytes[1..] {
let ok = b == b'_' || b.is_ascii_lowercase() || b.is_ascii_digit();
if !ok {
return false;
}
}
!is_reserved_keyword(name)
}
pub fn quote_ident(name: &str, db: DatabaseKind) -> String {
if is_safe_unquoted(name, db) {
name.to_string()
} else {
quote_ident_always(name, db)
}
}
pub fn quote_ident_always(name: &str, db: DatabaseKind) -> String {
match db {
DatabaseKind::Mysql => format!("`{}`", name.replace('`', "``")),
DatabaseKind::Postgres | DatabaseKind::Sqlite => {
format!("\"{}\"", name.replace('"', "\"\""))
}
}
}
pub fn quote_qualified(schema: Option<&str>, table: &str, db: DatabaseKind) -> String {
match schema {
Some(s) => format!("{}.{}", quote_ident(s, db), quote_ident(table, db)),
None => quote_ident(table, db),
}
}
pub fn is_safe_ident(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_identifier_pg_not_quoted() {
assert_eq!(quote_ident("users", DatabaseKind::Postgres), "users");
assert_eq!(quote_ident("agent_id", DatabaseKind::Postgres), "agent_id");
assert_eq!(
quote_ident("agent__connector", DatabaseKind::Postgres),
"agent__connector"
);
}
#[test]
fn safe_identifier_mysql_not_quoted() {
assert_eq!(quote_ident("users", DatabaseKind::Mysql), "users");
}
#[test]
fn uppercase_identifier_pg_quoted() {
assert_eq!(quote_ident("Users", DatabaseKind::Postgres), "\"Users\"");
}
#[test]
fn reserved_word_quoted_in_pg() {
assert_eq!(quote_ident("select", DatabaseKind::Postgres), "\"select\"");
assert_eq!(quote_ident("user", DatabaseKind::Postgres), "\"user\"");
assert_eq!(quote_ident("order", DatabaseKind::Postgres), "\"order\"");
}
#[test]
fn reserved_word_quoted_in_mysql() {
assert_eq!(quote_ident("select", DatabaseKind::Mysql), "`select`");
}
#[test]
fn identifier_with_dash_quoted() {
assert_eq!(
quote_ident("user-id", DatabaseKind::Postgres),
"\"user-id\""
);
}
#[test]
fn identifier_starting_with_digit_quoted() {
assert_eq!(quote_ident("123abc", DatabaseKind::Postgres), "\"123abc\"");
}
#[test]
fn empty_identifier_quoted() {
assert_eq!(quote_ident("", DatabaseKind::Postgres), "\"\"");
}
#[test]
fn injection_attempt_quoted_and_escaped() {
assert_eq!(
quote_ident("user\"; DROP TABLE x; --", DatabaseKind::Postgres),
"\"user\"\"; DROP TABLE x; --\""
);
}
#[test]
fn always_quote_safe_identifier_pg() {
assert_eq!(
quote_ident_always("users", DatabaseKind::Postgres),
"\"users\""
);
}
#[test]
fn always_quote_safe_identifier_mysql() {
assert_eq!(quote_ident_always("users", DatabaseKind::Mysql), "`users`");
}
#[test]
fn always_quote_escapes_internal_backtick() {
assert_eq!(quote_ident_always("ev`il", DatabaseKind::Mysql), "`ev``il`");
}
#[test]
fn qualified_safe_idents_not_quoted() {
assert_eq!(
quote_qualified(Some("agent"), "agent_connector", DatabaseKind::Postgres),
"agent.agent_connector"
);
assert_eq!(
quote_qualified(Some("app"), "users", DatabaseKind::Mysql),
"app.users"
);
}
#[test]
fn qualified_with_reserved_schema_quoted() {
assert_eq!(
quote_qualified(Some("user"), "items", DatabaseKind::Postgres),
"\"user\".items"
);
}
#[test]
fn qualified_without_schema() {
assert_eq!(quote_qualified(None, "users", DatabaseKind::Mysql), "users");
}
#[test]
fn safe_ident_rejects_dash() {
assert!(!is_safe_ident("user-id"));
}
#[test]
fn safe_ident_rejects_leading_digit() {
assert!(!is_safe_ident("123abc"));
}
#[test]
fn safe_ident_rejects_empty() {
assert!(!is_safe_ident(""));
}
#[test]
fn safe_ident_accepts_underscore_prefix() {
assert!(is_safe_ident("_private"));
}
#[test]
fn safe_ident_accepts_mixed_case() {
assert!(is_safe_ident("UserAccount2"));
}
#[test]
fn reserved_list_is_sorted() {
for pair in SQL_RESERVED.windows(2) {
assert!(
pair[0] < pair[1],
"SQL_RESERVED must be sorted; '{}' >= '{}'",
pair[0],
pair[1]
);
}
}
}