Skip to main content

database_mcp_sql/
identifier.rs

1//! Shared identifier validation for all database backends.
2
3use database_mcp_server::AppError;
4
5/// Wraps `name` in `quote_char` for safe use in SQL statements.
6///
7/// Escapes internal occurrences of `quote_char` by doubling them.
8#[must_use]
9pub fn quote_identifier(name: &str, quote_char: char) -> String {
10    let doubled: String = std::iter::repeat_n(quote_char, 2).collect();
11    let escaped = name.replace(quote_char, &doubled);
12    format!("{quote_char}{escaped}{quote_char}")
13}
14
15/// Wraps `value` in single quotes for safe use as a SQL string literal.
16///
17/// Escapes internal single quotes by doubling them.
18#[must_use]
19pub fn quote_string(value: &str) -> String {
20    let escaped = value.replace('\'', "''");
21    format!("'{escaped}'")
22}
23
24/// Validates that `name` is a non-empty identifier without control characters.
25///
26/// # Errors
27///
28/// Returns [`AppError::InvalidIdentifier`] if the name is empty,
29/// whitespace-only, or contains control characters.
30pub fn validate_identifier(name: &str) -> Result<(), AppError> {
31    if name.is_empty() || name.chars().all(char::is_whitespace) {
32        return Err(AppError::InvalidIdentifier(name.to_string()));
33    }
34    if name.chars().any(char::is_control) {
35        return Err(AppError::InvalidIdentifier(name.to_string()));
36    }
37    Ok(())
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn accepts_standard_names() {
46        assert!(validate_identifier("users").is_ok());
47        assert!(validate_identifier("my_table").is_ok());
48        assert!(validate_identifier("DB_123").is_ok());
49    }
50
51    #[test]
52    fn accepts_hyphenated_names() {
53        assert!(validate_identifier("eu-docker").is_ok());
54        assert!(validate_identifier("access-logs").is_ok());
55    }
56
57    #[test]
58    fn accepts_special_chars() {
59        assert!(validate_identifier("my.db").is_ok());
60        assert!(validate_identifier("123db").is_ok());
61        assert!(validate_identifier("café").is_ok());
62        assert!(validate_identifier("a b").is_ok());
63    }
64
65    #[test]
66    fn rejects_empty() {
67        assert!(validate_identifier("").is_err());
68    }
69
70    #[test]
71    fn rejects_whitespace_only() {
72        assert!(validate_identifier("   ").is_err());
73        assert!(validate_identifier("\t").is_err());
74    }
75
76    #[test]
77    fn rejects_control_chars() {
78        assert!(validate_identifier("test\x00db").is_err());
79        assert!(validate_identifier("test\ndb").is_err());
80        assert!(validate_identifier("test\x1Fdb").is_err());
81    }
82
83    #[test]
84    fn quote_with_double_quotes() {
85        assert_eq!(quote_identifier("users", '"'), "\"users\"");
86        assert_eq!(quote_identifier("eu-docker", '"'), "\"eu-docker\"");
87        assert_eq!(quote_identifier("test\"db", '"'), "\"test\"\"db\"");
88    }
89
90    #[test]
91    fn quote_with_backticks() {
92        assert_eq!(quote_identifier("users", '`'), "`users`");
93        assert_eq!(quote_identifier("test`db", '`'), "`test``db`");
94    }
95
96    #[test]
97    fn quote_string_normal() {
98        assert_eq!(quote_string("my_db"), "'my_db'");
99    }
100
101    #[test]
102    fn quote_string_empty() {
103        assert_eq!(quote_string(""), "''");
104    }
105
106    #[test]
107    fn quote_string_with_single_quotes() {
108        assert_eq!(quote_string("it's"), "'it''s'");
109        assert_eq!(quote_string("a'b'c"), "'a''b''c'");
110    }
111}