Skip to main content

elefant_tools/
quoting.rs

1use std::collections::HashMap;
2
3/// Provides utilities for quoting identifiers in PostgreSQL as needed.
4#[derive(Debug)]
5pub struct IdentifierQuoter {
6    /// Keywords that might need to be escaped, and whether they are allowed to be used as column names or type/function names.
7    keywords: HashMap<String, AllowedKeywordUsage>,
8}
9
10/// How a keyword is allowed to be used.
11#[derive(Debug, Copy, Clone)]
12pub struct AllowedKeywordUsage {
13    pub column_name: bool,
14    pub type_or_function_name: bool,
15}
16
17/// How an identifier is attempted to be used.
18#[derive(Debug, Copy, Clone, Eq, PartialEq)]
19pub enum AttemptedKeywordUsage {
20    ColumnName,
21    TypeOrFunctionName,
22    Other,
23}
24
25impl IdentifierQuoter {
26    /// Creates a new IdentifierQuoter with the specified keywords and their allowed usages.
27    pub fn new(keywords: HashMap<String, AllowedKeywordUsage>) -> Self {
28        Self { keywords }
29    }
30
31    /// Creates a new IdentifierQuoter with no keywords.
32    ///
33    /// This is mainly useful for testing as it doesn't require connecting to Postgres.
34    pub fn empty() -> Self {
35        Self {
36            keywords: HashMap::new(),
37        }
38    }
39
40    /// Quotes an identifier as needed.
41    ///
42    /// Ported from <https://github.com/postgres/postgres/blob/97957fdbaa429c7c582d4753b108cb1e23e1b28a/src/backend/utils/adt/ruleutils.c#L11975>
43    pub fn quote(&self, identifier: impl AsRef<str>, usage: AttemptedKeywordUsage) -> String {
44        let identifier = identifier.as_ref();
45
46        if identifier.is_empty() {
47            return "\"\"".to_string();
48        }
49
50        let mut chars = identifier.chars();
51
52        let safe = if let Some(allowed) = self.keywords.get(identifier) {
53            match usage {
54                AttemptedKeywordUsage::ColumnName => allowed.column_name,
55                AttemptedKeywordUsage::TypeOrFunctionName => allowed.type_or_function_name,
56                AttemptedKeywordUsage::Other => false,
57            }
58        } else {
59            matches!(chars.next(), Some('a'..='z' | '_'))
60                && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_'))
61        };
62
63        if safe {
64            identifier.to_string()
65        } else {
66            let escaped = identifier.replace('"', r#""""#);
67
68            format!("\"{escaped}\"")
69        }
70    }
71
72    /// Quotes multiple identifiers as needed.
73    pub fn quote_iter<'a, 's, S: AsRef<str>, I: IntoIterator<Item = S>>(
74        &'a self,
75        identifiers: I,
76        usage: AttemptedKeywordUsage,
77    ) -> impl Iterator<Item = String> + 'a
78    where
79        <I as IntoIterator>::IntoIter: 'a,
80    {
81        identifiers.into_iter().map(move |i| self.quote(i, usage))
82    }
83}
84
85/// A trait for types that can be quoted.
86pub(crate) trait Quotable {
87    /// Quotes the value as needed.
88    fn quote(&self, quoter: &IdentifierQuoter, usage: AttemptedKeywordUsage) -> String;
89}
90
91impl<S> Quotable for S
92where
93    S: AsRef<str>,
94{
95    fn quote(&self, quoter: &IdentifierQuoter, usage: AttemptedKeywordUsage) -> String {
96        quoter.quote(self, usage)
97    }
98}
99
100/// A trait for types that can be quoted as an iterator.
101pub(crate) trait QuotableIter: Sized {
102    fn quote(
103        self,
104        quoter: &IdentifierQuoter,
105        usage: AttemptedKeywordUsage,
106    ) -> IteratorQuoter<'_, Self>;
107}
108
109impl<I> QuotableIter for I
110where
111    I: Iterator,
112    I::Item: AsRef<str>,
113{
114    fn quote(
115        self,
116        quoter: &IdentifierQuoter,
117        usage: AttemptedKeywordUsage,
118    ) -> IteratorQuoter<'_, Self> {
119        IteratorQuoter {
120            quoter,
121            usage,
122            iter: self,
123        }
124    }
125}
126
127/// The iterator implementation used then quoting an iterator of values
128pub(crate) struct IteratorQuoter<'q, I> {
129    quoter: &'q IdentifierQuoter,
130    usage: AttemptedKeywordUsage,
131    iter: I,
132}
133
134impl<I> Iterator for IteratorQuoter<'_, I>
135where
136    I: Iterator,
137    I::Item: AsRef<str>,
138{
139    type Item = String;
140
141    fn next(&mut self) -> Option<Self::Item> {
142        self.iter.next().map(|i| self.quoter.quote(i, self.usage))
143    }
144}
145
146/// Quotes a a string value for usage in Postgres.
147pub(crate) fn quote_value_string(s: &str) -> String {
148    format!("'{}'", s.replace('\'', "''"))
149}
150
151#[cfg(test)]
152mod tests {
153    use crate::quoting::{AllowedKeywordUsage, AttemptedKeywordUsage};
154    use std::collections::HashMap;
155
156    #[test]
157    fn quoting() {
158        let quoter = super::IdentifierQuoter::new(HashMap::from([(
159            "table".to_string(),
160            AllowedKeywordUsage {
161                type_or_function_name: false,
162                column_name: false,
163            },
164        )]));
165
166        macro_rules! test_quote {
167            ($identifier:literal, $expected:literal) => {
168                let quoted = quoter.quote($identifier, AttemptedKeywordUsage::Other);
169                assert_eq!(quoted, $expected);
170            };
171        }
172
173        test_quote!("table", "\"table\"");
174        test_quote!("table1", "table1");
175        test_quote!("table_1", "table_1");
176        test_quote!("table-1", "\"table-1\"");
177        test_quote!("table 1", "\"table 1\"");
178        test_quote!("1table", "\"1table\"");
179        test_quote!("my_table", "my_table");
180        test_quote!("MyTable", "\"MyTable\"");
181        test_quote!("my\"table", "\"my\"\"table\"");
182        test_quote!("", "\"\"");
183    }
184
185    #[test]
186    fn quotes_keywords_based_on_usage() {
187        // `between` is a `C` keyword: usable unquoted as a column name, but not as a
188        // type/function name. `left` is a `T` keyword: the opposite.
189        let quoter = super::IdentifierQuoter::new(HashMap::from([
190            (
191                "between".to_string(),
192                AllowedKeywordUsage {
193                    column_name: true,
194                    type_or_function_name: false,
195                },
196            ),
197            (
198                "left".to_string(),
199                AllowedKeywordUsage {
200                    column_name: false,
201                    type_or_function_name: true,
202                },
203            ),
204        ]));
205
206        assert_eq!(
207            quoter.quote("between", AttemptedKeywordUsage::ColumnName),
208            "between"
209        );
210        assert_eq!(
211            quoter.quote("between", AttemptedKeywordUsage::TypeOrFunctionName),
212            "\"between\""
213        );
214
215        assert_eq!(
216            quoter.quote("left", AttemptedKeywordUsage::ColumnName),
217            "\"left\""
218        );
219        assert_eq!(
220            quoter.quote("left", AttemptedKeywordUsage::TypeOrFunctionName),
221            "left"
222        );
223
224        // Reserved-everywhere keywords are always quoted.
225        assert_eq!(
226            quoter.quote("left", AttemptedKeywordUsage::Other),
227            "\"left\""
228        );
229    }
230}