Skip to main content

nodedb_sql/planner/
ast_helpers.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared AST manipulation helpers for DML planners.
4
5use sqlparser::ast;
6
7use crate::error::{Result, SqlError};
8use crate::parser::normalize::{normalize_ident, normalize_object_name_checked};
9use crate::planner::select::convert_where_to_filters;
10use crate::resolver::columns::ResolvedTable;
11use crate::types::Filter;
12
13/// Return `(table, column)` for a `table.col` compound identifier, or `None`.
14pub fn qualified_ident_pair(expr: &ast::Expr) -> Option<(String, String)> {
15    match expr {
16        ast::Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
17            Some((normalize_ident(&parts[0]), normalize_ident(&parts[1])))
18        }
19        _ => None,
20    }
21}
22
23/// Flatten a right-leaning AND expression tree into a list of conjuncts.
24pub fn flatten_and_expr(expr: &ast::Expr, out: &mut Vec<ast::Expr>) {
25    match expr {
26        ast::Expr::BinaryOp {
27            left,
28            op: ast::BinaryOperator::And,
29            right,
30        } => {
31            flatten_and_expr(left, out);
32            flatten_and_expr(right, out);
33        }
34        other => out.push(other.clone()),
35    }
36}
37
38/// Reassemble conjuncts into a right-leaning AND tree. Panics if empty.
39pub fn rebuild_and_expr(mut conjuncts: Vec<ast::Expr>) -> ast::Expr {
40    let last = conjuncts.pop().expect("non-empty conjuncts");
41    conjuncts
42        .into_iter()
43        .rfold(last, |acc, next| ast::Expr::BinaryOp {
44            left: Box::new(next),
45            op: ast::BinaryOperator::And,
46            right: Box::new(acc),
47        })
48}
49
50/// Walk an expression and replace every `table.col` compound identifier where
51/// `table == qualifier` with a bare `col` identifier. Lets target-side
52/// predicates like `t.score > 15` be evaluated against documents that store
53/// fields without a table qualifier.
54pub fn strip_table_qualifier(expr: &ast::Expr, qualifier: &str) -> ast::Expr {
55    match expr {
56        ast::Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
57            if normalize_ident(&parts[0]) == qualifier {
58                ast::Expr::Identifier(parts[1].clone())
59            } else {
60                expr.clone()
61            }
62        }
63        ast::Expr::BinaryOp { left, op, right } => ast::Expr::BinaryOp {
64            left: Box::new(strip_table_qualifier(left, qualifier)),
65            op: op.clone(),
66            right: Box::new(strip_table_qualifier(right, qualifier)),
67        },
68        ast::Expr::UnaryOp { op, expr: inner } => ast::Expr::UnaryOp {
69            op: *op,
70            expr: Box::new(strip_table_qualifier(inner, qualifier)),
71        },
72        ast::Expr::Nested(inner) => {
73            ast::Expr::Nested(Box::new(strip_table_qualifier(inner, qualifier)))
74        }
75        ast::Expr::IsNull(inner) => {
76            ast::Expr::IsNull(Box::new(strip_table_qualifier(inner, qualifier)))
77        }
78        ast::Expr::IsNotNull(inner) => {
79            ast::Expr::IsNotNull(Box::new(strip_table_qualifier(inner, qualifier)))
80        }
81        other => other.clone(),
82    }
83}
84
85/// Walk `expr` and return the first qualifier on a `table.col` compound
86/// identifier that does not match any of `valid_qualifiers`. Used by the
87/// single-table WHERE planner to reject a table qualifier that refers to
88/// neither the table's name nor its alias (e.g. `WHERE wrong.name = ...`)
89/// with a typed error, instead of silently stripping it into a bare field
90/// lookup that would never match any row.
91pub fn find_foreign_qualifier(expr: &ast::Expr, valid_qualifiers: &[&str]) -> Option<String> {
92    match expr {
93        ast::Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
94            let qualifier = normalize_ident(&parts[0]);
95            if valid_qualifiers.contains(&qualifier.as_str()) {
96                None
97            } else {
98                Some(qualifier)
99            }
100        }
101        ast::Expr::BinaryOp { left, right, .. } => find_foreign_qualifier(left, valid_qualifiers)
102            .or_else(|| find_foreign_qualifier(right, valid_qualifiers)),
103        ast::Expr::UnaryOp { expr: inner, .. }
104        | ast::Expr::Nested(inner)
105        | ast::Expr::IsNull(inner)
106        | ast::Expr::IsNotNull(inner) => find_foreign_qualifier(inner, valid_qualifiers),
107        _ => None,
108    }
109}
110
111/// Reject a `table.col` qualifier in `expr` that matches none of
112/// `valid_qualifiers`, mapping it to a typed `UnknownTable` error.
113fn reject_foreign_qualifier(expr: &ast::Expr, valid_qualifiers: &[&str]) -> Result<()> {
114    match find_foreign_qualifier(expr, valid_qualifiers) {
115        Some(bad) => Err(SqlError::UnknownTable { name: bad }),
116        None => Ok(()),
117    }
118}
119
120/// Reject a single qualifier string that matches none of `valid_qualifiers`.
121fn reject_qualifier(qualifier: &str, valid_qualifiers: &[&str]) -> Result<()> {
122    if valid_qualifiers.contains(&qualifier) {
123        Ok(())
124    } else {
125        Err(SqlError::UnknownTable {
126            name: qualifier.to_string(),
127        })
128    }
129}
130
131/// Strip every qualifier in `qualifiers` from column refs in `expr`. Each pass
132/// is a no-op when its qualifier is absent, so passing both a table's name and
133/// its alias collapses `t.col` / `alias.col` to a bare `col`.
134fn strip_table_qualifiers(expr: &ast::Expr, qualifiers: &[&str]) -> ast::Expr {
135    qualifiers
136        .iter()
137        .fold(expr.clone(), |acc, q| strip_table_qualifier(&acc, q))
138}
139
140/// Normalize a SINGLE-TABLE `SELECT` by stripping the (always-redundant) table
141/// qualifier from column refs in the projection, WHERE, and GROUP BY, returning
142/// a rewritten copy. `t.col` / `alias.col` collapse to `col` before the
143/// qualified name can become a literal `"t.col"` field-lookup string that no
144/// stored document ever has (silent zero-row / empty-value bug). A qualifier
145/// that matches neither the table's name nor its alias is a typed error, never
146/// a silent match-nothing. Subquery bodies are intentionally not descended into
147/// (their qualifiers belong to their own tables).
148pub fn strip_single_table_qualifiers(
149    select: &ast::Select,
150    valid_qualifiers: &[&str],
151) -> Result<ast::Select> {
152    let mut out = select.clone();
153
154    for item in &mut out.projection {
155        match item {
156            ast::SelectItem::UnnamedExpr(expr)
157            | ast::SelectItem::ExprWithAlias { expr, .. }
158            | ast::SelectItem::ExprWithAliases { expr, .. } => {
159                reject_foreign_qualifier(expr, valid_qualifiers)?;
160                *expr = strip_table_qualifiers(expr, valid_qualifiers);
161            }
162            ast::SelectItem::QualifiedWildcard(kind, opts) => {
163                if let ast::SelectItemQualifiedWildcardKind::ObjectName(name) = kind {
164                    let qualifier = normalize_object_name_checked(name)?;
165                    reject_qualifier(&qualifier, valid_qualifiers)?;
166                    // `t.*` on the single table is exactly `*`.
167                    let opts = opts.clone();
168                    *item = ast::SelectItem::Wildcard(opts);
169                }
170            }
171            ast::SelectItem::Wildcard(_) => {}
172        }
173    }
174
175    if let Some(expr) = &mut out.selection {
176        reject_foreign_qualifier(expr, valid_qualifiers)?;
177        *expr = strip_table_qualifiers(expr, valid_qualifiers);
178    }
179
180    if let ast::GroupByExpr::Expressions(exprs, _) = &mut out.group_by {
181        for expr in exprs {
182            reject_foreign_qualifier(expr, valid_qualifiers)?;
183            *expr = strip_table_qualifiers(expr, valid_qualifiers);
184        }
185    }
186
187    Ok(out)
188}
189
190/// The qualifiers a single-table query's column references may carry: the
191/// table's name, and its alias when it has one.
192pub(crate) fn single_table_qualifiers(table: &ResolvedTable) -> Vec<&str> {
193    let ref_name = table.ref_name();
194    if ref_name == table.name {
195        vec![table.name.as_str()]
196    } else {
197        vec![table.name.as_str(), ref_name]
198    }
199}
200
201/// Strip `qualifier.` from all compound identifiers in `expr`, then convert
202/// the result to `Vec<Filter>` via `convert_where_to_filters`.
203pub fn strip_and_convert_filters(
204    conjuncts: Vec<ast::Expr>,
205    qualifier: &str,
206    scope: &crate::resolver::columns::TableScope,
207) -> Result<Vec<Filter>> {
208    if conjuncts.is_empty() {
209        return Ok(Vec::new());
210    }
211    let stripped: Vec<ast::Expr> = conjuncts
212        .into_iter()
213        .map(|c| strip_table_qualifier(&c, qualifier))
214        .collect();
215    let rebuilt = rebuild_and_expr(stripped);
216    convert_where_to_filters(&rebuilt, scope)
217}