Skip to main content

nodedb_sql/planner/select/
helpers.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared helpers for SELECT planning: projection conversion, WHERE
4//! filter conversion, and AST literal extraction utilities.
5
6use sqlparser::ast;
7
8use crate::error::{Result, SqlError};
9use crate::parser::normalize::{SCHEMA_QUALIFIED_MSG, normalize_ident};
10use crate::planner::cp_projection::{convert_cp_item, cp_projection_scope};
11use crate::planner::predicate_coerce::coerce_predicate_literals;
12use crate::resolver::ColumnScope;
13use crate::resolver::columns::TableScope;
14use crate::resolver::expr::convert_expr;
15use crate::types::*;
16
17/// Output projection carried by a base read plan, used to seed the
18/// `projection` of a search variant rewritten from it. Returns the resolved
19/// SELECT target list for the plans that carry one (`Scan`, `Join`,
20/// `TextSearch`); an empty list for shapes that do not.
21pub(super) fn source_projection(plan: &SqlPlan) -> Vec<Projection> {
22    match plan {
23        SqlPlan::Scan { projection, .. }
24        | SqlPlan::Join { projection, .. }
25        | SqlPlan::TextSearch(TextSearchPlan { projection, .. }) => projection.clone(),
26        _ => Vec::new(),
27    }
28}
29
30/// Convert SELECT projection items.
31///
32/// An item over a relation that calls a sequence accessor becomes
33/// [`Projection::CpComputed`]: the Control Plane evaluates it once per
34/// output row. Every other item resolves against `scope` unchanged, so an
35/// accessor nested where it must not be (an aggregate argument, a subquery)
36/// still refuses through that clause's own scope.
37pub fn convert_projection(
38    items: &[ast::SelectItem],
39    scope: &TableScope,
40) -> Result<Vec<Projection>> {
41    let cp_scope = cp_projection_scope(scope);
42    let scope = ColumnScope::Relations(scope);
43    let mut result = Vec::new();
44    for item in items {
45        match item {
46            ast::SelectItem::UnnamedExpr(expr) => {
47                let alias = format!("{expr}").to_lowercase();
48                if let Some(cp) = convert_cp_item(expr, alias.clone(), cp_scope.as_ref())? {
49                    result.push(cp);
50                    continue;
51                }
52                let sql_expr = convert_expr(expr, &scope)?;
53                match &sql_expr {
54                    SqlExpr::Column { table, name } => {
55                        result.push(Projection::Column(qualified_name(table.as_deref(), name)));
56                    }
57                    SqlExpr::Wildcard => {
58                        result.push(Projection::Star);
59                    }
60                    _ => {
61                        result.push(Projection::Computed {
62                            expr: sql_expr,
63                            alias,
64                        });
65                    }
66                }
67            }
68            ast::SelectItem::ExprWithAlias { expr, alias } => {
69                let alias = normalize_ident(alias);
70                if let Some(cp) = convert_cp_item(expr, alias.clone(), cp_scope.as_ref())? {
71                    result.push(cp);
72                    continue;
73                }
74                let sql_expr = convert_expr(expr, &scope)?;
75                result.push(Projection::Computed {
76                    expr: sql_expr,
77                    alias,
78                });
79            }
80            // `expr AS (a, b)` binds one expression to several output names,
81            // which only has meaning for a dialect that expands a projection
82            // into multiple columns. `Projection` carries a single name, and
83            // NodeDB's PostgreSQL surface has no such expansion, so the
84            // statement is refused rather than silently taking one alias.
85            ast::SelectItem::ExprWithAliases { aliases, .. } => {
86                return Err(SqlError::Unsupported {
87                    detail: format!(
88                        "multi-alias projection ('AS' with {} names) is not supported; \
89                         give the expression a single alias",
90                        aliases.len()
91                    ),
92                });
93            }
94            ast::SelectItem::Wildcard(_) => {
95                result.push(Projection::Star);
96            }
97            ast::SelectItem::QualifiedWildcard(kind, _) => {
98                let table_name = match kind {
99                    ast::SelectItemQualifiedWildcardKind::ObjectName(name) => {
100                        crate::parser::normalize::normalize_object_name_checked(name)?
101                    }
102                    _ => String::new(),
103                };
104                result.push(Projection::QualifiedStar(table_name));
105            }
106        }
107    }
108    Ok(result)
109}
110
111/// Build a qualified column reference (`table.name` or just `name`).
112pub fn qualified_name(table: Option<&str>, name: &str) -> String {
113    table.map_or_else(|| name.to_string(), |table| format!("{table}.{name}"))
114}
115
116/// Convert a WHERE expression into a list of Filter.
117///
118/// The one choke point every predicate passes through: `SELECT ... WHERE`,
119/// `UPDATE` / `DELETE ... WHERE`, a join's post-filter, `HAVING`, a LATERAL
120/// body, and a MERGE `WHEN ... AND` predicate. Literals compared against a
121/// declared instant column are coerced here, so no downstream path sees a
122/// bare integer where an instant is stored.
123pub fn convert_where_to_filters(expr: &ast::Expr, scope: &TableScope) -> Result<Vec<Filter>> {
124    let mut sql_expr = canonicalize_predicate(convert_expr(expr, &ColumnScope::Relations(scope))?);
125    coerce_predicate_literals(&mut sql_expr, scope)?;
126    Ok(vec![Filter {
127        expr: FilterExpr::Expr(sql_expr),
128    }])
129}
130
131/// Canonicalize comparison operand order so a bare column always sits on the
132/// left of a comparison (`column <op> literal`), flipping the operator when a
133/// swap happens (`5 < id` → `id > 5`).
134///
135/// Written literal-first (`'x' = id`), a predicate is logically identical to
136/// its column-first spelling but reaches the planner in a shape only some
137/// extractors recognize: the primary-key point-get rewrite accepts
138/// `column = literal` but not `literal = column`, so the two spellings would
139/// route to different physical operators (index point-lookup vs sequential
140/// scan) that disagree whenever the index and the stored tuples diverge.
141/// Normalizing at this single WHERE→filter choke point guarantees every
142/// downstream extractor — point-get, index lookup, scan-filter fast path —
143/// sees one canonical shape.
144fn canonicalize_predicate(expr: SqlExpr) -> SqlExpr {
145    match expr {
146        SqlExpr::BinaryOp { left, op, right } => {
147            let left = canonicalize_predicate(*left);
148            let right = canonicalize_predicate(*right);
149            // Only swap when the column sits on the right and the left side is
150            // not itself a bare column — the exact shape (`literal = column`)
151            // the point-get / fast-path extractors fail to recognize.
152            if let Some(flipped) = flip_comparison(op)
153                && !matches!(left, SqlExpr::Column { .. })
154                && matches!(right, SqlExpr::Column { .. })
155            {
156                return SqlExpr::BinaryOp {
157                    left: Box::new(right),
158                    op: flipped,
159                    right: Box::new(left),
160                };
161            }
162            SqlExpr::BinaryOp {
163                left: Box::new(left),
164                op,
165                right: Box::new(right),
166            }
167        }
168        SqlExpr::UnaryOp { op, expr } => SqlExpr::UnaryOp {
169            op,
170            expr: Box::new(canonicalize_predicate(*expr)),
171        },
172        other => other,
173    }
174}
175
176/// Map a comparison operator to the operator that preserves meaning when its
177/// two operands are swapped. Returns `None` for non-comparison operators
178/// (arithmetic, logical, string), which are never operand-swapped here.
179fn flip_comparison(op: BinaryOp) -> Option<BinaryOp> {
180    match op {
181        BinaryOp::Eq => Some(BinaryOp::Eq),
182        BinaryOp::Ne => Some(BinaryOp::Ne),
183        BinaryOp::Gt => Some(BinaryOp::Lt),
184        BinaryOp::Ge => Some(BinaryOp::Le),
185        BinaryOp::Lt => Some(BinaryOp::Gt),
186        BinaryOp::Le => Some(BinaryOp::Ge),
187        BinaryOp::Add
188        | BinaryOp::Sub
189        | BinaryOp::Mul
190        | BinaryOp::Div
191        | BinaryOp::Mod
192        | BinaryOp::And
193        | BinaryOp::Or
194        | BinaryOp::Concat => None,
195    }
196}
197
198pub fn extract_func_args(func: &ast::Function) -> Result<Vec<ast::Expr>> {
199    match &func.args {
200        ast::FunctionArguments::List(args) => Ok(args
201            .args
202            .iter()
203            .filter_map(|a| match a {
204                ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => Some(e.clone()),
205                _ => None,
206            })
207            .collect()),
208        _ => Ok(Vec::new()),
209    }
210}
211
212pub(super) fn extract_column_name(expr: &ast::Expr) -> Result<String> {
213    match expr {
214        ast::Expr::Identifier(ident) => Ok(normalize_ident(ident)),
215        ast::Expr::CompoundIdentifier(parts) if parts.len() >= 3 => {
216            let qualified: String = parts
217                .iter()
218                .map(normalize_ident)
219                .collect::<Vec<_>>()
220                .join(".");
221            Err(SqlError::Unsupported {
222                detail: format!(
223                    "schema-qualified column reference '{qualified}': {SCHEMA_QUALIFIED_MSG}"
224                ),
225            })
226        }
227        ast::Expr::CompoundIdentifier(parts) => Ok(parts
228            .iter()
229            .map(normalize_ident)
230            .collect::<Vec<_>>()
231            .join(".")),
232        _ => Err(SqlError::Unsupported {
233            detail: format!("expected column name, got: {expr}"),
234        }),
235    }
236}
237
238pub fn extract_string_literal(expr: &ast::Expr) -> Result<String> {
239    match expr {
240        ast::Expr::Value(v) => match &v.value {
241            ast::Value::SingleQuotedString(s) => Ok(s.clone()),
242            _ => Err(SqlError::Unsupported {
243                detail: format!("expected string literal, got: {expr}"),
244            }),
245        },
246        _ => Err(SqlError::Unsupported {
247            detail: format!("expected string literal, got: {expr}"),
248        }),
249    }
250}
251
252pub fn extract_float(expr: &ast::Expr) -> Result<f64> {
253    match expr {
254        ast::Expr::Value(v) => match &v.value {
255            ast::Value::Number(n, _) => n.parse::<f64>().map_err(|_| SqlError::TypeMismatch {
256                detail: format!("expected number: {n}"),
257            }),
258            _ => Err(SqlError::TypeMismatch {
259                detail: format!("expected number, got: {expr}"),
260            }),
261        },
262        // Handle negative numbers: -73.9855 is parsed as UnaryOp { Minus, 73.9855 }
263        ast::Expr::UnaryOp {
264            op: ast::UnaryOperator::Minus,
265            expr: inner,
266        } => extract_float(inner).map(|f| -f),
267        _ => Err(SqlError::TypeMismatch {
268            detail: format!("expected number, got: {expr}"),
269        }),
270    }
271}
272
273/// Map a vector distance function name to its `DistanceMetric`.
274///
275/// `vector_distance` (and the rewritten `<->` operator) → L2;
276/// `vector_cosine_distance` (and `<=>`) → Cosine;
277/// `vector_neg_inner_product` (and `<#>`) → InnerProduct.
278/// Unknown names default to L2 — callers must gate on a `VectorSearch`
279/// search-trigger before invoking this so unknown names cannot leak in.
280pub(super) fn metric_from_func_name(name: &str) -> DistanceMetric {
281    if name.eq_ignore_ascii_case("vector_cosine_distance") {
282        DistanceMetric::Cosine
283    } else if name.eq_ignore_ascii_case("vector_neg_inner_product") {
284        DistanceMetric::InnerProduct
285    } else {
286        DistanceMetric::L2
287    }
288}
289
290/// Extract a float array from ARRAY[...], make_array(...), or a JSON-array
291/// string literal like `'[1.0, 0.5, 0.0]'`.
292pub(super) fn extract_float_array(expr: &ast::Expr) -> Result<Vec<f32>> {
293    match expr {
294        ast::Expr::Array(ast::Array { elem, .. }) => elem
295            .iter()
296            .map(|e| extract_float(e).map(|f| f as f32))
297            .collect(),
298        ast::Expr::Function(func) => {
299            let name = func
300                .name
301                .0
302                .iter()
303                .map(|p| match p {
304                    ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
305                    _ => String::new(),
306                })
307                .collect::<Vec<_>>()
308                .join(".");
309            if name == "make_array" || name == "array" {
310                let args = extract_func_args(func)?;
311                args.iter()
312                    .map(|e| extract_float(e).map(|f| f as f32))
313                    .collect()
314            } else {
315                Err(SqlError::Unsupported {
316                    detail: format!("expected array, got function: {name}"),
317                })
318            }
319        }
320        // Accept JSON-array string literals: `'[1.0, 0.5, 0.0]'`.
321        // This is the canonical pgvector-compatible form for embedding vectors
322        // passed as SQL string literals.
323        ast::Expr::Value(v) => {
324            let s = match &v.value {
325                sqlparser::ast::Value::SingleQuotedString(s) => s.as_str(),
326                sqlparser::ast::Value::DoubleQuotedString(s) => s.as_str(),
327                _ => {
328                    return Err(SqlError::Unsupported {
329                        detail: format!("expected array literal, got: {expr}"),
330                    });
331                }
332            };
333            let trimmed = s.trim();
334            if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
335                return Err(SqlError::Unsupported {
336                    detail: format!("expected JSON array string, got: {s:?}"),
337                });
338            }
339            let inner = &trimmed[1..trimmed.len() - 1];
340            if inner.trim().is_empty() {
341                return Ok(Vec::new());
342            }
343            inner
344                .split(',')
345                .map(|part| {
346                    part.trim()
347                        .parse::<f32>()
348                        .map_err(|_| SqlError::Unsupported {
349                            detail: format!("cannot parse float from array element: {part:?}"),
350                        })
351                })
352                .collect()
353        }
354        _ => Err(SqlError::Unsupported {
355            detail: format!("expected array literal, got: {expr}"),
356        }),
357    }
358}