1use crate::error::SqlError;
10
11pub const RESERVED_KEYWORDS: &[&str] = &[
13 "GRAPH", "MATCH", "OPTIONAL", "UPSERT", "UNDROP", "PURGE", "CASCADE", "SEARCH", "CRDT",
14];
15
16fn reason_for(upper: &str) -> &'static str {
17 match upper {
18 "GRAPH" | "MATCH" | "OPTIONAL" => "graph dispatch keyword",
19 "UPSERT" => "preprocess rewrite keyword",
20 "UNDROP" => "DDL dispatch keyword",
21 "PURGE" | "CASCADE" => "DROP modifier keyword",
22 "SEARCH" => "DSL dispatch keyword",
23 "CRDT" => "DSL dispatch keyword",
24 _ => "reserved by NodeDB",
25 }
26}
27
28pub fn is_reserved(name: &str) -> bool {
31 let upper = name.to_uppercase();
32 RESERVED_KEYWORDS.contains(&upper.as_str())
33}
34
35fn invalid(name: &str, reason: &'static str) -> SqlError {
36 SqlError::InvalidIdentifier {
37 name: name.to_string(),
38 reason,
39 }
40}
41
42fn validate_quoted_content(value: &str) -> Result<(), SqlError> {
43 if value.is_empty() {
44 return Err(invalid(value, "quoted identifier must not be empty"));
45 }
46 if value.chars().any(char::is_control) {
47 return Err(invalid(
48 value,
49 "identifier must not contain control characters",
50 ));
51 }
52 if value.contains('"') {
53 return Err(invalid(value, "identifier must not contain double quotes"));
54 }
55 Ok(())
56}
57
58fn validate_bare_content(value: &str) -> Result<(), SqlError> {
59 if value.is_empty() {
60 return Err(invalid(value, "identifier must not be empty"));
61 }
62 if value.chars().any(char::is_control) {
63 return Err(invalid(
64 value,
65 "identifier must not contain control characters",
66 ));
67 }
68 let mut chars = value.chars();
69 let first = chars
70 .next()
71 .ok_or_else(|| invalid(value, "identifier must not be empty"))?;
72 if first != '_' && !first.is_alphabetic() {
73 return Err(invalid(
74 value,
75 "bare identifier must start with a letter or underscore",
76 ));
77 }
78 if !chars.all(|ch| ch == '_' || ch == '$' || ch.is_alphanumeric()) {
79 return Err(invalid(
80 value,
81 "bare identifier may contain only letters, digits, underscores, or dollar signs",
82 ));
83 }
84 Ok(())
85}
86
87fn normalize_bare(value: &str) -> Result<String, SqlError> {
88 validate_bare_content(value)?;
89 let normalized = value.to_lowercase();
90 let upper = normalized.to_uppercase();
91 if RESERVED_KEYWORDS.contains(&upper.as_str()) {
92 return Err(SqlError::ReservedIdentifier {
93 name: value.to_string(),
94 reason: reason_for(&upper),
95 });
96 }
97 Ok(normalized)
98}
99
100pub fn check_identifier(raw_name: &str) -> Result<String, SqlError> {
107 if raw_name.starts_with('"') {
108 return decode_raw_quoted_identifier(raw_name);
109 }
110 normalize_bare(raw_name)
111}
112
113pub fn check_identifier_or_wildcard(raw_name: &str) -> Result<String, SqlError> {
119 if raw_name == "*" {
120 return Ok("*".to_string());
121 }
122 check_identifier(raw_name)
123}
124
125fn decode_raw_quoted_identifier(raw_name: &str) -> Result<String, SqlError> {
126 let mut rest = raw_name
127 .strip_prefix('"')
128 .ok_or_else(|| invalid(raw_name, "quoted identifier must start with a double quote"))?;
129 let mut decoded = String::new();
130 loop {
131 let Some(ch) = rest.chars().next() else {
132 return Err(invalid(raw_name, "unterminated quoted identifier"));
133 };
134 rest = &rest[ch.len_utf8()..];
135 if ch == '"' {
136 if let Some(after_escape) = rest.strip_prefix('"') {
137 decoded.push('"');
138 rest = after_escape;
139 continue;
140 }
141 if !rest.is_empty() {
142 return Err(invalid(
143 raw_name,
144 "unexpected content after quoted identifier",
145 ));
146 }
147 validate_quoted_content(&decoded)?;
148 return Ok(decoded);
149 }
150 decoded.push(ch);
151 }
152}
153
154pub fn check_ast_identifier(ident: &sqlparser::ast::Ident) -> Result<String, SqlError> {
160 if ident.quote_style.is_some() {
161 validate_quoted_content(&ident.value)?;
162 Ok(ident.value.clone())
163 } else {
164 normalize_bare(&ident.value)
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use sqlparser::ast::Ident;
172
173 #[test]
174 fn bare_identifiers_normalize_and_reserved_words_reject() {
175 assert_eq!(
176 check_identifier("MiXeD_1$Name").expect("bare"),
177 "mixed_1$name"
178 );
179 assert!(matches!(
180 check_identifier("match"),
181 Err(SqlError::ReservedIdentifier { .. })
182 ));
183 assert_eq!(
184 check_identifier("\"MATCH\"").expect("quoted reserved"),
185 "MATCH"
186 );
187 }
188
189 #[test]
190 fn quoted_identifiers_preserve_case_but_reject_unsafe_content() {
191 assert_eq!(
192 check_identifier("\"MiXeD Unicode 雪\"").expect("quoted"),
193 "MiXeD Unicode 雪"
194 );
195 for raw in [
196 "\"\"",
197 "\"a\"\"b\"",
198 "\"a\n\"",
199 "\"unterminated",
200 "\"a\"tail",
201 ] {
202 assert!(
203 matches!(
204 check_identifier(raw),
205 Err(SqlError::InvalidIdentifier { .. })
206 ),
207 "{raw}"
208 );
209 }
210 }
211
212 #[test]
213 fn bare_identifiers_reject_noncanonical_or_control_content() {
214 for raw in ["", "1name", "na-me", "name;drop", "name\n", "name\""] {
215 assert!(
216 matches!(
217 check_identifier(raw),
218 Err(SqlError::InvalidIdentifier { .. })
219 ),
220 "{raw}"
221 );
222 }
223 assert_eq!(check_identifier("雪表").expect("Unicode bare"), "雪表");
224 }
225
226 #[test]
227 fn ast_identifiers_apply_decoded_content_rules() {
228 assert_eq!(
229 check_ast_identifier(&Ident::new("MiXeD")).expect("bare AST"),
230 "mixed"
231 );
232 let mut quoted = Ident::new("MATCH");
233 quoted.quote_style = Some('"');
234 assert_eq!(
235 check_ast_identifier("ed).expect("quoted reserved AST"),
236 "MATCH"
237 );
238
239 for value in ["", "a\"b", "a\n"] {
240 let mut ident = Ident::new(value);
241 ident.quote_style = Some('"');
242 assert!(matches!(
243 check_ast_identifier(&ident),
244 Err(SqlError::InvalidIdentifier { .. })
245 ));
246 }
247 }
248}