Skip to main content

nodedb_query/functions/
eval.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Scalar function evaluation dispatch across per-family sub-modules.
4
5use nodedb_types::Value;
6
7use crate::expr::EvalError;
8
9use super::{
10    array, conditional, datetime, fts, id, json, math, string, system, text_chunk, types, vector,
11};
12
13/// Evaluate a scalar function call.
14///
15/// A `NULL` argument gives `Ok(Value::Null)` (SQL NULL propagation). These
16/// calls fail instead:
17/// - `mod`'s zero-modulus arm (`math::try_eval`) returns
18///   `Err(EvalError::DivisionByZero)`;
19/// - a vector distance over operands of different dimensions or over a
20///   non-vector operand (`vector::try_eval`);
21/// - a document function given a malformed JSONPath (`json::try_eval`);
22/// - a name no family module implements returns
23///   `Err(EvalError::UnknownFunction)`, never a silent `NULL`.
24///
25/// The fallible families (`math`, `vector`, `json`) return
26/// `Option<Result<Value, EvalError>>`. The rest stay `Option<Value>`-shaped
27/// and are wrapped in `Ok` at this dispatch boundary.
28pub fn eval_function(name: &str, args: &[Value]) -> Result<Value, EvalError> {
29    if let Some(v) = string::try_eval(name, args) {
30        return Ok(v);
31    }
32    if let Some(r) = math::try_eval(name, args) {
33        return r;
34    }
35    if let Some(v) = conditional::try_eval(name, args) {
36        return Ok(v);
37    }
38    if let Some(v) = id::try_eval(name, args) {
39        return Ok(v);
40    }
41    if let Some(v) = datetime::try_eval(name, args) {
42        return Ok(v);
43    }
44    if let Some(r) = json::try_eval(name, args) {
45        return r;
46    }
47    if let Some(v) = types::try_eval(name, args) {
48        return Ok(v);
49    }
50    if let Some(v) = array::try_eval(name, args) {
51        return Ok(v);
52    }
53    if let Some(v) = fts::try_eval_fts(name, args) {
54        return Ok(v);
55    }
56    if let Some(v) = system::try_eval(name, args) {
57        return Ok(v);
58    }
59    if let Some(r) = vector::try_eval(name, args) {
60        return r;
61    }
62    if let Some(v) = text_chunk::try_eval(name, args) {
63        return Ok(v);
64    }
65    // Geo / Spatial functions — delegated to geo_functions module.
66    crate::geo_functions::eval_geo_function(name, args).ok_or_else(|| EvalError::UnknownFunction {
67        name: name.to_owned(),
68    })
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::expr::SqlExpr;
75
76    fn eval_fn(name: &str, args: Vec<Value>) -> Value {
77        eval_function(name, &args).unwrap()
78    }
79
80    #[test]
81    fn mod_by_zero_errors() {
82        let err = eval_function("mod", &[Value::Integer(5), Value::Integer(0)]).unwrap_err();
83        assert_eq!(err, EvalError::DivisionByZero);
84    }
85
86    #[test]
87    fn an_unknown_function_is_an_error_not_null() {
88        let err = eval_function("no_such_function", &[Value::Integer(1)]).unwrap_err();
89        assert_eq!(
90            err,
91            EvalError::UnknownFunction {
92                name: "no_such_function".into()
93            }
94        );
95    }
96
97    /// Registered SQL scalars with a per-row meaning dispatch to a real
98    /// evaluator, never to the unknown-function error.
99    #[test]
100    fn registered_row_scalars_have_evaluators() {
101        let doc = Value::Object(
102            [(
103                "tags".to_string(),
104                Value::Array(vec![Value::String("a".into())]),
105            )]
106            .into_iter()
107            .collect(),
108        );
109        let path = Value::String("$.tags[0]".into());
110        let vector = Value::Array(vec![Value::Float(1.0), Value::Float(0.0)]);
111        let calls: [(&str, Vec<Value>); 8] = [
112            ("doc_get", vec![doc.clone(), path.clone()]),
113            ("doc_exists", vec![doc.clone(), path.clone()]),
114            (
115                "doc_array_contains",
116                vec![
117                    doc.clone(),
118                    Value::String("$.tags".into()),
119                    Value::String("a".into()),
120                ],
121            ),
122            ("nav", vec![doc, path]),
123            ("vector_distance", vec![vector.clone(), vector.clone()]),
124            (
125                "vector_cosine_distance",
126                vec![vector.clone(), vector.clone()],
127            ),
128            ("vector_neg_inner_product", vec![vector.clone(), vector]),
129            (
130                "ndb_chunk_text",
131                vec![Value::String("abc".into()), Value::Integer(2)],
132            ),
133        ];
134        for (name, args) in calls {
135            let value = eval_function(name, &args)
136                .unwrap_or_else(|e| panic!("{name}() must evaluate, got {e}"));
137            assert_ne!(value, Value::Null, "{name}() gave NULL");
138        }
139    }
140
141    fn text(s: &str) -> Value {
142        Value::String(s.into())
143    }
144
145    #[test]
146    fn like_matches_percent_and_underscore() {
147        assert_eq!(
148            eval_fn("like", vec![text("alice"), text("a%")]),
149            Value::Bool(true)
150        );
151        assert_eq!(
152            eval_fn("like", vec![text("alice"), text("a_ice")]),
153            Value::Bool(true)
154        );
155        assert_eq!(
156            eval_fn("like", vec![text("alice"), text("b%")]),
157            Value::Bool(false)
158        );
159        assert_eq!(
160            eval_fn("like", vec![text("Alice"), text("a%")]),
161            Value::Bool(false)
162        );
163    }
164
165    #[test]
166    fn like_honours_the_escape_character() {
167        assert_eq!(
168            eval_fn("like", vec![text("50%"), text("50\\%")]),
169            Value::Bool(true)
170        );
171        assert_eq!(
172            eval_fn("like", vec![text("500"), text("50\\%")]),
173            Value::Bool(false)
174        );
175        assert_eq!(
176            eval_fn("like", vec![text("50%"), text("50!%"), text("!")]),
177            Value::Bool(true)
178        );
179        assert_eq!(
180            eval_fn("like", vec![text("a\\b"), text("a\\b"), text("")]),
181            Value::Bool(true)
182        );
183    }
184
185    #[test]
186    fn like_with_a_null_operand_is_null() {
187        assert_eq!(eval_fn("like", vec![Value::Null, text("a%")]), Value::Null);
188        assert_eq!(eval_fn("ilike", vec![text("a"), Value::Null]), Value::Null);
189    }
190
191    #[test]
192    fn ilike_folds_unicode_case() {
193        assert_eq!(
194            eval_fn("ilike", vec![text("ÉCOLE"), text("éc%")]),
195            Value::Bool(true)
196        );
197        assert_eq!(
198            eval_fn("ilike", vec![text("Straße"), text("STRA%")]),
199            Value::Bool(true)
200        );
201        assert_eq!(
202            eval_fn("like", vec![text("ÉCOLE"), text("éc%")]),
203            Value::Bool(false)
204        );
205    }
206
207    #[test]
208    fn not_like_negates_the_call() {
209        let expr = SqlExpr::Negate(Box::new(SqlExpr::Function {
210            name: "like".into(),
211            args: vec![SqlExpr::Literal(text("bob")), SqlExpr::Literal(text("a%"))],
212        }));
213        assert_eq!(expr.eval(&Value::Null).unwrap(), Value::Bool(true));
214    }
215
216    #[test]
217    fn make_array_keeps_every_evaluated_element() {
218        assert_eq!(
219            eval_fn(
220                "make_array",
221                vec![Value::Integer(5), Value::Null, text("x")]
222            ),
223            Value::Array(vec![Value::Integer(5), Value::Null, text("x")])
224        );
225    }
226
227    #[test]
228    fn upper() {
229        assert_eq!(
230            eval_fn("upper", vec![Value::String("hello".into())]),
231            Value::String("HELLO".into())
232        );
233    }
234
235    #[test]
236    fn upper_null_propagation() {
237        assert_eq!(eval_fn("upper", vec![Value::Null]), Value::Null);
238    }
239
240    #[test]
241    fn substring() {
242        assert_eq!(
243            eval_fn(
244                "substr",
245                vec![
246                    Value::String("hello".into()),
247                    Value::Integer(2),
248                    Value::Integer(3)
249                ]
250            ),
251            Value::String("ell".into())
252        );
253    }
254
255    #[test]
256    fn round_with_decimals() {
257        assert_eq!(
258            eval_fn("round", vec![Value::Float(3.15159), Value::Integer(2)]),
259            Value::Float(3.15)
260        );
261    }
262
263    #[test]
264    fn typeof_int() {
265        assert_eq!(
266            eval_fn("typeof", vec![Value::Integer(42)]),
267            Value::String("int".into())
268        );
269    }
270
271    #[test]
272    fn function_via_expr() {
273        let expr = SqlExpr::Function {
274            name: "upper".into(),
275            args: vec![SqlExpr::Column("name".into())],
276        };
277        let doc = Value::Object(
278            [("name".to_string(), Value::String("alice".into()))]
279                .into_iter()
280                .collect(),
281        );
282        assert_eq!(expr.eval(&doc).unwrap(), Value::String("ALICE".into()));
283    }
284
285    #[test]
286    fn geo_geohash_encode() {
287        let result = eval_fn(
288            "geo_geohash",
289            vec![
290                Value::Float(-73.9857),
291                Value::Float(40.758),
292                Value::Integer(6),
293            ],
294        );
295        let hash = result.as_str().unwrap();
296        assert_eq!(hash.len(), 6);
297        assert!(hash.starts_with("dr5ru"), "got {hash}");
298    }
299
300    #[test]
301    fn geo_geohash_decode() {
302        let hash = eval_fn(
303            "geo_geohash",
304            vec![Value::Float(0.0), Value::Float(0.0), Value::Integer(6)],
305        );
306        let result = eval_fn("geo_geohash_decode", vec![hash]);
307        assert!(!result.is_null());
308        assert!(result.get("min_lng").is_some());
309    }
310
311    #[test]
312    fn geo_geohash_neighbors_returns_8() {
313        let hash = eval_fn(
314            "geo_geohash",
315            vec![Value::Float(10.0), Value::Float(50.0), Value::Integer(6)],
316        );
317        let result = eval_fn("geo_geohash_neighbors", vec![hash]);
318        let arr = result.as_array().unwrap();
319        assert_eq!(arr.len(), 8);
320    }
321
322    fn point_value(lng: f64, lat: f64) -> Value {
323        let geom = nodedb_types::geometry::Geometry::point(lng, lat);
324        Value::Geometry(geom)
325    }
326
327    fn square_value() -> Value {
328        let geom = nodedb_types::geometry::Geometry::polygon(vec![vec![
329            [0.0, 0.0],
330            [10.0, 0.0],
331            [10.0, 10.0],
332            [0.0, 10.0],
333            [0.0, 0.0],
334        ]]);
335        Value::Geometry(geom)
336    }
337
338    #[test]
339    fn st_contains_sql() {
340        let result = eval_fn("st_contains", vec![square_value(), point_value(5.0, 5.0)]);
341        assert_eq!(result, Value::Bool(true));
342    }
343
344    #[test]
345    fn st_intersects_sql() {
346        let result = eval_fn("st_intersects", vec![square_value(), point_value(5.0, 0.0)]);
347        assert_eq!(result, Value::Bool(true));
348    }
349
350    #[test]
351    fn st_distance_sql() {
352        let result = eval_fn(
353            "st_distance",
354            vec![point_value(0.0, 0.0), point_value(0.0, 1.0)],
355        );
356        let d = result.as_f64().unwrap();
357        assert!((d - 111_195.0).abs() < 500.0, "got {d}");
358    }
359
360    #[test]
361    fn st_dwithin_sql() {
362        let result = eval_fn(
363            "st_dwithin",
364            vec![
365                point_value(0.0, 0.0),
366                point_value(0.001, 0.0),
367                Value::Float(200.0),
368            ],
369        );
370        assert_eq!(result, Value::Bool(true));
371    }
372
373    #[test]
374    fn st_buffer_sql() {
375        let result = eval_fn(
376            "st_buffer",
377            vec![
378                point_value(0.0, 0.0),
379                Value::Float(1000.0),
380                Value::Integer(8),
381            ],
382        );
383        // Result should be a Geometry (Polygon)
384        assert!(result.as_geometry().is_some());
385    }
386
387    #[test]
388    fn st_envelope_sql() {
389        let result = eval_fn("st_envelope", vec![square_value()]);
390        assert!(result.as_geometry().is_some());
391    }
392
393    #[test]
394    fn geo_length_sql() {
395        let line = Value::Geometry(nodedb_types::geometry::Geometry::line_string(vec![
396            [0.0, 0.0],
397            [0.0, 1.0],
398        ]));
399        let result = eval_fn("geo_length", vec![line]);
400        let d = result.as_f64().unwrap();
401        assert!((d - 111_195.0).abs() < 500.0, "got {d}");
402    }
403
404    #[test]
405    fn geo_x_y() {
406        assert_eq!(
407            eval_fn("geo_x", vec![point_value(5.0, 10.0)])
408                .as_f64()
409                .unwrap(),
410            5.0
411        );
412        assert_eq!(
413            eval_fn("geo_y", vec![point_value(5.0, 10.0)])
414                .as_f64()
415                .unwrap(),
416            10.0
417        );
418    }
419
420    #[test]
421    fn geo_type_sql() {
422        assert_eq!(
423            eval_fn("geo_type", vec![point_value(0.0, 0.0)]),
424            Value::String("Point".into())
425        );
426        assert_eq!(
427            eval_fn("geo_type", vec![square_value()]),
428            Value::String("Polygon".into())
429        );
430    }
431
432    #[test]
433    fn geo_num_points_sql() {
434        assert_eq!(
435            eval_fn("geo_num_points", vec![point_value(0.0, 0.0)]),
436            Value::Integer(1)
437        );
438        assert_eq!(
439            eval_fn("geo_num_points", vec![square_value()]),
440            Value::Integer(5)
441        );
442    }
443
444    #[test]
445    fn geo_is_valid_sql() {
446        assert_eq!(
447            eval_fn("geo_is_valid", vec![square_value()]),
448            Value::Bool(true)
449        );
450    }
451
452    #[test]
453    fn geo_as_wkt_sql() {
454        let result = eval_fn("geo_as_wkt", vec![point_value(5.0, 10.0)]);
455        assert_eq!(result, Value::String("POINT(5 10)".into()));
456    }
457
458    #[test]
459    fn geo_from_wkt_sql() {
460        let result = eval_fn("geo_from_wkt", vec![Value::String("POINT(5 10)".into())]);
461        assert!(result.as_geometry().is_some());
462    }
463
464    #[test]
465    fn geo_circle_sql() {
466        let result = eval_fn(
467            "geo_circle",
468            vec![
469                Value::Float(0.0),
470                Value::Float(0.0),
471                Value::Float(1000.0),
472                Value::Integer(16),
473            ],
474        );
475        assert!(result.as_geometry().is_some());
476    }
477
478    #[test]
479    fn geo_bbox_sql() {
480        let result = eval_fn(
481            "geo_bbox",
482            vec![
483                Value::Float(0.0),
484                Value::Float(0.0),
485                Value::Float(10.0),
486                Value::Float(10.0),
487            ],
488        );
489        assert!(result.as_geometry().is_some());
490    }
491
492    #[test]
493    fn version_returns_postgres_compatible_string() {
494        match eval_fn("version", vec![]) {
495            Value::String(s) => assert!(s.contains("PostgreSQL")),
496            other => panic!("expected Value::String, got {other:?}"),
497        }
498    }
499}