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