Skip to main content

corium_query/
builtins.rs

1//! The native predicate/function set for query call clauses.
2//!
3//! This is resolution step (1) from `docs/design/query-engine.md`; names
4//! outside it fall through to the sandboxed cljrs resolution seam (step 2,
5//! the [`crate::ExternCall`] hook wired by `corium-cljrs`). `tuple` and
6//! `untuple` are deliberately absent until tuple value types land
7//! (deferred by ADR-0009).
8
9use std::cmp::Ordering;
10
11use corium_core::{TotalF64, Value};
12
13use crate::QueryError;
14
15/// Result of evaluating a native call.
16#[derive(Clone, Debug, PartialEq)]
17pub enum CallResult {
18    /// A predicate outcome.
19    Test(bool),
20    /// A scalar value.
21    Scalar(Value),
22    /// A collection of values.
23    Coll(Vec<Value>),
24    /// A relation of tuples.
25    Rel(Vec<Vec<Value>>),
26}
27
28fn type_error(name: &str, args: &[Value]) -> QueryError {
29    QueryError::Type(format!("{name} cannot be applied to {args:?}"))
30}
31
32fn arity_error(name: &str) -> QueryError {
33    QueryError::Arity(format!("wrong number of arguments to {name}"))
34}
35
36/// Numeric view of a value for arithmetic and comparisons.
37enum Num {
38    Long(i64),
39    Double(f64),
40}
41
42fn as_num(value: &Value) -> Option<Num> {
43    match value {
44        Value::Long(v) | Value::Instant(v) => Some(Num::Long(*v)),
45        Value::Double(v) => Some(Num::Double(v.0)),
46        // Entity ids compare as their numeric representation, as in Datomic.
47        Value::Ref(e) => i64::try_from(e.raw()).ok().map(Num::Long),
48        _ => None,
49    }
50}
51
52/// Total comparison across the value types queries can meet, with numeric
53/// coercion between longs, doubles, instants, and entity ids.
54///
55/// # Errors
56/// Returns [`QueryError::Type`] for incomparable operand types.
57pub fn compare(left: &Value, right: &Value) -> Result<Ordering, QueryError> {
58    if let (Some(l), Some(r)) = (as_num(left), as_num(right)) {
59        return Ok(match (l, r) {
60            (Num::Long(l), Num::Long(r)) => l.cmp(&r),
61            (Num::Long(l), Num::Double(r)) => TotalF64(to_f64(l)).cmp(&TotalF64(r)),
62            (Num::Double(l), Num::Long(r)) => TotalF64(l).cmp(&TotalF64(to_f64(r))),
63            (Num::Double(l), Num::Double(r)) => TotalF64(l).cmp(&TotalF64(r)),
64        });
65    }
66    match (left, right) {
67        (Value::Str(l), Value::Str(r)) => Ok(l.cmp(r)),
68        (Value::Bool(l), Value::Bool(r)) => Ok(l.cmp(r)),
69        (Value::Keyword(l), Value::Keyword(r)) => Ok(l.cmp(r)),
70        (Value::Uuid(l), Value::Uuid(r)) => Ok(l.cmp(r)),
71        (Value::Bytes(l), Value::Bytes(r)) => Ok(l.cmp(r)),
72        _ => Err(QueryError::Type(format!(
73            "cannot compare {left:?} with {right:?}"
74        ))),
75    }
76}
77
78/// Loose equality used by `=`/`!=`: numeric coercion, no error on
79/// cross-type operands (they are simply unequal).
80#[must_use]
81pub fn loose_eq(left: &Value, right: &Value) -> bool {
82    compare(left, right).is_ok_and(Ordering::is_eq)
83}
84
85#[allow(clippy::cast_precision_loss)]
86fn to_f64(v: i64) -> f64 {
87    v as f64
88}
89
90fn fold_numeric(
91    name: &str,
92    args: &[Value],
93    long_op: impl Fn(i64, i64) -> Option<i64>,
94    double_op: impl Fn(f64, f64) -> f64,
95) -> Result<Value, QueryError> {
96    let mut iter = args.iter();
97    let first = iter.next().ok_or_else(|| arity_error(name))?;
98    let mut acc = match first {
99        Value::Long(v) => Num::Long(*v),
100        Value::Double(v) => Num::Double(v.0),
101        _ => return Err(type_error(name, args)),
102    };
103    for arg in iter {
104        let rhs = match arg {
105            Value::Long(v) => Num::Long(*v),
106            Value::Double(v) => Num::Double(v.0),
107            _ => return Err(type_error(name, args)),
108        };
109        acc = match (acc, rhs) {
110            (Num::Long(l), Num::Long(r)) => match long_op(l, r) {
111                Some(v) => Num::Long(v),
112                None => return Err(QueryError::Type(format!("{name} overflowed"))),
113            },
114            (l, r) => {
115                let l = match l {
116                    Num::Long(v) => to_f64(v),
117                    Num::Double(v) => v,
118                };
119                let r = match r {
120                    Num::Long(v) => to_f64(v),
121                    Num::Double(v) => v,
122                };
123                Num::Double(double_op(l, r))
124            }
125        };
126    }
127    Ok(match acc {
128        Num::Long(v) => Value::Long(v),
129        Num::Double(v) => Value::Double(TotalF64(v)),
130    })
131}
132
133fn string_arg<'a>(name: &str, args: &'a [Value], index: usize) -> Result<&'a str, QueryError> {
134    match args.get(index) {
135        Some(Value::Str(s)) => Ok(s),
136        _ => Err(type_error(name, args)),
137    }
138}
139
140fn long_arg(name: &str, args: &[Value], index: usize) -> Result<i64, QueryError> {
141    match args.get(index) {
142        Some(Value::Long(v)) => Ok(*v),
143        _ => Err(type_error(name, args)),
144    }
145}
146
147fn one_long(name: &str, args: &[Value]) -> Result<i64, QueryError> {
148    if args.len() != 1 {
149        return Err(arity_error(name));
150    }
151    long_arg(name, args, 0)
152}
153
154fn two_longs(name: &str, args: &[Value]) -> Result<(i64, i64), QueryError> {
155    if args.len() != 2 {
156        return Err(arity_error(name));
157    }
158    Ok((long_arg(name, args, 0)?, long_arg(name, args, 1)?))
159}
160
161fn chain_compare(
162    args: &[Value],
163    accept: impl Fn(Ordering) -> bool,
164) -> Result<CallResult, QueryError> {
165    for window in args.windows(2) {
166        if !accept(compare(&window[0], &window[1])?) {
167            return Ok(CallResult::Test(false));
168        }
169    }
170    Ok(CallResult::Test(true))
171}
172
173/// Renders a value the way `str` concatenation sees it.
174#[must_use]
175pub fn value_to_display(value: &Value) -> String {
176    match value {
177        Value::Bool(v) => v.to_string(),
178        Value::Long(v) | Value::Instant(v) => v.to_string(),
179        Value::Double(v) => v.0.to_string(),
180        Value::Str(s) => s.to_string(),
181        Value::Uuid(v) => format!("{v:032x}"),
182        Value::Keyword(id) => format!(":kw{id}"),
183        Value::Ref(e) => e.raw().to_string(),
184        Value::Bytes(b) => format!("{b:02x?}"),
185    }
186}
187
188/// Whether `name` is in the native call set (excluding the db-context
189/// builtins `get-else`, `missing?`, and `ground`, which the executor
190/// handles itself).
191#[must_use]
192pub fn is_native(name: &str) -> bool {
193    matches!(
194        name,
195        "<" | "<="
196            | ">"
197            | ">="
198            | "="
199            | "=="
200            | "!="
201            | "not="
202            | "even?"
203            | "odd?"
204            | "zero?"
205            | "pos?"
206            | "neg?"
207            | "true?"
208            | "false?"
209            | "starts-with?"
210            | "ends-with?"
211            | "includes?"
212            | "+"
213            | "-"
214            | "*"
215            | "/"
216            | "quot"
217            | "rem"
218            | "mod"
219            | "inc"
220            | "dec"
221            | "abs"
222            | "min"
223            | "max"
224            | "str"
225            | "count"
226            | "subs"
227            | "upper-case"
228            | "lower-case"
229            | "identity"
230    )
231}
232
233/// Evaluates a native call over fully bound arguments.
234///
235/// # Errors
236/// Returns [`QueryError`] for unknown names, wrong arity, or operand types
237/// the operation does not support.
238#[allow(clippy::too_many_lines)]
239pub fn call(name: &str, args: &[Value]) -> Result<CallResult, QueryError> {
240    match name {
241        "<" => chain_compare(args, Ordering::is_lt),
242        "<=" => chain_compare(args, Ordering::is_le),
243        ">" => chain_compare(args, Ordering::is_gt),
244        ">=" => chain_compare(args, Ordering::is_ge),
245        "=" | "==" => Ok(CallResult::Test(
246            args.windows(2).all(|w| loose_eq(&w[0], &w[1])),
247        )),
248        "!=" | "not=" => Ok(CallResult::Test(
249            args.windows(2).any(|w| !loose_eq(&w[0], &w[1])),
250        )),
251        "even?" => Ok(CallResult::Test(one_long(name, args)? % 2 == 0)),
252        "odd?" => Ok(CallResult::Test(one_long(name, args)? % 2 != 0)),
253        "zero?" => Ok(CallResult::Test(one_long(name, args)? == 0)),
254        "pos?" => Ok(CallResult::Test(one_long(name, args)? > 0)),
255        "neg?" => Ok(CallResult::Test(one_long(name, args)? < 0)),
256        "true?" => Ok(CallResult::Test(args == [Value::Bool(true)])),
257        "false?" => Ok(CallResult::Test(args == [Value::Bool(false)])),
258        "starts-with?" => Ok(CallResult::Test(
259            string_arg(name, args, 0)?.starts_with(string_arg(name, args, 1)?),
260        )),
261        "ends-with?" => Ok(CallResult::Test(
262            string_arg(name, args, 0)?.ends_with(string_arg(name, args, 1)?),
263        )),
264        "includes?" => Ok(CallResult::Test(
265            string_arg(name, args, 0)?.contains(string_arg(name, args, 1)?),
266        )),
267        "+" => Ok(CallResult::Scalar(fold_numeric(
268            name,
269            args,
270            i64::checked_add,
271            |l, r| l + r,
272        )?)),
273        "-" => {
274            if args.len() == 1 {
275                return call("-", &[Value::Long(0), args[0].clone()]);
276            }
277            Ok(CallResult::Scalar(fold_numeric(
278                name,
279                args,
280                i64::checked_sub,
281                |l, r| l - r,
282            )?))
283        }
284        "*" => Ok(CallResult::Scalar(fold_numeric(
285            name,
286            args,
287            i64::checked_mul,
288            |l, r| l * r,
289        )?)),
290        "/" => {
291            // Long division stays exact when it divides evenly, otherwise
292            // falls to a double (there is no ratio type).
293            if args.len() != 2 {
294                return Err(arity_error(name));
295            }
296            match (&args[0], &args[1]) {
297                (Value::Long(_), Value::Long(0)) => {
298                    Err(QueryError::Type("division by zero".into()))
299                }
300                (Value::Long(l), Value::Long(r)) if l % r == 0 => {
301                    Ok(CallResult::Scalar(Value::Long(l / r)))
302                }
303                (left, right) => {
304                    let (Some(l), Some(r)) = (as_num(left), as_num(right)) else {
305                        return Err(type_error(name, args));
306                    };
307                    let l = match l {
308                        Num::Long(v) => to_f64(v),
309                        Num::Double(v) => v,
310                    };
311                    let r = match r {
312                        Num::Long(v) => to_f64(v),
313                        Num::Double(v) => v,
314                    };
315                    Ok(CallResult::Scalar(Value::Double(TotalF64(l / r))))
316                }
317            }
318        }
319        "quot" => {
320            let (l, r) = two_longs(name, args)?;
321            if r == 0 {
322                return Err(QueryError::Type("division by zero".into()));
323            }
324            Ok(CallResult::Scalar(Value::Long(l / r)))
325        }
326        "rem" => {
327            let (l, r) = two_longs(name, args)?;
328            if r == 0 {
329                return Err(QueryError::Type("division by zero".into()));
330            }
331            Ok(CallResult::Scalar(Value::Long(l % r)))
332        }
333        "mod" => {
334            let (l, r) = two_longs(name, args)?;
335            if r == 0 {
336                return Err(QueryError::Type("division by zero".into()));
337            }
338            Ok(CallResult::Scalar(Value::Long(l.rem_euclid(r))))
339        }
340        "inc" => Ok(CallResult::Scalar(Value::Long(
341            one_long(name, args)?
342                .checked_add(1)
343                .ok_or_else(|| QueryError::Type("inc overflowed".into()))?,
344        ))),
345        "dec" => Ok(CallResult::Scalar(Value::Long(
346            one_long(name, args)?
347                .checked_sub(1)
348                .ok_or_else(|| QueryError::Type("dec overflowed".into()))?,
349        ))),
350        "abs" => match args {
351            [Value::Long(v)] => Ok(CallResult::Scalar(Value::Long(v.abs()))),
352            [Value::Double(v)] => Ok(CallResult::Scalar(Value::Double(TotalF64(v.0.abs())))),
353            _ => Err(type_error(name, args)),
354        },
355        "min" | "max" => {
356            if args.is_empty() {
357                return Err(arity_error(name));
358            }
359            let mut best = args[0].clone();
360            for arg in &args[1..] {
361                let ordering = compare(arg, &best)?;
362                let better = if name == "min" {
363                    ordering.is_lt()
364                } else {
365                    ordering.is_gt()
366                };
367                if better {
368                    best = arg.clone();
369                }
370            }
371            Ok(CallResult::Scalar(best))
372        }
373        "str" => Ok(CallResult::Scalar(Value::Str(
374            args.iter().map(value_to_display).collect::<String>().into(),
375        ))),
376        "count" => Ok(CallResult::Scalar(Value::Long(
377            i64::try_from(string_arg(name, args, 0)?.chars().count())
378                .map_err(|_| QueryError::Type("count overflowed".into()))?,
379        ))),
380        "subs" => {
381            let text = string_arg(name, args, 0)?;
382            let start =
383                usize::try_from(long_arg(name, args, 1)?).map_err(|_| type_error(name, args))?;
384            let end = match args.get(2) {
385                Some(Value::Long(v)) => usize::try_from(*v).map_err(|_| type_error(name, args))?,
386                None => text.chars().count(),
387                Some(_) => return Err(type_error(name, args)),
388            };
389            let out: String = text
390                .chars()
391                .skip(start)
392                .take(end.saturating_sub(start))
393                .collect();
394            Ok(CallResult::Scalar(Value::Str(out.into())))
395        }
396        "upper-case" => Ok(CallResult::Scalar(Value::Str(
397            string_arg(name, args, 0)?.to_uppercase().into(),
398        ))),
399        "lower-case" => Ok(CallResult::Scalar(Value::Str(
400            string_arg(name, args, 0)?.to_lowercase().into(),
401        ))),
402        "identity" => match args {
403            [value] => Ok(CallResult::Scalar(value.clone())),
404            _ => Err(arity_error(name)),
405        },
406        _ => Err(QueryError::Unsupported(format!(
407            "unknown function or predicate {name}"
408        ))),
409    }
410}