Skip to main content

nodedb_sql/parser/
normalize.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! SQL identifier normalization.
4
5use crate::error::{Result, SqlError};
6use crate::reserved::{check_ast_identifier, check_identifier};
7
8/// Human-readable message for schema-qualified name rejections.
9/// Defined once so all rejection sites produce consistent output.
10pub const SCHEMA_QUALIFIED_MSG: &str = "schema-qualified names are not supported; NodeDB has no schema concept \
11     — use 'users' not 'public.users'";
12
13/// Normalize a SQL identifier: lowercase unquoted, preserve quoted.
14pub fn normalize_ident(ident: &sqlparser::ast::Ident) -> String {
15    if ident.quote_style.is_some() {
16        ident.value.clone()
17    } else {
18        ident.value.to_lowercase()
19    }
20}
21
22/// Normalize a compound object name, rejecting schema-qualified forms.
23///
24/// Accepts a single-part name (plain identifier) and returns it normalized.
25/// Rejects any name with more than one part (e.g. `public.users`,
26/// `db.public.users`) with `SqlError::Unsupported`.
27pub fn normalize_object_name_checked(name: &sqlparser::ast::ObjectName) -> Result<String> {
28    if name.0.len() > 1 {
29        // Validate every component before reflecting it in an error. This keeps
30        // malformed quoted/control-bearing identifiers out of diagnostics too.
31        let qualified = name
32            .0
33            .iter()
34            .map(|part| match part {
35                sqlparser::ast::ObjectNamePart::Identifier(ident) => check_ast_identifier(ident),
36                _ => Err(SqlError::InvalidIdentifier {
37                    name: String::new(),
38                    reason: "object name must contain only identifiers",
39                }),
40            })
41            .collect::<Result<Vec<_>>>()?
42            .join(".");
43        return Err(SqlError::Unsupported {
44            detail: format!("'{qualified}': {SCHEMA_QUALIFIED_MSG}"),
45        });
46    }
47    let part = name.0.first().ok_or_else(|| SqlError::InvalidIdentifier {
48        name: String::new(),
49        reason: "object name must contain an identifier",
50    })?;
51    match part {
52        sqlparser::ast::ObjectNamePart::Identifier(ident) => check_ast_identifier(ident),
53        _ => Err(SqlError::InvalidIdentifier {
54            name: String::new(),
55            reason: "object name must contain an identifier",
56        }),
57    }
58}
59
60/// Normalize a single entry of an `INSERT` column list.
61///
62/// The parser models these as `ObjectName`, but an `INSERT` target column is
63/// always one plain identifier: a qualified form like `t.col` names no column
64/// NodeDB can write to, and a function part is not a name at all. Both are
65/// typed errors — taking the last part of a qualified name binds values to a
66/// column the statement never asked for.
67pub fn normalize_insert_column(name: &sqlparser::ast::ObjectName) -> Result<String> {
68    if let [sqlparser::ast::ObjectNamePart::Identifier(ident)] = name.0.as_slice() {
69        return Ok(normalize_ident(ident));
70    }
71    if name.0.len() == 1 {
72        // The sole part is a function call, so there is no identifier to name.
73        return Err(SqlError::InvalidIdentifier {
74            name: String::new(),
75            reason: "an INSERT column list takes plain column names",
76        });
77    }
78    // Qualified or empty. `normalize_object_name_checked` owns both rejections
79    // and validates every component before reflecting it in the message; it
80    // cannot return `Ok` for either shape.
81    normalize_object_name_checked(name)
82}
83
84/// Normalize a table name, accepting the two system-schema qualifiers that name
85/// catalog relations (NodeDB has no user schemas, so any other qualifier is
86/// rejected by [`normalize_object_name_checked`]):
87///
88/// - `pg_catalog.pg_class` → `pg_class` (the `pg_catalog` schema is implicit).
89/// - `_system.audit_log` → `_system.audit_log` (the `_system.` prefix is part of
90///   the catalog relation's name).
91///
92/// These are resolved like any other relation through `SqlCatalog::resolve_relation`.
93fn normalize_table_name(name: &sqlparser::ast::ObjectName) -> Result<String> {
94    if name.0.len() == 2 {
95        let parts: Vec<String> = name
96            .0
97            .iter()
98            .map(|part| match part {
99                sqlparser::ast::ObjectNamePart::Identifier(ident) => check_ast_identifier(ident),
100                _ => Err(SqlError::InvalidIdentifier {
101                    name: String::new(),
102                    reason: "object name must contain an identifier",
103                }),
104            })
105            .collect::<Result<_>>()?;
106        match parts[0].as_str() {
107            "pg_catalog" => return Ok(parts[1].clone()),
108            "_system" => return Ok(format!("_system.{}", parts[1])),
109            _ => {}
110        }
111    }
112    normalize_object_name_checked(name)
113}
114
115/// Resolve a raw relation token the same way [`normalize_table_name`] resolves
116/// a parsed one.
117///
118/// Handlers that read a relation out of a whitespace-split statement get the
119/// token before `sqlparser` sees it, so they need the rule in token form:
120///
121/// - `pg_catalog.pg_class` → `pg_class`.
122/// - `_system.audit_log` → `_system.audit_log`.
123/// - Any other qualifier is rejected as schema-qualified.
124/// - An unqualified token decodes through [`check_identifier`].
125///
126/// A token carrying a double quote is treated as one identifier, so a quoted
127/// name containing a dot never splits.
128pub fn check_relation_token(raw_name: &str) -> Result<String> {
129    if raw_name.contains('"') || !raw_name.contains('.') {
130        return check_identifier(raw_name);
131    }
132    // Validate every component before reflecting it in an error, the way
133    // `normalize_object_name_checked` does.
134    let parts = raw_name
135        .split('.')
136        .map(check_identifier)
137        .collect::<Result<Vec<_>>>()?;
138    if parts.len() == 2 {
139        match parts[0].as_str() {
140            "pg_catalog" => return Ok(parts[1].clone()),
141            "_system" => return Ok(format!("_system.{}", parts[1])),
142            _ => {}
143        }
144    }
145    Err(SqlError::Unsupported {
146        detail: format!("'{}': {SCHEMA_QUALIFIED_MSG}", parts.join(".")),
147    })
148}
149
150/// Extract table name and optional alias from a table factor.
151///
152/// Returns `Err` if the table name is schema-qualified with a non-system schema.
153pub fn table_name_from_factor(
154    factor: &sqlparser::ast::TableFactor,
155) -> Result<Option<(String, Option<String>)>> {
156    match factor {
157        sqlparser::ast::TableFactor::Table { name, alias, .. } => {
158            let table = normalize_table_name(name)?;
159            let alias_name = alias
160                .as_ref()
161                .map(|alias| check_ast_identifier(&alias.name))
162                .transpose()?;
163            Ok(Some((table, alias_name)))
164        }
165        _ => Ok(None),
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::parser::statement::parse_sql;
173    use sqlparser::ast::Statement;
174
175    fn parse_table_factor(sql: &str) -> sqlparser::ast::TableFactor {
176        let stmts = parse_sql(sql).expect("parse failed");
177        let Statement::Query(q) = &stmts[0] else {
178            panic!("expected query");
179        };
180        let sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() else {
181            panic!("expected select body");
182        };
183        sel.from[0].relation.clone()
184    }
185
186    fn parse_object_name(sql: &str) -> sqlparser::ast::ObjectName {
187        match parse_table_factor(sql) {
188            sqlparser::ast::TableFactor::Table { name, .. } => name,
189            other => panic!("expected table factor, got {other:?}"),
190        }
191    }
192
193    #[test]
194    fn plain_name_accepted() {
195        let name = parse_object_name("SELECT * FROM users");
196        assert_eq!(normalize_object_name_checked(&name).unwrap(), "users");
197    }
198
199    #[test]
200    fn schema_qualified_two_parts_rejected() {
201        let name = parse_object_name("SELECT * FROM public.users");
202        let err = normalize_object_name_checked(&name).unwrap_err();
203        assert!(
204            matches!(err, SqlError::Unsupported { .. }),
205            "expected Unsupported, got {err:?}"
206        );
207        let msg = format!("{err}");
208        assert!(
209            msg.contains("public.users") || msg.contains("schema-qualified"),
210            "error should mention the qualified name or schema: {msg}"
211        );
212    }
213
214    #[test]
215    fn schema_qualified_three_parts_rejected() {
216        // db.public.users — three-part name.
217        // sqlparser may not parse this as a table name with three parts in all dialects,
218        // but we can verify via a manually constructed ObjectName.
219        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
220        let name = ObjectName(vec![
221            ObjectNamePart::Identifier(Ident::new("db")),
222            ObjectNamePart::Identifier(Ident::new("public")),
223            ObjectNamePart::Identifier(Ident::new("users")),
224        ]);
225        let err = normalize_object_name_checked(&name).unwrap_err();
226        assert!(
227            matches!(err, SqlError::Unsupported { .. }),
228            "expected Unsupported, got {err:?}"
229        );
230    }
231
232    #[test]
233    fn object_names_enforce_ast_identifier_rules() {
234        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
235
236        let empty = ObjectName(vec![ObjectNamePart::Identifier(Ident::new(""))]);
237        assert!(matches!(
238            normalize_object_name_checked(&empty),
239            Err(SqlError::InvalidIdentifier { .. })
240        ));
241
242        let reserved = ObjectName(vec![ObjectNamePart::Identifier(Ident::new("MATCH"))]);
243        assert!(matches!(
244            normalize_object_name_checked(&reserved),
245            Err(SqlError::ReservedIdentifier { .. })
246        ));
247
248        let mut quoted = Ident::new("MiXeD 雪");
249        quoted.quote_style = Some('"');
250        let quoted_name = ObjectName(vec![ObjectNamePart::Identifier(quoted)]);
251        assert_eq!(
252            normalize_object_name_checked(&quoted_name).expect("quoted object name"),
253            "MiXeD 雪"
254        );
255
256        let quote = ObjectName(vec![ObjectNamePart::Identifier(Ident::new("a\"b"))]);
257        assert!(matches!(
258            normalize_object_name_checked(&quote),
259            Err(SqlError::InvalidIdentifier { .. })
260        ));
261    }
262
263    #[test]
264    fn insert_columns_take_plain_names_only() {
265        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
266
267        let plain = ObjectName(vec![ObjectNamePart::Identifier(Ident::new("MiXeD"))]);
268        assert_eq!(
269            normalize_insert_column(&plain).expect("plain column"),
270            "mixed"
271        );
272
273        let mut quoted_ident = Ident::new("MiXeD");
274        quoted_ident.quote_style = Some('"');
275        let quoted = ObjectName(vec![ObjectNamePart::Identifier(quoted_ident)]);
276        assert_eq!(
277            normalize_insert_column(&quoted).expect("quoted column"),
278            "MiXeD"
279        );
280
281        // `INSERT INTO t (a.b) VALUES (1)` parses as a two-part name. Taking
282        // either part would bind the value to a column the statement did not
283        // name, so the whole statement is refused.
284        let qualified = ObjectName(vec![
285            ObjectNamePart::Identifier(Ident::new("a")),
286            ObjectNamePart::Identifier(Ident::new("b")),
287        ]);
288        assert!(matches!(
289            normalize_insert_column(&qualified),
290            Err(SqlError::Unsupported { .. })
291        ));
292
293        let empty = ObjectName(Vec::new());
294        assert!(matches!(
295            normalize_insert_column(&empty),
296            Err(SqlError::InvalidIdentifier { .. })
297        ));
298    }
299
300    #[test]
301    fn relation_tokens_accept_the_two_system_qualifiers() {
302        assert_eq!(
303            check_relation_token("_system.audit_log").expect("system relation"),
304            "_system.audit_log"
305        );
306        assert_eq!(
307            check_relation_token("pg_catalog.pg_class").expect("catalog relation"),
308            "pg_class"
309        );
310        assert_eq!(check_relation_token("MiXeD").expect("bare"), "mixed");
311        assert_eq!(check_relation_token("\"MiXeD\"").expect("quoted"), "MiXeD");
312    }
313
314    #[test]
315    fn relation_tokens_reject_other_qualifiers() {
316        for token in ["public.users", "db.public.users"] {
317            assert!(
318                matches!(
319                    check_relation_token(token),
320                    Err(SqlError::Unsupported { .. })
321                ),
322                "{token}"
323            );
324        }
325        // A quoted token never splits, so a dot inside it stays part of the
326        // name and fails the identifier grammar rather than reading as a
327        // qualifier.
328        assert!(matches!(
329            check_relation_token("_system.\"a.b\""),
330            Err(SqlError::InvalidIdentifier { .. })
331        ));
332    }
333
334    #[test]
335    fn qualified_names_validate_components_before_rejection() {
336        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
337
338        for invalid_value in ["bad\nname", "a\"b"] {
339            let mut invalid = Ident::new(invalid_value);
340            invalid.quote_style = Some('"');
341            let name = ObjectName(vec![
342                ObjectNamePart::Identifier(Ident::new("public")),
343                ObjectNamePart::Identifier(invalid),
344            ]);
345            assert!(matches!(
346                normalize_object_name_checked(&name),
347                Err(SqlError::InvalidIdentifier { .. })
348            ));
349        }
350    }
351
352    #[test]
353    fn table_aliases_enforce_ast_identifier_rules() {
354        use crate::planner::lateral::subquery::lateral_alias_from_factor;
355
356        let quoted = parse_table_factor("SELECT * FROM users AS \"MiXeD 雪\"");
357        assert_eq!(
358            table_name_from_factor(&quoted).expect("valid alias"),
359            Some(("users".to_string(), Some("MiXeD 雪".to_string())))
360        );
361
362        for alias in ["\"\"", "\"a\"\"b\""] {
363            let ordinary = parse_table_factor(&format!("SELECT * FROM users AS {alias}"));
364            assert!(matches!(
365                table_name_from_factor(&ordinary),
366                Err(SqlError::InvalidIdentifier { .. })
367            ));
368
369            let lateral =
370                parse_table_factor(&format!("SELECT * FROM LATERAL (SELECT 1) AS {alias}"));
371            assert!(matches!(
372                lateral_alias_from_factor(&lateral),
373                Err(SqlError::InvalidIdentifier { .. })
374            ));
375        }
376    }
377
378    #[test]
379    fn system_table_qualifiers_validate_each_identifier() {
380        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
381
382        let system = ObjectName(vec![
383            ObjectNamePart::Identifier(Ident::new("_system")),
384            ObjectNamePart::Identifier(Ident::new("audit_log")),
385        ]);
386        assert_eq!(
387            normalize_table_name(&system).expect("system table"),
388            "_system.audit_log"
389        );
390
391        let invalid = ObjectName(vec![
392            ObjectNamePart::Identifier(Ident::new("_system")),
393            ObjectNamePart::Identifier(Ident::new("audit\nlog")),
394        ]);
395        assert!(matches!(
396            normalize_table_name(&invalid),
397            Err(SqlError::InvalidIdentifier { .. })
398        ));
399    }
400}