Skip to main content

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",
115        "and",
116        "any",
117        "array",
118        "as",
119        "asc",
120        "between",
121        "by",
122        "case",
123        "cast",
124        "check",
125        "column",
126        "constraint",
127        "create",
128        "cross",
129        "current",
130        "default",
131        "delete",
132        "desc",
133        "distinct",
134        "drop",
135        "else",
136        "end",
137        "exists",
138        "false",
139        "for",
140        "foreign",
141        "from",
142        "full",
143        "group",
144        "having",
145        "in",
146        "index",
147        "inner",
148        "insert",
149        "into",
150        "is",
151        "join",
152        "key",
153        "left",
154        "like",
155        "limit",
156        "not",
157        "null",
158        "offset",
159        "on",
160        "or",
161        "order",
162        "outer",
163        "primary",
164        "references",
165        "right",
166        "select",
167        "set",
168        "table",
169        "then",
170        "to",
171        "true",
172        "union",
173        "unique",
174        "update",
175        "using",
176        "values",
177        "when",
178        "where",
179        "with",
180    ];
181
182    KEYWORDS.contains(&s.to_lowercase().as_str())
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_escape_ident() {
191        assert_eq!(escape_ident("users"), "\"users\"");
192        assert_eq!(escape_ident("user_table"), "\"user_table\"");
193        assert_eq!(escape_ident("user\"name"), "\"user\"\"name\"");
194        assert_eq!(escape_ident("My Table"), "\"My Table\"");
195        assert_eq!(escape_ident(""), "\"\"");
196    }
197
198    #[test]
199    fn test_quote_literal() {
200        assert_eq!(quote_literal("hello"), "'hello'");
201        assert_eq!(quote_literal("it's"), "'it''s'");
202        assert_eq!(quote_literal(""), "''");
203    }
204
205    #[test]
206    fn test_from_qi() {
207        let qi = QualifiedIdentifier::new("public", "users");
208        assert_eq!(from_qi(&qi), "\"public\".\"users\"");
209
210        let qi = QualifiedIdentifier::unqualified("users");
211        assert_eq!(from_qi(&qi), "\"users\"");
212
213        let qi = QualifiedIdentifier::new("my schema", "my\"table");
214        assert_eq!(from_qi(&qi), "\"my schema\".\"my\"\"table\"");
215    }
216
217    #[test]
218    fn test_is_valid_identifier() {
219        assert!(is_valid_identifier("users"));
220        assert!(is_valid_identifier("_private"));
221        assert!(is_valid_identifier("user123"));
222        assert!(is_valid_identifier("user$table"));
223
224        assert!(!is_valid_identifier(""));
225        assert!(!is_valid_identifier("123users"));
226        assert!(!is_valid_identifier("my-table"));
227        assert!(!is_valid_identifier("my table"));
228    }
229
230    #[test]
231    fn test_is_keyword() {
232        assert!(is_keyword("select"));
233        assert!(is_keyword("SELECT"));
234        assert!(is_keyword("from"));
235        assert!(is_keyword("WHERE"));
236
237        assert!(!is_keyword("users"));
238        assert!(!is_keyword("my_column"));
239    }
240}