postrust_sql/
identifier.rs

1//! Safe SQL identifier handling.
2//!
3//! Provides functions for safely escaping and quoting SQL identifiers
4//! and literals to prevent SQL injection.
5
6/// Escape a SQL identifier (table name, column name, etc.).
7///
8/// This function wraps the identifier in double quotes and escapes
9/// any embedded double quotes by doubling them.
10///
11/// # Examples
12///
13/// ```
14/// use postrust_sql::escape_ident;
15///
16/// assert_eq!(escape_ident("users"), "\"users\"");
17/// assert_eq!(escape_ident("user\"name"), "\"user\"\"name\"");
18/// assert_eq!(escape_ident("My Table"), "\"My Table\"");
19/// ```
20pub fn escape_ident(name: &str) -> String {
21    format!("\"{}\"", name.replace('"', "\"\""))
22}
23
24/// Quote a SQL literal string.
25///
26/// This function wraps the string in single quotes and escapes
27/// any embedded single quotes by doubling them. This is only for
28/// cases where parameterized queries can't be used (e.g., SET commands).
29///
30/// # Warning
31///
32/// Prefer using parameterized queries with `SqlParam` instead of this function
33/// whenever possible. This function should only be used for SQL constructs
34/// that don't support parameters (like some SET commands).
35///
36/// # Examples
37///
38/// ```
39/// use postrust_sql::quote_literal;
40///
41/// assert_eq!(quote_literal("hello"), "'hello'");
42/// assert_eq!(quote_literal("it's"), "'it''s'");
43/// ```
44pub fn quote_literal(s: &str) -> String {
45    format!("'{}'", s.replace('\'', "''"))
46}
47
48/// Qualified identifier (schema.name).
49#[derive(Clone, Debug, PartialEq, Eq, Hash)]
50pub struct QualifiedIdentifier {
51    pub schema: String,
52    pub name: String,
53}
54
55impl QualifiedIdentifier {
56    pub fn new(schema: impl Into<String>, name: impl Into<String>) -> Self {
57        Self {
58            schema: schema.into(),
59            name: name.into(),
60        }
61    }
62
63    pub fn unqualified(name: impl Into<String>) -> Self {
64        Self {
65            schema: String::new(),
66            name: name.into(),
67        }
68    }
69}
70
71/// Convert a qualified identifier to a safe SQL string.
72///
73/// # Examples
74///
75/// ```
76/// use postrust_sql::{from_qi, identifier::QualifiedIdentifier};
77///
78/// let qi = QualifiedIdentifier::new("public", "users");
79/// assert_eq!(from_qi(&qi), "\"public\".\"users\"");
80///
81/// let qi = QualifiedIdentifier::unqualified("users");
82/// assert_eq!(from_qi(&qi), "\"users\"");
83/// ```
84pub fn from_qi(qi: &QualifiedIdentifier) -> String {
85    if qi.schema.is_empty() {
86        escape_ident(&qi.name)
87    } else {
88        format!("{}.{}", escape_ident(&qi.schema), escape_ident(&qi.name))
89    }
90}
91
92/// Check if a string is a valid unquoted identifier.
93///
94/// PostgreSQL unquoted identifiers must start with a letter or underscore,
95/// and can contain letters, digits, underscores, and dollar signs.
96pub fn is_valid_identifier(s: &str) -> bool {
97    if s.is_empty() {
98        return false;
99    }
100
101    let mut chars = s.chars();
102    let first = chars.next().unwrap();
103
104    if !first.is_ascii_alphabetic() && first != '_' {
105        return false;
106    }
107
108    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
109}
110
111/// Check if a string is a SQL keyword that should be quoted.
112pub fn is_keyword(s: &str) -> bool {
113    const KEYWORDS: &[&str] = &[
114        "all", "and", "any", "array", "as", "asc", "between", "by",
115        "case", "cast", "check", "column", "constraint", "create",
116        "cross", "current", "default", "delete", "desc", "distinct",
117        "drop", "else", "end", "exists", "false", "for", "foreign",
118        "from", "full", "group", "having", "in", "index", "inner",
119        "insert", "into", "is", "join", "key", "left", "like", "limit",
120        "not", "null", "offset", "on", "or", "order", "outer", "primary",
121        "references", "right", "select", "set", "table", "then", "to",
122        "true", "union", "unique", "update", "using", "values", "when",
123        "where", "with",
124    ];
125
126    KEYWORDS.contains(&s.to_lowercase().as_str())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_escape_ident() {
135        assert_eq!(escape_ident("users"), "\"users\"");
136        assert_eq!(escape_ident("user_table"), "\"user_table\"");
137        assert_eq!(escape_ident("user\"name"), "\"user\"\"name\"");
138        assert_eq!(escape_ident("My Table"), "\"My Table\"");
139        assert_eq!(escape_ident(""), "\"\"");
140    }
141
142    #[test]
143    fn test_quote_literal() {
144        assert_eq!(quote_literal("hello"), "'hello'");
145        assert_eq!(quote_literal("it's"), "'it''s'");
146        assert_eq!(quote_literal(""), "''");
147    }
148
149    #[test]
150    fn test_from_qi() {
151        let qi = QualifiedIdentifier::new("public", "users");
152        assert_eq!(from_qi(&qi), "\"public\".\"users\"");
153
154        let qi = QualifiedIdentifier::unqualified("users");
155        assert_eq!(from_qi(&qi), "\"users\"");
156
157        let qi = QualifiedIdentifier::new("my schema", "my\"table");
158        assert_eq!(from_qi(&qi), "\"my schema\".\"my\"\"table\"");
159    }
160
161    #[test]
162    fn test_is_valid_identifier() {
163        assert!(is_valid_identifier("users"));
164        assert!(is_valid_identifier("_private"));
165        assert!(is_valid_identifier("user123"));
166        assert!(is_valid_identifier("user$table"));
167
168        assert!(!is_valid_identifier(""));
169        assert!(!is_valid_identifier("123users"));
170        assert!(!is_valid_identifier("my-table"));
171        assert!(!is_valid_identifier("my table"));
172    }
173
174    #[test]
175    fn test_is_keyword() {
176        assert!(is_keyword("select"));
177        assert!(is_keyword("SELECT"));
178        assert!(is_keyword("from"));
179        assert!(is_keyword("WHERE"));
180
181        assert!(!is_keyword("users"));
182        assert!(!is_keyword("my_column"));
183    }
184}