Skip to main content

nodedb_sql/
reserved.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! NodeDB reserved identifier list and canonical identifier validation.
4//!
5//! The validator is the single boundary for identifier content that survives
6//! into planning. Bare identifiers are canonicalized to lowercase; quoted
7//! identifiers preserve case and may opt into NodeDB-reserved words.
8
9use crate::error::SqlError;
10
11/// Words that NodeDB claims as dispatch or rewrite keywords.
12pub 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
28/// Return `true` if `name` matches a NodeDB reserved keyword
29/// (case-insensitive).
30pub 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
100/// Validate a raw identifier token extracted from SQL.
101///
102/// Quoted tokens must use SQL doubled-quote escaping. Their decoded content
103/// preserves case and bypasses the reserved-word restriction, but all control
104/// characters and embedded quotes are rejected. Bare tokens must use NodeDB's
105/// canonical ASCII identifier grammar.
106pub 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
113/// Validate a raw token that names either an identifier or the `*` wildcard.
114///
115/// Clauses that accept "every column" or "every collection" carry `*` in the
116/// slot an identifier otherwise fills. The wildcard is recognized first, so the
117/// identifier grammar never sees it.
118pub 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
154/// Validate an identifier already decoded by `sqlparser`.
155///
156/// `sqlparser::ast::Ident::value` has SQL quote escaping decoded, so no raw
157/// token reconstruction is needed. Quoted identifiers preserve case; bare
158/// identifiers are canonicalized and checked against reserved words.
159pub 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(&quoted).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}