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/// Extract table name and optional alias from a table factor.
53///
54/// Returns `Err` if the table name is schema-qualified.
55pub fn table_name_from_factor(
56    factor: &sqlparser::ast::TableFactor,
57) -> Result<Option<(String, Option<String>)>> {
58    match factor {
59        sqlparser::ast::TableFactor::Table { name, alias, .. } => {
60            let table = normalize_object_name_checked(name)?;
61            let alias_name = alias.as_ref().map(|a| normalize_ident(&a.name));
62            Ok(Some((table, alias_name)))
63        }
64        _ => Ok(None),
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::parser::statement::parse_sql;
72    use sqlparser::ast::Statement;
73
74    fn parse_object_name(sql: &str) -> sqlparser::ast::ObjectName {
75        // Parse a `SELECT * FROM <name>` and extract the table ObjectName.
76        let stmts = parse_sql(sql).expect("parse failed");
77        let Statement::Query(q) = &stmts[0] else {
78            panic!("expected query");
79        };
80        let sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() else {
81            panic!("expected select body");
82        };
83        match &sel.from[0].relation {
84            sqlparser::ast::TableFactor::Table { name, .. } => name.clone(),
85            other => panic!("expected table factor, got {other:?}"),
86        }
87    }
88
89    #[test]
90    fn plain_name_accepted() {
91        let name = parse_object_name("SELECT * FROM users");
92        assert_eq!(normalize_object_name_checked(&name).unwrap(), "users");
93    }
94
95    #[test]
96    fn schema_qualified_two_parts_rejected() {
97        let name = parse_object_name("SELECT * FROM public.users");
98        let err = normalize_object_name_checked(&name).unwrap_err();
99        assert!(
100            matches!(err, SqlError::Unsupported { .. }),
101            "expected Unsupported, got {err:?}"
102        );
103        let msg = format!("{err}");
104        assert!(
105            msg.contains("public.users") || msg.contains("schema-qualified"),
106            "error should mention the qualified name or schema: {msg}"
107        );
108    }
109
110    #[test]
111    fn schema_qualified_three_parts_rejected() {
112        // db.public.users — three-part name.
113        // sqlparser may not parse this as a table name with three parts in all dialects,
114        // but we can verify via a manually constructed ObjectName.
115        use sqlparser::ast::{Ident, ObjectName, ObjectNamePart};
116        let name = ObjectName(vec![
117            ObjectNamePart::Identifier(Ident::new("db")),
118            ObjectNamePart::Identifier(Ident::new("public")),
119            ObjectNamePart::Identifier(Ident::new("users")),
120        ]);
121        let err = normalize_object_name_checked(&name).unwrap_err();
122        assert!(
123            matches!(err, SqlError::Unsupported { .. }),
124            "expected Unsupported, got {err:?}"
125        );
126    }
127}