Skip to main content

nodedb_sql/resolver/expr/convert/
operators.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Binary, unary, and `ANY` operator conversion.
4
5use sqlparser::ast::{BinaryOperator, Expr, UnaryOperator, Value};
6
7use crate::error::{Result, SqlError};
8use crate::resolver::ColumnScope;
9use crate::resolver::expr::binary_ops::{convert_binary_op, convert_unary_op};
10use crate::types::*;
11
12use super::entry::convert_expr_depth;
13
14pub(super) fn convert_binary(
15    left: &Expr,
16    op: &BinaryOperator,
17    right: &Expr,
18    depth: &mut usize,
19    scope: &ColumnScope<'_>,
20) -> Result<SqlExpr> {
21    // JSON and FTS operators are lowered to function calls before the
22    // generic binary-op path so they are never passed to
23    // convert_binary_op.
24    let json_fn: Option<&str> = match op {
25        BinaryOperator::Arrow => Some("pg_json_get"),
26        BinaryOperator::LongArrow => Some("pg_json_get_text"),
27        BinaryOperator::HashArrow => Some("pg_json_path_get"),
28        BinaryOperator::HashLongArrow => Some("pg_json_path_get_text"),
29        BinaryOperator::AtArrow => Some("pg_json_contains"),
30        BinaryOperator::ArrowAt => Some("pg_json_contained_by"),
31        BinaryOperator::Question => Some("pg_json_has_key"),
32        BinaryOperator::QuestionAnd => Some("pg_json_has_all_keys"),
33        BinaryOperator::QuestionPipe => Some("pg_json_has_any_key"),
34        _ => None,
35    };
36    if let Some(name) = json_fn {
37        return Ok(SqlExpr::Function {
38            name: name.into(),
39            args: vec![
40                convert_expr_depth(left, depth, scope)?,
41                convert_expr_depth(right, depth, scope)?,
42            ],
43            distinct: false,
44        });
45    }
46    // `col @@ query` → pg_fts_match(col, query)
47    if matches!(op, BinaryOperator::AtAt) {
48        let col_expr = convert_expr_depth(left, depth, scope)?;
49        let query_expr = convert_expr_depth(right, depth, scope)?;
50        return Ok(crate::functions::fts_ops::pg_fts_funcs::lower_pg_fts_match(
51            col_expr, query_expr,
52        ));
53    }
54    Ok(SqlExpr::BinaryOp {
55        left: Box::new(convert_expr_depth(left, depth, scope)?),
56        op: convert_binary_op(op)?,
57        right: Box::new(convert_expr_depth(right, depth, scope)?),
58    })
59}
60
61/// A negative integer literal reaches sqlparser as unary minus applied
62/// to a *positive* number, so the most negative `BIGINT` arrives as
63/// `-(9223372036854775808)` — and that operand does not fit an `i64`.
64/// Converting the operand on its own therefore falls back to `Float`
65/// and silently turns an exact integer into an approximate one. Folding
66/// the sign into the literal before parsing keeps the whole `i64` range
67/// exact; anything that still does not fit takes the general path.
68pub(super) fn convert_unary(
69    op: &UnaryOperator,
70    inner: &Expr,
71    depth: &mut usize,
72    scope: &ColumnScope<'_>,
73) -> Result<SqlExpr> {
74    if matches!(op, UnaryOperator::Minus)
75        && let Expr::Value(v) = inner
76        && let Value::Number(n, _) = &v.value
77    {
78        return match format!("-{n}").parse::<i64>() {
79            Ok(i) => Ok(SqlExpr::Literal(SqlValue::Int(i))),
80            Err(_) => Ok(SqlExpr::UnaryOp {
81                op: UnaryOp::Neg,
82                expr: Box::new(convert_expr_depth(inner, depth, scope)?),
83            }),
84        };
85    }
86    Ok(SqlExpr::UnaryOp {
87        op: convert_unary_op(op)?,
88        expr: Box::new(convert_expr_depth(inner, depth, scope)?),
89    })
90}
91
92/// `left = ANY(right)` — desugar into InList over array elements.
93/// When `right` resolves to an ArrayLiteral (or a function call that
94/// the bridge/evaluator will fold to an array), emit InList so the
95/// downstream scan filter path handles it natively.
96pub(super) fn convert_any_op(
97    left: &Expr,
98    compare_op: &BinaryOperator,
99    right: &Expr,
100    depth: &mut usize,
101    scope: &ColumnScope<'_>,
102) -> Result<SqlExpr> {
103    // Only support `=` comparison for now; reject other operators
104    // with a clear, non-AST-leaking message.
105    if !matches!(compare_op, BinaryOperator::Eq) {
106        return Err(SqlError::Unsupported {
107            detail: "ANY operator with non-equality comparison is not supported".into(),
108        });
109    }
110    let left_expr = convert_expr_depth(left, depth, scope)?;
111    let right_expr = convert_expr_depth(right, depth, scope)?;
112    // Expand the right-hand side into a list if it is an array literal;
113    // otherwise wrap as a single-element list so InList still evaluates.
114    let list = match right_expr {
115        SqlExpr::ArrayLiteral(elems) => elems,
116        other => vec![other],
117    };
118    Ok(SqlExpr::InList {
119        expr: Box::new(left_expr),
120        list,
121        negated: false,
122    })
123}
124
125#[cfg(test)]
126mod tests {
127    use crate::resolver::expr::convert::entry::tests::{select_expr_lowered, where_sql_expr};
128    use crate::types::*;
129
130    /// `"col" = 'literal'` — double-quoted identifier on the left, single-quoted
131    /// string literal on the right — must lower to `BinaryOp(Column("col"), Eq,
132    /// Literal(String("literal")))`.  This is the canonical mixed-quotation form
133    /// used in WHERE clauses (e.g. WHERE "userId" = 'alice').
134    #[test]
135    fn double_quoted_col_eq_single_quoted_literal() {
136        let expr = where_sql_expr(r#"SELECT * FROM t WHERE "col" = 'literal'"#);
137        match expr {
138            SqlExpr::BinaryOp { left, right, .. } => {
139                assert!(
140                    matches!(*left, SqlExpr::Column { ref name, .. } if name == "col"),
141                    "left should be Column(col), got {left:?}"
142                );
143                assert!(
144                    matches!(*right, SqlExpr::Literal(SqlValue::String(ref s)) if s == "literal"),
145                    "right should be Literal(String(\"literal\")), got {right:?}"
146                );
147            }
148            other => panic!("expected BinaryOp, got {other:?}"),
149        }
150    }
151
152    /// `"colA" = "colB"` — both sides are double-quoted identifiers; both must
153    /// resolve as column references, not string literals.
154    #[test]
155    fn double_quoted_col_eq_double_quoted_col() {
156        let expr = where_sql_expr(r#"SELECT * FROM t WHERE "colA" = "colB""#);
157        match expr {
158            SqlExpr::BinaryOp { left, right, .. } => {
159                assert!(
160                    matches!(*left, SqlExpr::Column { ref name, .. } if name == "colA"),
161                    "left should be Column(colA), got {left:?}"
162                );
163                assert!(
164                    matches!(*right, SqlExpr::Column { ref name, .. } if name == "colB"),
165                    "right should be Column(colB), got {right:?}"
166                );
167            }
168            other => panic!("expected BinaryOp, got {other:?}"),
169        }
170    }
171
172    // ── JSON operator lowering tests ───────────────────────────────────────
173
174    fn assert_json_fn(sql: &str, expected_fn: &str) {
175        let expr = select_expr_lowered(sql);
176        match expr {
177            SqlExpr::Function { name, args, .. } => {
178                assert_eq!(name, expected_fn, "wrong function name");
179                assert_eq!(args.len(), 2, "expected 2 args");
180            }
181            other => panic!("expected Function, got {other:?}"),
182        }
183    }
184
185    #[test]
186    fn arrow_lowers_to_pg_json_get() {
187        assert_json_fn("SELECT data->'key' FROM t", "pg_json_get");
188    }
189
190    #[test]
191    fn long_arrow_lowers_to_pg_json_get_text() {
192        assert_json_fn("SELECT data->>'key' FROM t", "pg_json_get_text");
193    }
194
195    #[test]
196    fn hash_arrow_lowers_to_pg_json_path_get() {
197        assert_json_fn("SELECT data#>'{a,b}' FROM t", "pg_json_path_get");
198    }
199
200    #[test]
201    fn hash_long_arrow_lowers_to_pg_json_path_get_text() {
202        assert_json_fn("SELECT data#>>'{a,b}' FROM t", "pg_json_path_get_text");
203    }
204
205    #[test]
206    fn at_arrow_lowers_to_pg_json_contains() {
207        assert_json_fn("SELECT data @> '{\"a\":1}' FROM t", "pg_json_contains");
208    }
209
210    #[test]
211    fn arrow_at_lowers_to_pg_json_contained_by() {
212        assert_json_fn("SELECT '{\"a\":1}' <@ data FROM t", "pg_json_contained_by");
213    }
214
215    #[test]
216    fn question_lowers_to_pg_json_has_key() {
217        assert_json_fn("SELECT data ? 'key' FROM t", "pg_json_has_key");
218    }
219
220    #[test]
221    fn question_and_lowers_to_pg_json_has_all_keys() {
222        assert_json_fn(
223            "SELECT data ?& ARRAY['a','b'] FROM t",
224            "pg_json_has_all_keys",
225        );
226    }
227
228    #[test]
229    fn question_pipe_lowers_to_pg_json_has_any_key() {
230        assert_json_fn(
231            "SELECT data ?| ARRAY['a','b'] FROM t",
232            "pg_json_has_any_key",
233        );
234    }
235
236    #[test]
237    fn chained_arrow_lowers_nested() {
238        // data->'a'->'b' → pg_json_get(pg_json_get(data, 'a'), 'b')
239        let expr = select_expr_lowered("SELECT data->'a'->'b' FROM t");
240        match expr {
241            SqlExpr::Function { name, ref args, .. } => {
242                assert_eq!(name, "pg_json_get", "outer fn should be pg_json_get");
243                match &args[0] {
244                    SqlExpr::Function {
245                        name: inner_name, ..
246                    } => {
247                        assert_eq!(inner_name, "pg_json_get", "inner fn should be pg_json_get");
248                    }
249                    other => panic!("expected inner pg_json_get, got {other:?}"),
250                }
251            }
252            other => panic!("expected outer pg_json_get, got {other:?}"),
253        }
254    }
255
256    // ── FTS operator / function lowering tests ────────────────────────────────
257
258    fn where_fn(sql: &str) -> SqlExpr {
259        where_sql_expr(sql)
260    }
261
262    #[test]
263    fn at_at_lowers_to_pg_fts_match() {
264        // col @@ to_tsquery('rust & lang') → pg_fts_match(col, pg_to_tsquery('rust & lang'))
265        let expr = where_fn("SELECT * FROM t WHERE body @@ to_tsquery('rust & lang')");
266        match expr {
267            SqlExpr::Function {
268                ref name, ref args, ..
269            } => {
270                assert_eq!(
271                    name, "pg_fts_match",
272                    "operator @@ should lower to pg_fts_match"
273                );
274                assert_eq!(args.len(), 2, "expected 2 args");
275                match &args[1] {
276                    SqlExpr::Function { name: inner, .. } => {
277                        assert_eq!(inner, "pg_to_tsquery");
278                    }
279                    other => panic!("expected pg_to_tsquery as right arg, got {other:?}"),
280                }
281            }
282            other => panic!("expected pg_fts_match Function, got {other:?}"),
283        }
284    }
285
286    #[test]
287    fn at_at_with_plainto_tsquery() {
288        // col @@ plainto_tsquery('rust lang') → pg_fts_match(col, pg_plainto_tsquery(...))
289        let expr = where_fn("SELECT * FROM t WHERE body @@ plainto_tsquery('rust lang')");
290        match expr {
291            SqlExpr::Function {
292                ref name, ref args, ..
293            } => {
294                assert_eq!(name, "pg_fts_match");
295                match &args[1] {
296                    SqlExpr::Function { name: inner, .. } => {
297                        assert_eq!(inner, "pg_plainto_tsquery");
298                    }
299                    other => panic!("expected pg_plainto_tsquery, got {other:?}"),
300                }
301            }
302            other => panic!("expected pg_fts_match, got {other:?}"),
303        }
304    }
305}