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    /// The schema. Empty when the identifier is unqualified, in which case
52    /// resolution is left to the connection's `search_path`.
53    pub schema: String,
54    /// The object's own name, unqualified and unquoted.
55    pub name: String,
56}
57
58impl QualifiedIdentifier {
59    /// A `schema.name` identifier.
60    pub fn new(schema: impl Into<String>, name: impl Into<String>) -> Self {
61        Self {
62            schema: schema.into(),
63            name: name.into(),
64        }
65    }
66
67    /// A bare `name`, to be resolved against the connection's `search_path`.
68    ///
69    /// Prefer [`new`](Self::new) wherever the schema is known: which object a
70    /// bare name reaches depends on session state rather than on the query.
71    pub fn unqualified(name: impl Into<String>) -> Self {
72        Self {
73            schema: String::new(),
74            name: name.into(),
75        }
76    }
77}
78
79/// Convert a qualified identifier to a safe SQL string.
80///
81/// # Examples
82///
83/// ```
84/// use postrust_sql::{from_qi, identifier::QualifiedIdentifier};
85///
86/// let qi = QualifiedIdentifier::new("public", "users");
87/// assert_eq!(from_qi(&qi), "\"public\".\"users\"");
88///
89/// let qi = QualifiedIdentifier::unqualified("users");
90/// assert_eq!(from_qi(&qi), "\"users\"");
91/// ```
92pub fn from_qi(qi: &QualifiedIdentifier) -> String {
93    if qi.schema.is_empty() {
94        escape_ident(&qi.name)
95    } else {
96        format!("{}.{}", escape_ident(&qi.schema), escape_ident(&qi.name))
97    }
98}
99
100/// Check if a string is a valid unquoted identifier.
101///
102/// PostgreSQL unquoted identifiers must start with a letter or underscore,
103/// and can contain letters, digits, underscores, and dollar signs.
104pub fn is_valid_identifier(s: &str) -> bool {
105    if s.is_empty() {
106        return false;
107    }
108
109    let mut chars = s.chars();
110    let first = chars.next().unwrap();
111
112    if !first.is_ascii_alphabetic() && first != '_' {
113        return false;
114    }
115
116    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
117}
118
119/// Check if a string is a SQL keyword that should be quoted.
120pub fn is_keyword(s: &str) -> bool {
121    const KEYWORDS: &[&str] = &[
122        "all",
123        "and",
124        "any",
125        "array",
126        "as",
127        "asc",
128        "between",
129        "by",
130        "case",
131        "cast",
132        "check",
133        "column",
134        "constraint",
135        "create",
136        "cross",
137        "current",
138        "default",
139        "delete",
140        "desc",
141        "distinct",
142        "drop",
143        "else",
144        "end",
145        "exists",
146        "false",
147        "for",
148        "foreign",
149        "from",
150        "full",
151        "group",
152        "having",
153        "in",
154        "index",
155        "inner",
156        "insert",
157        "into",
158        "is",
159        "join",
160        "key",
161        "left",
162        "like",
163        "limit",
164        "not",
165        "null",
166        "offset",
167        "on",
168        "or",
169        "order",
170        "outer",
171        "primary",
172        "references",
173        "right",
174        "select",
175        "set",
176        "table",
177        "then",
178        "to",
179        "true",
180        "union",
181        "unique",
182        "update",
183        "using",
184        "values",
185        "when",
186        "where",
187        "with",
188    ];
189
190    KEYWORDS.contains(&s.to_lowercase().as_str())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_escape_ident() {
199        assert_eq!(escape_ident("users"), "\"users\"");
200        assert_eq!(escape_ident("user_table"), "\"user_table\"");
201        assert_eq!(escape_ident("user\"name"), "\"user\"\"name\"");
202        assert_eq!(escape_ident("My Table"), "\"My Table\"");
203        assert_eq!(escape_ident(""), "\"\"");
204    }
205
206    #[test]
207    fn test_quote_literal() {
208        assert_eq!(quote_literal("hello"), "'hello'");
209        assert_eq!(quote_literal("it's"), "'it''s'");
210        assert_eq!(quote_literal(""), "''");
211    }
212
213    #[test]
214    fn test_from_qi() {
215        let qi = QualifiedIdentifier::new("public", "users");
216        assert_eq!(from_qi(&qi), "\"public\".\"users\"");
217
218        let qi = QualifiedIdentifier::unqualified("users");
219        assert_eq!(from_qi(&qi), "\"users\"");
220
221        let qi = QualifiedIdentifier::new("my schema", "my\"table");
222        assert_eq!(from_qi(&qi), "\"my schema\".\"my\"\"table\"");
223    }
224
225    #[test]
226    fn test_is_valid_identifier() {
227        assert!(is_valid_identifier("users"));
228        assert!(is_valid_identifier("_private"));
229        assert!(is_valid_identifier("user123"));
230        assert!(is_valid_identifier("user$table"));
231
232        assert!(!is_valid_identifier(""));
233        assert!(!is_valid_identifier("123users"));
234        assert!(!is_valid_identifier("my-table"));
235        assert!(!is_valid_identifier("my table"));
236    }
237
238    #[test]
239    fn test_is_keyword() {
240        assert!(is_keyword("select"));
241        assert!(is_keyword("SELECT"));
242        assert!(is_keyword("from"));
243        assert!(is_keyword("WHERE"));
244
245        assert!(!is_keyword("users"));
246        assert!(!is_keyword("my_column"));
247    }
248}