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