Skip to main content

nodedb_sql/resolver/expr/convert/
identifier.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Column references and bare-identifier conversion.
4
5use sqlparser::ast::Ident;
6
7use crate::error::{Result, SqlError};
8use crate::parser::normalize::{SCHEMA_QUALIFIED_MSG, normalize_ident};
9use crate::resolver::ColumnScope;
10use crate::types::*;
11
12/// SQL-standard niladic functions: written without parentheses. Parsers
13/// emit them as bare identifiers; we promote them to function calls so
14/// they fold to a value at plan time instead of resolving to a column.
15fn is_zero_arg_keyword_function(name: &str) -> bool {
16    matches!(
17        name,
18        "current_timestamp"
19            | "current_date"
20            | "current_time"
21            | "localtime"
22            | "localtimestamp"
23            | "current_user"
24            | "current_role"
25            | "current_schema"
26            | "session_user"
27            | "user"
28            | "version"
29    )
30}
31
32pub(super) fn convert_identifier(ident: &Ident, scope: &ColumnScope<'_>) -> Result<SqlExpr> {
33    let name = normalize_ident(ident);
34    // SQL-standard zero-arg keyword functions parse as bare
35    // identifiers (no parentheses): `SELECT current_timestamp`,
36    // `SELECT current_user`, etc. Promote them to function calls
37    // so const folding evaluates them like the parenthesised form.
38    if is_zero_arg_keyword_function(&name) {
39        return Ok(SqlExpr::Function {
40            name,
41            args: vec![],
42            distinct: false,
43        });
44    }
45    let table = scope.column_qualifier(None, &name)?;
46    Ok(SqlExpr::Column { table, name })
47}
48
49pub(super) fn convert_compound_identifier(
50    parts: &[Ident],
51    scope: &ColumnScope<'_>,
52) -> Result<SqlExpr> {
53    if parts.len() >= 3 {
54        let qualified: String = parts
55            .iter()
56            .map(normalize_ident)
57            .collect::<Vec<_>>()
58            .join(".");
59        return Err(SqlError::Unsupported {
60            detail: format!(
61                "schema-qualified column reference '{qualified}': {SCHEMA_QUALIFIED_MSG}"
62            ),
63        });
64    }
65    let table = normalize_ident(&parts[0]);
66    let name = normalize_ident(&parts[1]);
67    let table = scope.column_qualifier(Some(&table), &name)?;
68    Ok(SqlExpr::Column { table, name })
69}
70
71#[cfg(test)]
72mod tests {
73    use sqlparser::ast::Expr;
74
75    use crate::error::SqlError;
76    use crate::resolver::ColumnScope;
77    use crate::resolver::expr::convert::convert_expr;
78    use crate::resolver::expr::convert::entry::tests::first_select_expr;
79    use crate::types::*;
80
81    #[test]
82    fn compound_identifier_two_parts_is_column() {
83        let expr = first_select_expr("SELECT t.col FROM t");
84        let result = convert_expr(&expr, &ColumnScope::Unchecked).expect("should succeed");
85        match result {
86            SqlExpr::Column {
87                table: Some(t),
88                name,
89            } => {
90                assert_eq!(t, "t");
91                assert_eq!(name, "col");
92            }
93            other => panic!("expected Column with table, got {other:?}"),
94        }
95    }
96
97    #[test]
98    fn compound_identifier_three_parts_rejected() {
99        // schema.table.col — should be rejected.
100        use sqlparser::ast::Ident;
101        let parts = vec![Ident::new("schema"), Ident::new("table"), Ident::new("col")];
102        let expr = Expr::CompoundIdentifier(parts);
103        let err = convert_expr(&expr, &ColumnScope::Unchecked).unwrap_err();
104        assert!(
105            matches!(err, SqlError::Unsupported { .. }),
106            "expected Unsupported, got {err:?}"
107        );
108        let msg = format!("{err}");
109        assert!(
110            msg.contains("schema.table.col") || msg.contains("schema-qualified"),
111            "error should mention the qualified name: {msg}"
112        );
113    }
114
115    #[test]
116    fn compound_identifier_four_parts_rejected() {
117        use sqlparser::ast::Ident;
118        let parts = vec![
119            Ident::new("a"),
120            Ident::new("b"),
121            Ident::new("c"),
122            Ident::new("d"),
123        ];
124        let expr = Expr::CompoundIdentifier(parts);
125        let err = convert_expr(&expr, &ColumnScope::Unchecked).unwrap_err();
126        assert!(
127            matches!(err, SqlError::Unsupported { .. }),
128            "expected Unsupported, got {err:?}"
129        );
130    }
131
132    /// `"userId"` with the PostgreSQL dialect is an identifier (quoted,
133    /// case-preserved), not a string literal.
134    #[test]
135    fn double_quoted_is_identifier_not_literal() {
136        let expr = first_select_expr(r#"SELECT "userId" FROM users"#);
137        match expr {
138            Expr::Identifier(ident) => {
139                assert_eq!(ident.value, "userId");
140                assert_eq!(ident.quote_style, Some('"'));
141            }
142            other => panic!("expected Identifier, got {other:?}"),
143        }
144    }
145
146    /// A double-quoted identifier in the SELECT list resolves as `SqlExpr::Column`
147    /// with the exact case preserved (not lowercased, because it was quoted).
148    #[test]
149    fn double_quoted_select_col_case_preserved() {
150        let expr = first_select_expr(r#"SELECT "userId" FROM users"#);
151        let sql_expr =
152            convert_expr(&expr, &ColumnScope::Unchecked).expect("convert_expr should succeed");
153        match sql_expr {
154            SqlExpr::Column { name, table } => {
155                assert_eq!(
156                    name, "userId",
157                    "case must be preserved for quoted identifier"
158                );
159                assert_eq!(table, None, "no table qualifier expected");
160            }
161            other => panic!("expected Column, got {other:?}"),
162        }
163    }
164}