Skip to main content

nodedb_sql/resolver/expr/
functions.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::sync::LazyLock;
4
5use sqlparser::ast;
6
7use crate::error::{Result, SqlError};
8use crate::functions::registry::FunctionRegistry;
9use crate::parser::normalize::{SCHEMA_QUALIFIED_MSG, normalize_ident};
10use crate::resolver::ColumnScope;
11use crate::types::*;
12
13use super::convert::convert_expr_depth;
14
15/// Process-wide function registry backing the undefined-function gate in
16/// [`convert_function_depth`]. `FunctionRegistry::new()` rebuilds the full
17/// builtin table (aggregates + scalars) from scratch, so a fresh instance
18/// per function-call site would redo that work on every call in every
19/// statement; a lazily-initialized singleton amortizes it, mirroring
20/// `planner::const_fold::DEFAULT_REGISTRY`.
21static FUNCTION_REGISTRY: LazyLock<FunctionRegistry> = LazyLock::new(FunctionRegistry::new);
22
23pub(super) fn convert_function_depth(
24    func: &ast::Function,
25    depth: &mut usize,
26    scope: &ColumnScope<'_>,
27) -> Result<SqlExpr> {
28    // Intercept PG FTS surface functions and lower them to pg_* internal names
29    // before the generic path runs.
30    if func.name.0.len() == 1 {
31        let raw_name = match &func.name.0[0] {
32            ast::ObjectNamePart::Identifier(ident) => ident.value.to_ascii_lowercase(),
33            _ => String::new(),
34        };
35        if let Some(expr) = intercept_fts_function(&raw_name, func, depth, scope)? {
36            return Ok(expr);
37        }
38    }
39
40    if func.name.0.len() > 1 {
41        let qualified: String = func
42            .name
43            .0
44            .iter()
45            .map(|p| match p {
46                ast::ObjectNamePart::Identifier(ident) => ident.value.clone(),
47                _ => String::new(),
48            })
49            .collect::<Vec<_>>()
50            .join(".");
51        return Err(SqlError::Unsupported {
52            detail: format!("schema-qualified function name '{qualified}': {SCHEMA_QUALIFIED_MSG}"),
53        });
54    }
55    let name = func
56        .name
57        .0
58        .iter()
59        .map(|p| match p {
60            ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
61            _ => String::new(),
62        })
63        .collect::<Vec<_>>()
64        .join(".");
65
66    // `current_schemas(bool)` — PostgreSQL catalog function returning TEXT[].
67    // Fold to a literal array at parse time so `= ANY(current_schemas(...))` works
68    // without threading session context into the data-plane evaluator.
69    if (name == "current_schemas" || name == "current_schema")
70        && let Some(expr) = intercept_catalog_function(&name, func, depth, scope)?
71    {
72        return Ok(expr);
73    }
74
75    // Plan-time existence gate: every special-form
76    // interception above (FTS surface functions, catalog functions) has
77    // already had its shot at `name` and returned early. Anything reaching
78    // this point is a plain scalar/aggregate/window call that must be a
79    // known builtin — `FunctionRegistry` is the single source of truth used
80    // by aggregate/window classification and constant folding elsewhere in
81    // this crate, so an unknown name here would otherwise flow through to
82    // `SqlExpr::Function` and silently evaluate to `NULL` for every row at
83    // runtime (`nodedb_query::functions::eval_function`'s fallback). Reject
84    // it here instead, so the whole statement fails at plan time.
85    if FUNCTION_REGISTRY.lookup(&name).is_none() {
86        return Err(SqlError::UndefinedFunction { name });
87    }
88
89    // Per-row gate. A sequence accessor is evaluated by the planner for a
90    // FROM-less SELECT (`planner::catalog_expr_fold`) and a column DEFAULT
91    // (`planner::defaults`), and by the Control Plane once per output row
92    // for a SELECT-list item over a relation (the scope allows it there).
93    // Every other row-scope clause (WHERE, ORDER BY, GROUP BY, HAVING, JOIN
94    // ON, SET, aggregate and window arguments, subqueries) reaches the row
95    // evaluator, which holds no sequence state and answers `NULL` for every
96    // row. Refuse the call there so the statement fails at plan time.
97    if scope.is_row_scope()
98        && !scope.allows_cp_functions()
99        && crate::functions::sequence_accessor::is_sequence_accessor(&name)
100    {
101        return Err(SqlError::SequencePerRowUnsupported { name });
102    }
103
104    let args = collect_function_args(func, depth, scope)?;
105
106    let distinct = match &func.args {
107        ast::FunctionArguments::List(arg_list) => {
108            matches!(
109                arg_list.duplicate_treatment,
110                Some(ast::DuplicateTreatment::Distinct)
111            )
112        }
113        _ => false,
114    };
115
116    Ok(SqlExpr::Function {
117        name,
118        args,
119        distinct,
120    })
121}
122
123/// Intercept PG FTS surface functions and lower them to `pg_*` internal
124/// function-call `SqlExpr` nodes.  Returns `Ok(None)` for non-FTS names.
125///
126/// `ts_rank_cd` is rejected with `SqlError::Unsupported` as per the
127/// approved design.
128fn intercept_fts_function(
129    name: &str,
130    func: &ast::Function,
131    depth: &mut usize,
132    scope: &ColumnScope<'_>,
133) -> Result<Option<SqlExpr>> {
134    use crate::functions::fts_ops::pg_fts_funcs;
135
136    if !matches!(
137        name,
138        "to_tsvector"
139            | "to_tsquery"
140            | "plainto_tsquery"
141            | "phraseto_tsquery"
142            | "websearch_to_tsquery"
143            | "ts_rank"
144            | "ts_rank_cd"
145            | "ts_headline"
146    ) {
147        return Ok(None);
148    }
149    let args = collect_function_args(func, depth, scope)?;
150    match name {
151        "to_tsvector" => Ok(Some(SqlExpr::Function {
152            name: "pg_to_tsvector".into(),
153            args,
154            distinct: false,
155        })),
156        "to_tsquery" => Ok(Some(pg_fts_funcs::lower_pg_to_tsquery(args))),
157        "plainto_tsquery" => Ok(Some(pg_fts_funcs::lower_pg_plainto_tsquery(args))),
158        "phraseto_tsquery" => Ok(Some(pg_fts_funcs::lower_phraseto_tsquery(args))),
159        "websearch_to_tsquery" => Ok(Some(pg_fts_funcs::lower_pg_websearch_to_tsquery(args))),
160        "ts_rank" => Ok(Some(SqlExpr::Function {
161            name: "pg_ts_rank".into(),
162            args,
163            distinct: false,
164        })),
165        "ts_rank_cd" => Err(SqlError::Unsupported {
166            detail: "ts_rank_cd is not supported; use ts_rank or bm25_score instead".into(),
167        }),
168        "ts_headline" => Ok(Some(SqlExpr::Function {
169            name: "pg_ts_headline".into(),
170            args,
171            distinct: false,
172        })),
173        _ => Ok(None),
174    }
175}
176
177/// Intercept PostgreSQL catalog functions and fold them to literal arrays.
178///
179/// `current_schemas(true)` → `ARRAY['pg_catalog', 'public']`
180/// `current_schemas(false)` → `ARRAY['public']`
181/// `current_schema()` → `'public'`
182///
183/// Returns `Ok(Some(expr))` when the function is folded, `Ok(None)` otherwise.
184fn intercept_catalog_function(
185    name: &str,
186    func: &ast::Function,
187    depth: &mut usize,
188    scope: &ColumnScope<'_>,
189) -> Result<Option<SqlExpr>> {
190    let args = collect_function_args(func, depth, scope)?;
191    match name {
192        "current_schemas" => {
193            // Determine whether to include implicit schemas (pg_catalog).
194            let include_implicit = match args.first() {
195                Some(SqlExpr::Literal(SqlValue::Bool(b))) => *b,
196                // Default to false when arg is missing or non-literal.
197                _ => false,
198            };
199            let mut schemas: Vec<SqlExpr> = Vec::new();
200            if include_implicit {
201                schemas.push(SqlExpr::Literal(SqlValue::String("pg_catalog".into())));
202            }
203            schemas.push(SqlExpr::Literal(SqlValue::String("public".into())));
204            Ok(Some(SqlExpr::ArrayLiteral(schemas)))
205        }
206        "current_schema" => Ok(Some(SqlExpr::Literal(SqlValue::String("public".into())))),
207        _ => Ok(None),
208    }
209}
210
211/// Collect function call arguments, converting each `Expr` to `SqlExpr`.
212fn collect_function_args(
213    func: &ast::Function,
214    depth: &mut usize,
215    scope: &ColumnScope<'_>,
216) -> Result<Vec<SqlExpr>> {
217    match &func.args {
218        ast::FunctionArguments::None => Ok(Vec::new()),
219        ast::FunctionArguments::Subquery(_) => Err(SqlError::Unsupported {
220            detail: "subquery in function args".into(),
221        }),
222        ast::FunctionArguments::List(arg_list) => arg_list
223            .args
224            .iter()
225            .filter_map(|a| match a {
226                ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => {
227                    Some(convert_expr_depth(e, depth, scope))
228                }
229                ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Wildcard) => {
230                    Some(Ok(SqlExpr::Wildcard))
231                }
232                ast::FunctionArg::Named {
233                    arg: ast::FunctionArgExpr::Expr(e),
234                    ..
235                } => Some(convert_expr_depth(e, depth, scope)),
236                _ => None,
237            })
238            .collect::<Result<Vec<_>>>(),
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use sqlparser::dialect::GenericDialect;
245    use sqlparser::parser::Parser;
246
247    use super::*;
248
249    /// Parse `SELECT <expr>` and return the sole projection expression.
250    fn parse_expr(sql: &str) -> ast::Expr {
251        let stmts = Parser::parse_sql(&GenericDialect {}, sql).unwrap();
252        match stmts.into_iter().next().unwrap() {
253            ast::Statement::Query(q) => match *q.body {
254                ast::SetExpr::Select(s) => match s.projection.into_iter().next().unwrap() {
255                    ast::SelectItem::UnnamedExpr(e) => e,
256                    ast::SelectItem::ExprWithAlias { expr, .. } => expr,
257                    other => panic!("expected a bare/aliased expr projection, got {other:?}"),
258                },
259                _ => panic!("expected SELECT"),
260            },
261            _ => panic!("expected query"),
262        }
263    }
264
265    /// Parse `SELECT <call>` and return the function-call AST node.
266    fn function_ast(sql: &str) -> ast::Function {
267        match parse_expr(sql) {
268            ast::Expr::Function(f) => f,
269            other => panic!("expected a function call expr, got {other:?}"),
270        }
271    }
272
273    #[test]
274    fn unknown_function_name_is_rejected() {
275        let func = function_ast("SELECT totally_bogus_fn(1, 2)");
276        let mut depth = 0;
277        let err = convert_function_depth(&func, &mut depth, &ColumnScope::Unchecked).unwrap_err();
278        match err {
279            SqlError::UndefinedFunction { name } => assert_eq!(name, "totally_bogus_fn"),
280            other => panic!("expected SqlError::UndefinedFunction, got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn known_scalar_function_name_resolves() {
286        let func = function_ast("SELECT upper('x')");
287        let mut depth = 0;
288        let expr = convert_function_depth(&func, &mut depth, &ColumnScope::Unchecked).unwrap();
289        match expr {
290            SqlExpr::Function { name, .. } => assert_eq!(name, "upper"),
291            other => panic!("expected SqlExpr::Function, got {other:?}"),
292        }
293    }
294
295    /// `func.name` is only lower-cased upstream (`normalize_ident`) for
296    /// *unquoted* identifiers, so a quoted `"UPPER"` reaches the registry
297    /// gate with its original case intact. The gate must still resolve it —
298    /// `FunctionRegistry::lookup` case-folds internally — proving the
299    /// existence check itself is case-insensitive, not merely riding on
300    /// upstream normalization.
301    #[test]
302    fn function_lookup_is_case_insensitive() {
303        let mut depth = 0;
304        let lower = function_ast("SELECT upper('x')");
305        let upper_quoted = function_ast(r#"SELECT "UPPER"('x')"#);
306        assert!(
307            convert_function_depth(&lower, &mut depth, &ColumnScope::Unchecked).is_ok(),
308            "lowercase 'upper' must resolve"
309        );
310        assert!(
311            convert_function_depth(&upper_quoted, &mut depth, &ColumnScope::Unchecked).is_ok(),
312            "quoted 'UPPER' must still resolve via case-insensitive registry lookup"
313        );
314    }
315
316    /// A FROM-less SELECT resolves against an empty relation set, where the
317    /// planner evaluates the accessor through the catalog.
318    #[test]
319    fn a_sequence_accessor_resolves_without_a_relation() {
320        let func = function_ast("SELECT nextval('s')");
321        let mut depth = 0;
322        let scope = crate::resolver::columns::TableScope::new();
323        let expr = convert_function_depth(&func, &mut depth, &ColumnScope::Relations(&scope))
324            .expect("a FROM-less sequence accessor must resolve");
325        match expr {
326            SqlExpr::Function { name, .. } => assert_eq!(name, "nextval"),
327            other => panic!("expected SqlExpr::Function, got {other:?}"),
328        }
329    }
330
331    /// A relation in scope makes the call per-row, which the row evaluator
332    /// cannot serve, so the resolver refuses it.
333    #[test]
334    fn a_sequence_accessor_over_a_relation_is_refused() {
335        let mut depth = 0;
336        let outer = crate::resolver::columns::TableScope::new();
337        let scope = crate::resolver::columns::TableScope::new().nested_in(outer);
338        for call in [
339            "SELECT nextval('s')",
340            "SELECT currval('s')",
341            "SELECT setval('s', 1)",
342        ] {
343            let func = function_ast(call);
344            let err = convert_function_depth(&func, &mut depth, &ColumnScope::Relations(&scope))
345                .unwrap_err();
346            assert!(
347                matches!(err, SqlError::SequencePerRowUnsupported { .. }),
348                "expected SqlError::SequencePerRowUnsupported for {call}, got {err:?}"
349            );
350        }
351    }
352
353    /// A SELECT-list scope that allows Control-Plane functions resolves the
354    /// accessor over a relation; a scope nested inside it does not inherit
355    /// the allowance.
356    #[test]
357    fn a_cp_allowing_scope_resolves_the_accessor_and_a_nested_scope_refuses() {
358        let mut depth = 0;
359        let func = function_ast("SELECT nextval('s')");
360        let allowed =
361            crate::resolver::columns::test_support::open_scope("t").allowing_cp_functions();
362        assert!(allowed.is_row_scope());
363        let expr = convert_function_depth(&func, &mut depth, &ColumnScope::Relations(&allowed))
364            .expect("a SELECT-list accessor over a relation must resolve");
365        assert!(matches!(expr, SqlExpr::Function { ref name, .. } if name == "nextval"));
366
367        let nested = crate::resolver::columns::TableScope::new().nested_in(allowed);
368        let err = convert_function_depth(&func, &mut depth, &ColumnScope::Relations(&nested))
369            .unwrap_err();
370        assert!(matches!(err, SqlError::SequencePerRowUnsupported { .. }));
371    }
372}