Skip to main content

nodedb_sql/
reserved.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! NodeDB reserved identifier list.
4//!
5//! These keywords are intercepted by the DDL dispatcher or DSL rewriter
6//! before the SQL reaches sqlparser. Using them as bare (unquoted)
7//! identifiers would cause silent misdispatch, so they are rejected at
8//! parse time with an actionable error message that includes the quoted
9//! escape form.
10
11use crate::error::SqlError;
12
13/// Words that NodeDB claims as dispatch or rewrite keywords.
14///
15/// Each entry is stored in UPPER case. All comparisons normalise the
16/// input to upper case before checking containment.
17pub const RESERVED_KEYWORDS: &[&str] = &[
18    "GRAPH",    // graph dispatch keyword
19    "MATCH",    // graph dispatch keyword
20    "OPTIONAL", // graph dispatch keyword
21    "UPSERT",   // preprocess rewrite keyword
22    "UNDROP",   // DDL dispatch keyword
23    "PURGE",    // DROP modifier keyword
24    "CASCADE",  // DROP modifier keyword
25    "SEARCH",   // DSL dispatch keyword
26    "CRDT",     // DSL dispatch keyword
27];
28
29fn reason_for(upper: &str) -> &'static str {
30    match upper {
31        "GRAPH" | "MATCH" | "OPTIONAL" => "graph dispatch keyword",
32        "UPSERT" => "preprocess rewrite keyword",
33        "UNDROP" => "DDL dispatch keyword",
34        "PURGE" | "CASCADE" => "DROP modifier keyword",
35        "SEARCH" | "CRDT" => "DSL dispatch keyword",
36        _ => "reserved by NodeDB",
37    }
38}
39
40/// Return `true` if `name` matches a NodeDB reserved keyword
41/// (case-insensitive).
42pub fn is_reserved(name: &str) -> bool {
43    let upper = name.to_uppercase();
44    RESERVED_KEYWORDS.contains(&upper.as_str())
45}
46
47/// Validate a raw identifier token extracted from SQL.
48///
49/// * If `raw_name` is surrounded by double-quotes (standard SQL quoting),
50///   the quotes are stripped and the inner text is returned unchanged as
51///   `Ok(inner)` — the user has opted in to the reserved word.
52/// * Otherwise, the token is normalised to upper case and compared against
53///   [`RESERVED_KEYWORDS`]. A match returns
54///   `Err(SqlError::ReservedIdentifier { .. })` with an actionable hint.
55/// * A clean identifier is returned as `Ok(name.to_lowercase())` to match
56///   the existing `parse_col_token` behaviour.
57pub fn check_identifier(raw_name: &str) -> Result<String, SqlError> {
58    if raw_name.starts_with('"') && raw_name.ends_with('"') && raw_name.len() >= 2 {
59        // Standard SQL quoted identifier: strip the surrounding quotes.
60        return Ok(raw_name[1..raw_name.len() - 1].to_string());
61    }
62
63    let upper = raw_name.to_uppercase();
64    if RESERVED_KEYWORDS.contains(&upper.as_str()) {
65        let reason = reason_for(&upper);
66        return Err(SqlError::ReservedIdentifier {
67            name: raw_name.to_string(),
68            reason,
69        });
70    }
71
72    Ok(raw_name.to_lowercase())
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::error::SqlError;
79
80    // ── is_reserved ──────────────────────────────────────────────────────────
81
82    #[test]
83    fn reserved_upper() {
84        assert!(is_reserved("MATCH"));
85    }
86
87    #[test]
88    fn reserved_lower() {
89        assert!(is_reserved("match"));
90    }
91
92    #[test]
93    fn not_reserved() {
94        assert!(!is_reserved("id"));
95    }
96
97    // ── check_identifier ─────────────────────────────────────────────────────
98
99    #[test]
100    fn bare_reserved_is_err() {
101        let err = check_identifier("match").unwrap_err();
102        assert!(matches!(err, SqlError::ReservedIdentifier { .. }));
103    }
104
105    #[test]
106    fn quoted_lower_is_ok() {
107        assert_eq!(check_identifier("\"match\"").unwrap(), "match");
108    }
109
110    #[test]
111    fn quoted_upper_is_ok() {
112        assert_eq!(check_identifier("\"MATCH\"").unwrap(), "MATCH");
113    }
114
115    #[test]
116    fn clean_identifier_is_ok() {
117        assert_eq!(check_identifier("id").unwrap(), "id");
118    }
119
120    // ── one test per reserved word: bare rejected, quoted accepted ────────────
121
122    #[test]
123    fn graph_reserved() {
124        assert!(check_identifier("graph").is_err());
125        assert_eq!(check_identifier("\"graph\"").unwrap(), "graph");
126    }
127
128    #[test]
129    fn match_reserved() {
130        assert!(check_identifier("match").is_err());
131        assert_eq!(check_identifier("\"match\"").unwrap(), "match");
132    }
133
134    #[test]
135    fn optional_reserved() {
136        assert!(check_identifier("optional").is_err());
137        assert_eq!(check_identifier("\"optional\"").unwrap(), "optional");
138    }
139
140    #[test]
141    fn upsert_reserved() {
142        assert!(check_identifier("upsert").is_err());
143        assert_eq!(check_identifier("\"upsert\"").unwrap(), "upsert");
144    }
145
146    #[test]
147    fn undrop_reserved() {
148        assert!(check_identifier("undrop").is_err());
149        assert_eq!(check_identifier("\"undrop\"").unwrap(), "undrop");
150    }
151
152    #[test]
153    fn purge_reserved() {
154        assert!(check_identifier("purge").is_err());
155        assert_eq!(check_identifier("\"purge\"").unwrap(), "purge");
156    }
157
158    #[test]
159    fn cascade_reserved() {
160        assert!(check_identifier("cascade").is_err());
161        assert_eq!(check_identifier("\"cascade\"").unwrap(), "cascade");
162    }
163
164    #[test]
165    fn search_reserved() {
166        assert!(check_identifier("search").is_err());
167        assert_eq!(check_identifier("\"search\"").unwrap(), "search");
168    }
169
170    #[test]
171    fn crdt_reserved() {
172        assert!(check_identifier("crdt").is_err());
173        assert_eq!(check_identifier("\"crdt\"").unwrap(), "crdt");
174    }
175}