1use crate::SyntaxNode;
2use crate::generated::keywords::{AS_LABEL_KEYWORDS, RESERVED_KEYWORDS, TYPE_FUNC_NAME_KEYWORDS};
3
4pub fn quote_string_literal(text: &str) -> String {
5 format!("'{}'", text.replace('\'', "''"))
6}
7
8fn quote(text: &str) -> String {
9 format!(r#""{}""#, text.replace('"', r#""""#))
10}
11
12pub fn quote_column_alias(text: &str) -> String {
13 if needs_quoting(text) {
14 quote(text)
15 } else {
16 text.to_string()
17 }
18}
19
20pub fn quote_bare_column_alias(text: &str) -> String {
21 if needs_quoting(text) || is_as_label_word(text) {
22 quote(text)
23 } else {
24 text.to_string()
25 }
26}
27
28pub fn quote_ident(text: &str) -> String {
29 if needs_quoting(text) || is_reserved_word(text) || is_type_func_name_word(text) {
30 quote(text)
31 } else {
32 text.to_string()
33 }
34}
35
36pub fn unquote_ident(node: &SyntaxNode) -> Option<String> {
37 let text = node.text().to_string();
38
39 if !text.starts_with('"') || !text.ends_with('"') {
40 return None;
41 }
42
43 let text = &text[1..text.len() - 1];
44
45 if is_reserved_word(text) || is_type_func_name_word(text) {
46 return None;
47 }
48
49 if text.is_empty() {
50 return None;
51 }
52
53 let mut chars = text.chars();
54
55 match chars.next() {
57 Some(c) if c.is_lowercase() || c == '_' => {}
58 _ => return None,
59 }
60
61 for c in chars {
62 if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
63 continue;
64 }
65 return None;
66 }
67
68 Some(text.to_string())
69}
70
71pub fn needs_quoting(text: &str) -> bool {
72 if text.is_empty() {
73 return true;
74 }
75
76 let mut chars = text.chars();
81
82 match chars.next() {
83 Some(c) if c.is_lowercase() || c == '_' => {}
84 _ => return true,
85 }
86
87 for c in chars {
88 if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
89 continue;
90 }
91 return true;
92 }
93
94 false
95}
96
97pub fn is_reserved_word(text: &str) -> bool {
98 RESERVED_KEYWORDS
99 .binary_search(&text.to_ascii_lowercase().as_str())
100 .is_ok()
101}
102
103fn is_type_func_name_word(text: &str) -> bool {
104 TYPE_FUNC_NAME_KEYWORDS
105 .binary_search(&text.to_ascii_lowercase().as_str())
106 .is_ok()
107}
108
109fn is_as_label_word(text: &str) -> bool {
110 AS_LABEL_KEYWORDS
111 .binary_search(&text.to_ascii_lowercase().as_str())
112 .is_ok()
113}
114
115pub fn strip_quotes(text: &str) -> Option<&str> {
116 text.strip_prefix('\'')?.strip_suffix('\'')
117}
118
119pub fn strip_prefixed_quotes(text: &str, prefix: [char; 2]) -> Option<&str> {
120 strip_quotes(text.strip_prefix(prefix)?)
121}
122
123pub fn strip_unicode_esc_prefix(text: &str) -> Option<&str> {
124 strip_quotes(text.strip_prefix(['u', 'U'])?.strip_prefix('&')?)
125}
126
127pub fn dollar_quote_tag(text: &str) -> Option<&str> {
128 text.strip_prefix('$')?.split_once('$').map(|(tag, _)| tag)
129}
130
131pub fn strip_dollar_quotes(text: &str) -> Option<&str> {
132 let tag = dollar_quote_tag(text)?;
133 let body = &text[tag.len() + 2..];
134 let closing = format!("${tag}$");
135 body.strip_suffix(&closing)
136}
137
138#[cfg(test)]
139mod tests {
140 use insta::assert_snapshot;
141
142 use super::*;
143
144 #[test]
145 fn quote_string_literal_escapes_apostrophes() {
146 assert_snapshot!(quote_string_literal("it's"), @"'it''s'");
147 }
148
149 #[test]
150 fn quote_column_alias_handles_embedded_quotes() {
151 assert_snapshot!(quote_column_alias(r#"foo"bar"#), @r#""foo""bar""#);
152 }
153
154 #[test]
155 fn quote_column_alias_doesnt_quote_reserved_words() {
156 assert_snapshot!(quote_column_alias("case"), @"case");
158 assert_snapshot!(quote_column_alias("array"), @"array");
159 }
160
161 #[test]
162 fn quote_column_alias_doesnt_quote_simple_identifiers() {
163 assert_snapshot!(quote_column_alias("col_name"), @"col_name");
164 }
165
166 #[test]
167 fn quote_column_alias_handles_special_column_name() {
168 assert_snapshot!(quote_column_alias("?column?"), @r#""?column?""#);
169 }
170
171 #[test]
172 fn quote_bare_column_alias_quotes_keywords_that_need_an_as() {
173 assert_snapshot!(quote_bare_column_alias("filter"), @r#""filter""#);
174 assert_snapshot!(quote_bare_column_alias("day"), @r#""day""#);
175 assert_snapshot!(quote_bare_column_alias("array"), @r#""array""#);
177 }
178
179 #[test]
180 fn quote_bare_column_alias_doesnt_quote_bare_label_keywords() {
181 assert_snapshot!(quote_bare_column_alias("between"), @"between");
182 assert_snapshot!(quote_bare_column_alias("all"), @"all");
183 assert_snapshot!(quote_bare_column_alias("left"), @"left");
184 assert_snapshot!(quote_bare_column_alias("col_name"), @"col_name");
185 }
186
187 #[test]
188 fn quote_ident_doesnt_quote_simple_identifiers() {
189 assert_snapshot!(quote_ident("col_name"), @"col_name");
190 assert_snapshot!(quote_ident("users"), @"users");
191 assert_snapshot!(quote_ident("t2$"), @"t2$");
192 }
193
194 #[test]
195 fn quote_ident_doesnt_quote_column_or_table_keywords() {
196 assert_snapshot!(quote_ident("data"), @"data");
198 assert_snapshot!(quote_ident("value"), @"value");
199 assert_snapshot!(quote_ident("int"), @"int");
201 }
202
203 #[test]
204 fn quote_ident_quotes_reserved_words() {
205 assert_snapshot!(quote_ident("select"), @r#""select""#);
206 assert_snapshot!(quote_ident("array"), @r#""array""#);
207 }
208
209 #[test]
210 fn quote_ident_quotes_type_func_name_words() {
211 assert_snapshot!(quote_ident("left"), @r#""left""#);
212 assert_snapshot!(quote_ident("join"), @r#""join""#);
213 }
214
215 #[test]
216 fn quote_ident_quotes_names_that_dont_fold_to_themselves() {
217 assert_snapshot!(quote_ident("Mixed"), @r#""Mixed""#);
218 assert_snapshot!(quote_ident("has space"), @r#""has space""#);
219 assert_snapshot!(quote_ident(""), @r#""""#);
220 assert_snapshot!(quote_ident(r#"foo"bar"#), @r#""foo""bar""#);
221 }
222}