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};
6
7/// Human-readable message for schema-qualified name rejections.
8/// Defined once so all rejection sites produce consistent output.
9pub const SCHEMA_QUALIFIED_MSG: &str = "schema-qualified names are not supported; NodeDB has no schema concept \
10     — use 'users' not 'public.users'";
11
12/// Normalize a SQL identifier: lowercase unquoted, preserve quoted.
13pub fn normalize_ident(ident: &sqlparser::ast::Ident) -> String {
14    if ident.quote_style.is_some() {
15        ident.value.clone()
16    } else {
17        ident.value.to_lowercase()
18    }
19}
20
21/// Normalize a compound object name, rejecting schema-qualified forms.
22///
23/// Accepts a single-part name (plain identifier) and returns it normalized.
24/// Rejects any name with more than one part (e.g. `public.users`,
25/// `db.public.users`) with `SqlError::Unsupported`.
26pub fn normalize_object_name_checked(name: &sqlparser::ast::ObjectName) -> Result<String> {
27    if name.0.len() > 1 {
28        // Build a human-readable representation of what was actually written.
29        let qualified: String = name
30            .0
31            .iter()
32            .map(|part| match part {
33                sqlparser::ast::ObjectNamePart::Identifier(ident) => ident.value.clone(),
34                _ => String::new(),
35            })
36            .collect::<Vec<_>>()
37            .join(".");
38        return Err(SqlError::Unsupported {
39            detail: format!("'{qualified}': {SCHEMA_QUALIFIED_MSG}"),
40        });
41    }
42    Ok(name
43        .0
44        .first()
45        .map(|part| match part {
46            sqlparser::ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
47            _ => String::new(),
48        })
49        .unwrap_or_default())
50}
51
52/// Normalize a table name, accepting the two system-schema qualifiers that name
53/// catalog relations (NodeDB has no user schemas, so any other qualifier is
54/// rejected by [`normalize_object_name_checked`]):
55///
56/// - `pg_catalog.pg_class` → `pg_class` (the `pg_catalog` schema is implicit).
57/// - `_system.audit_log` → `_system.audit_log` (the `_system.` prefix is part of
58///   the catalog relation's name).
59///
60/// These are resolved like any other relation through `SqlCatalog::resolve_relation`.
61fn normalize_table_name(name: &sqlparser::ast::ObjectName) -> Result<String> {
62    if name.0.len() == 2 {
63        let parts: Vec<String> = name
64            .0
65            .iter()
66            .map(|p| match p {
67                sqlparser::ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
68                _ => String::new(),
69            })
70            .collect();
71        match parts[0].as_str() {
72            "pg_catalog" => return Ok(parts[1].clone()),
73            "_system" => return Ok(format!("_system.{}", parts[1])),
74            _ => {}
75        }
76    }
77    normalize_object_name_checked(name)
78}
79
80/// Extract table name and optional alias from a table factor.
81///
82/// Returns `Err` if the table name is schema-qualified with a non-system schema.
83pub fn table_name_from_factor(
84    factor: &sqlparser::ast::TableFactor,
85) -> Result<Option<(String, Option<String>)>> {
86    match factor {
87        sqlparser::ast::TableFactor::Table { name, alias, .. } => {
88            let table = normalize_table_name(name)?;
89            let alias_name = alias.as_ref().map(|a| normalize_ident(&a.name));
90            Ok(Some((table, alias_name)))
91        }
92        _ => Ok(None),
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::parser::statement::parse_sql;
100    use sqlparser::ast::Statement;
101
102    fn parse_object_name(sql: &str) -> sqlparser::ast::ObjectName {
103        // Parse a `SELECT * FROM <name>` and extract the table ObjectName.
104        let stmts = parse_sql(sql).expect("parse failed");
105        let Statement::Query(q) = &stmts[0] else {
106            panic!("expected query");
107        };
108        let sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() else {
109            panic!("expected select body");
110        };
111        match &sel.from[0].relation {
112            sqlparser::ast::TableFactor::Table { name, .. } => name.clone(),
113            other => panic!("expected table factor, got {other:?}"),
114        }
115    }
116
117    #[test]
118    fn plain_name_accepted() {
119        let name = parse_object_name("SELECT * FROM users");
120        assert_eq!(normalize_object_name_checked(&name).unwrap(), "users");
121    }
122
123    #[test]
124    fn schema_qualified_two_parts_rejected() {
125        let name = parse_object_name("SELECT * FROM public.users");
126        let err = normalize_object_name_checked(&name).unwrap_err();
127        assert!(
128            matches!(err, SqlError::Unsupported { .. }),
129            "expected Unsupported, got {err:?}"
130        );
131        let msg = format!("{err}");
132        assert!(
133            msg.contains("public.users") || msg.contains("schema-qualified"),
134            "error should mention the qualified name or schema: {msg}"
135        );
136    }
137
138    #[test]
139    fn schema_qualified_three_parts_rejected() {
140        // db.public.users — three-part name.
141        // sqlparser may not parse this as a table name with three parts in all dialects,
142        // but we can verify via a manually constructed ObjectName.
143        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
144        let name = ObjectName(vec![
145            ObjectNamePart::Identifier(Ident::new("db")),
146            ObjectNamePart::Identifier(Ident::new("public")),
147            ObjectNamePart::Identifier(Ident::new("users")),
148        ]);
149        let err = normalize_object_name_checked(&name).unwrap_err();
150        assert!(
151            matches!(err, SqlError::Unsupported { .. }),
152            "expected Unsupported, got {err:?}"
153        );
154    }
155}