Skip to main content

kimberlite_query/
expression.rs

1//! Scalar expression evaluator (ROADMAP v0.5.0 item A).
2//!
3//! A row → [`Value`] evaluator for the SELECT-projection scalar functions
4//! the Phase 1–9 SQL coverage uplift intentionally deferred. Keeps the
5//! kernel pure (no IO, no clocks, no randomness) — anything that needs
6//! the wallclock threads a clock parameter in at the call site so VOPR's
7//! determinism contract is preserved.
8//!
9//! Currently supports:
10//!
11//! * Literals — every [`Value`] variant (Text, Numeric, Boolean, …)
12//! * Column references — resolved against the projection's column map
13//! * String functions: `UPPER`, `LOWER`, `LENGTH` (char count), `TRIM`,
14//!   `CONCAT`, `||`
15//! * Numeric functions: `ABS`, `ROUND(x)`, `ROUND(x, scale)`, `CEIL`,
16//!   `CEILING`, `FLOOR`
17//! * Null/type coercion: `COALESCE(a, b, …)`, `NULLIF(a, b)`
18//!
19//! Added in v0.5.1:
20//!
21//! * `CAST(x AS T)` — numeric subtype conversions, numeric ↔ Text
22//!   parsing/formatting, Boolean ↔ Text, and NULL preservation. The
23//!   predicate-level integration landed with the `ScalarCmp`
24//!   `Predicate` variant in the parser.
25//!
26//! Still deferred:
27//!
28//! * `MOD`, `POWER`, `SQRT` (number-theoretic — need proper overflow
29//!   handling across TinyInt/SmallInt/Integer/BigInt/Real)
30//! * `SUBSTRING`, `EXTRACT`, `DATE_TRUNC`, `NOW()`,
31//!   `CURRENT_TIMESTAMP`, `CURRENT_DATE`, interval arithmetic — need
32//!   a clock-threading decision we haven't made yet (VOPR sim clock
33//!   vs production wall clock)
34//!
35//! Each function is a whitelisted, named variant on [`ScalarExpr`] —
36//! deliberately not a dynamic-dispatch table, so a typo in a SQL
37//! function name is rejected at planning time rather than runtime.
38
39use crate::error::{QueryError, Result};
40use crate::schema::{ColumnName, DataType};
41use crate::value::Value;
42
43/// A scalar expression that evaluates to a [`Value`] against a row.
44///
45/// Pressurecraft: pure over its inputs (no IO, no clocks, no RNG).
46/// Every variant's evaluation is deterministic — same inputs produce
47/// the same output. VOPR-safe.
48#[derive(Debug, Clone)]
49pub enum ScalarExpr {
50    /// Literal value.
51    Literal(Value),
52    /// Reference to a row column by name.
53    Column(ColumnName),
54
55    // --- String functions --------------------------------------------------
56    /// `UPPER(s)` — ASCII-preserving uppercase via Unicode simple mapping.
57    Upper(Box<ScalarExpr>),
58    /// `LOWER(s)` — Unicode simple lowercase.
59    Lower(Box<ScalarExpr>),
60    /// `LENGTH(s)` — character count (not byte count).
61    Length(Box<ScalarExpr>),
62    /// `TRIM(s)` — strip ASCII whitespace from both ends.
63    Trim(Box<ScalarExpr>),
64    /// `CONCAT(a, b, …)` — string concatenation. A `NULL` operand makes
65    /// the whole result NULL (PostgreSQL-compatible, differs from MySQL).
66    Concat(Vec<ScalarExpr>),
67
68    // --- Numeric functions -------------------------------------------------
69    /// `ABS(n)` — absolute value. Preserves the integer subtype when the
70    /// argument is an integer; returns `Real` for `Real`; returns
71    /// `Decimal` (same scale) for `Decimal`.
72    Abs(Box<ScalarExpr>),
73    /// `ROUND(x)` — half-away-from-zero. For integers this is identity.
74    Round(Box<ScalarExpr>),
75    /// `ROUND(x, scale)` — round to `scale` decimal places. Only
76    /// meaningful for `Real` / `Decimal` operands; integer operands are
77    /// returned unchanged.
78    RoundScale(Box<ScalarExpr>, i32),
79    /// `CEIL(x)` / `CEILING(x)` — least integer >= x.
80    Ceil(Box<ScalarExpr>),
81    /// `FLOOR(x)` — greatest integer <= x.
82    Floor(Box<ScalarExpr>),
83
84    // --- Null / conditional ------------------------------------------------
85    /// `COALESCE(e1, e2, …)` — first non-NULL argument, or NULL.
86    Coalesce(Vec<ScalarExpr>),
87    /// `NULLIF(a, b)` — NULL if `a == b`, otherwise `a`.
88    Nullif(Box<ScalarExpr>, Box<ScalarExpr>),
89
90    // --- Type coercion -----------------------------------------------------
91    /// `CAST(x AS T)` — convert `x` to the target [`DataType`].
92    ///
93    /// NULL in → NULL out for every target. Overflow on narrowing
94    /// integer casts and unparseable strings surface as
95    /// [`QueryError::TypeMismatch`] rather than silent truncation.
96    Cast(Box<ScalarExpr>, DataType),
97}
98
99/// A row paired with its column map, passed to [`evaluate`]. The column
100/// map is an ordered list of names matching the row's positional layout.
101pub struct EvalContext<'a> {
102    pub columns: &'a [ColumnName],
103    pub row: &'a [Value],
104}
105
106impl<'a> EvalContext<'a> {
107    pub fn new(columns: &'a [ColumnName], row: &'a [Value]) -> Self {
108        assert!(
109            columns.len() == row.len(),
110            "EvalContext precondition: columns and row must have equal length",
111        );
112        Self { columns, row }
113    }
114
115    fn lookup(&self, name: &ColumnName) -> Result<&Value> {
116        self.columns
117            .iter()
118            .position(|c| c == name)
119            .and_then(|idx| self.row.get(idx))
120            .ok_or_else(|| QueryError::ColumnNotFound {
121                table: String::new(),
122                column: name.to_string(),
123            })
124    }
125}
126
127/// Evaluate a scalar expression against a row.
128///
129/// Pure function. Deterministic. Does not allocate a new context — the
130/// caller owns it. 2+ assertions per function (per pressurecraft guide):
131/// preconditions on argument count, postconditions on return-type
132/// consistency.
133pub fn evaluate(expr: &ScalarExpr, ctx: &EvalContext<'_>) -> Result<Value> {
134    match expr {
135        ScalarExpr::Literal(v) => Ok(v.clone()),
136        ScalarExpr::Column(name) => Ok(ctx.lookup(name)?.clone()),
137
138        // ---- Strings ----
139        ScalarExpr::Upper(inner) => match evaluate(inner, ctx)? {
140            Value::Null => Ok(Value::Null),
141            Value::Text(s) => Ok(Value::Text(s.to_uppercase())),
142            other => Err(type_error("UPPER", "Text", &other)),
143        },
144        ScalarExpr::Lower(inner) => match evaluate(inner, ctx)? {
145            Value::Null => Ok(Value::Null),
146            Value::Text(s) => Ok(Value::Text(s.to_lowercase())),
147            other => Err(type_error("LOWER", "Text", &other)),
148        },
149        ScalarExpr::Length(inner) => match evaluate(inner, ctx)? {
150            Value::Null => Ok(Value::Null),
151            Value::Text(s) => {
152                // SQL LENGTH is character count, not byte count.
153                let chars = s.chars().count();
154                // Postcondition: the returned count matches the str's
155                // actual character iterator length (invariant on UTF-8).
156                debug_assert_eq!(chars, s.chars().count());
157                Ok(Value::BigInt(chars as i64))
158            }
159            other => Err(type_error("LENGTH", "Text", &other)),
160        },
161        ScalarExpr::Trim(inner) => match evaluate(inner, ctx)? {
162            Value::Null => Ok(Value::Null),
163            Value::Text(s) => Ok(Value::Text(s.trim().to_string())),
164            other => Err(type_error("TRIM", "Text", &other)),
165        },
166        ScalarExpr::Concat(parts) => {
167            assert!(
168                !parts.is_empty(),
169                "CONCAT precondition: at least one argument"
170            );
171            let mut out = String::new();
172            for p in parts {
173                match evaluate(p, ctx)? {
174                    Value::Null => return Ok(Value::Null),
175                    Value::Text(s) => out.push_str(&s),
176                    other => return Err(type_error("CONCAT", "Text", &other)),
177                }
178            }
179            Ok(Value::Text(out))
180        }
181
182        // ---- Numerics ----
183        ScalarExpr::Abs(inner) => match evaluate(inner, ctx)? {
184            Value::Null => Ok(Value::Null),
185            Value::TinyInt(n) => Ok(Value::TinyInt(n.saturating_abs())),
186            Value::SmallInt(n) => Ok(Value::SmallInt(n.saturating_abs())),
187            Value::Integer(n) => Ok(Value::Integer(n.saturating_abs())),
188            Value::BigInt(n) => Ok(Value::BigInt(n.saturating_abs())),
189            Value::Real(n) => Ok(Value::Real(n.abs())),
190            Value::Decimal(val, scale) => Ok(Value::Decimal(val.saturating_abs(), scale)),
191            other => Err(type_error("ABS", "Numeric", &other)),
192        },
193        ScalarExpr::Round(inner) => match evaluate(inner, ctx)? {
194            Value::Null => Ok(Value::Null),
195            // Integers round to themselves.
196            v @ (Value::TinyInt(_) | Value::SmallInt(_) | Value::Integer(_) | Value::BigInt(_)) => {
197                Ok(v)
198            }
199            Value::Real(x) => Ok(Value::Real(x.round())),
200            Value::Decimal(val, scale) => Ok(decimal_round_to_scale(val, scale, 0)),
201            other => Err(type_error("ROUND", "Numeric", &other)),
202        },
203        ScalarExpr::RoundScale(inner, target_scale) => {
204            assert!(
205                *target_scale >= 0 && *target_scale < i32::from(u8::MAX),
206                "ROUND scale must fit in a non-negative u8",
207            );
208            let target = u8::try_from(*target_scale).unwrap_or(0);
209            match evaluate(inner, ctx)? {
210                Value::Null => Ok(Value::Null),
211                v @ (Value::TinyInt(_)
212                | Value::SmallInt(_)
213                | Value::Integer(_)
214                | Value::BigInt(_)) => Ok(v),
215                Value::Real(x) => {
216                    // (x * 10^scale).round() / 10^scale — standard
217                    // half-away-from-zero rounding for f64.
218                    let factor = 10f64.powi(i32::from(target));
219                    Ok(Value::Real((x * factor).round() / factor))
220                }
221                Value::Decimal(val, scale) => Ok(decimal_round_to_scale(val, scale, target)),
222                other => Err(type_error("ROUND", "Numeric", &other)),
223            }
224        }
225        ScalarExpr::Ceil(inner) => match evaluate(inner, ctx)? {
226            Value::Null => Ok(Value::Null),
227            v @ (Value::TinyInt(_) | Value::SmallInt(_) | Value::Integer(_) | Value::BigInt(_)) => {
228                Ok(v)
229            }
230            Value::Real(x) => Ok(Value::Real(x.ceil())),
231            Value::Decimal(val, scale) => {
232                if scale == 0 {
233                    Ok(Value::Decimal(val, 0))
234                } else {
235                    Ok(decimal_ceil(val, scale))
236                }
237            }
238            other => Err(type_error("CEIL", "Numeric", &other)),
239        },
240        ScalarExpr::Floor(inner) => match evaluate(inner, ctx)? {
241            Value::Null => Ok(Value::Null),
242            v @ (Value::TinyInt(_) | Value::SmallInt(_) | Value::Integer(_) | Value::BigInt(_)) => {
243                Ok(v)
244            }
245            Value::Real(x) => Ok(Value::Real(x.floor())),
246            Value::Decimal(val, scale) => {
247                if scale == 0 {
248                    Ok(Value::Decimal(val, 0))
249                } else {
250                    Ok(decimal_floor(val, scale))
251                }
252            }
253            other => Err(type_error("FLOOR", "Numeric", &other)),
254        },
255
256        // ---- Null / conditional ----
257        ScalarExpr::Coalesce(exprs) => {
258            assert!(
259                !exprs.is_empty(),
260                "COALESCE precondition: at least one argument"
261            );
262            for e in exprs {
263                let v = evaluate(e, ctx)?;
264                if !matches!(v, Value::Null) {
265                    return Ok(v);
266                }
267            }
268            Ok(Value::Null)
269        }
270        ScalarExpr::Nullif(a, b) => {
271            let av = evaluate(a, ctx)?;
272            let bv = evaluate(b, ctx)?;
273            if av == bv { Ok(Value::Null) } else { Ok(av) }
274        }
275
276        // ---- Type coercion ----
277        ScalarExpr::Cast(inner, target) => cast_value(evaluate(inner, ctx)?, *target),
278    }
279}
280
281/// Coerce `value` to `target`. NULL is preserved verbatim for every
282/// target. Integer subtype widening is lossless; narrowing checks
283/// for overflow. Numeric ↔ Text goes through `str::parse` / `Display`.
284/// Boolean ↔ Text accepts the literals `"true"` / `"false"` (case-
285/// insensitive). Returns `QueryError::TypeMismatch` for unsupported
286/// source/target pairs rather than panicking or silently truncating.
287fn cast_value(value: Value, target: DataType) -> Result<Value> {
288    if matches!(value, Value::Null) {
289        return Ok(Value::Null);
290    }
291    match (value, target) {
292        // Identity casts — short-circuit even across subtle subtype
293        // boundaries (Decimal keeps its scale).
294        (v @ Value::TinyInt(_), DataType::TinyInt)
295        | (v @ Value::SmallInt(_), DataType::SmallInt)
296        | (v @ Value::Integer(_), DataType::Integer)
297        | (v @ Value::BigInt(_), DataType::BigInt)
298        | (v @ Value::Real(_), DataType::Real)
299        | (v @ Value::Text(_), DataType::Text)
300        | (v @ Value::Bytes(_), DataType::Bytes)
301        | (v @ Value::Boolean(_), DataType::Boolean)
302        | (v @ Value::Date(_), DataType::Date)
303        | (v @ Value::Time(_), DataType::Time)
304        | (v @ Value::Timestamp(_), DataType::Timestamp)
305        | (v @ Value::Uuid(_), DataType::Uuid)
306        | (v @ Value::Json(_), DataType::Json) => Ok(v),
307
308        // Integer widening — always lossless.
309        (Value::TinyInt(n), DataType::SmallInt) => Ok(Value::SmallInt(i16::from(n))),
310        (Value::TinyInt(n), DataType::Integer) => Ok(Value::Integer(i32::from(n))),
311        (Value::TinyInt(n), DataType::BigInt) => Ok(Value::BigInt(i64::from(n))),
312        (Value::SmallInt(n), DataType::Integer) => Ok(Value::Integer(i32::from(n))),
313        (Value::SmallInt(n), DataType::BigInt) => Ok(Value::BigInt(i64::from(n))),
314        (Value::Integer(n), DataType::BigInt) => Ok(Value::BigInt(i64::from(n))),
315
316        // Integer narrowing — checked.
317        (Value::SmallInt(n), DataType::TinyInt) => i8::try_from(n)
318            .map(Value::TinyInt)
319            .map_err(|_| cast_error("SmallInt", "TinyInt", "overflow")),
320        (Value::Integer(n), DataType::TinyInt) => i8::try_from(n)
321            .map(Value::TinyInt)
322            .map_err(|_| cast_error("Integer", "TinyInt", "overflow")),
323        (Value::Integer(n), DataType::SmallInt) => i16::try_from(n)
324            .map(Value::SmallInt)
325            .map_err(|_| cast_error("Integer", "SmallInt", "overflow")),
326        (Value::BigInt(n), DataType::TinyInt) => i8::try_from(n)
327            .map(Value::TinyInt)
328            .map_err(|_| cast_error("BigInt", "TinyInt", "overflow")),
329        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n)
330            .map(Value::SmallInt)
331            .map_err(|_| cast_error("BigInt", "SmallInt", "overflow")),
332        (Value::BigInt(n), DataType::Integer) => i32::try_from(n)
333            .map(Value::Integer)
334            .map_err(|_| cast_error("BigInt", "Integer", "overflow")),
335
336        // Integer → Real — lossless for i32 and below; possible rounding
337        // for i64 past 2^53 but no information loss vs the user's intent.
338        (Value::TinyInt(n), DataType::Real) => Ok(Value::Real(f64::from(n))),
339        (Value::SmallInt(n), DataType::Real) => Ok(Value::Real(f64::from(n))),
340        (Value::Integer(n), DataType::Real) => Ok(Value::Real(f64::from(n))),
341        #[allow(clippy::cast_precision_loss)]
342        (Value::BigInt(n), DataType::Real) => Ok(Value::Real(n as f64)),
343
344        // Real → Integer — truncate toward zero (standard SQL).
345        (Value::Real(x), DataType::TinyInt) => f64_to_int::<i8>(x, "TinyInt").map(Value::TinyInt),
346        (Value::Real(x), DataType::SmallInt) => {
347            f64_to_int::<i16>(x, "SmallInt").map(Value::SmallInt)
348        }
349        (Value::Real(x), DataType::Integer) => f64_to_int::<i32>(x, "Integer").map(Value::Integer),
350        (Value::Real(x), DataType::BigInt) => f64_to_int::<i64>(x, "BigInt").map(Value::BigInt),
351
352        // Text → numerics — parse, error on bad input.
353        (Value::Text(s), DataType::TinyInt) => s
354            .trim()
355            .parse::<i8>()
356            .map(Value::TinyInt)
357            .map_err(|_| cast_error("Text", "TinyInt", &s)),
358        (Value::Text(s), DataType::SmallInt) => s
359            .trim()
360            .parse::<i16>()
361            .map(Value::SmallInt)
362            .map_err(|_| cast_error("Text", "SmallInt", &s)),
363        (Value::Text(s), DataType::Integer) => s
364            .trim()
365            .parse::<i32>()
366            .map(Value::Integer)
367            .map_err(|_| cast_error("Text", "Integer", &s)),
368        (Value::Text(s), DataType::BigInt) => s
369            .trim()
370            .parse::<i64>()
371            .map(Value::BigInt)
372            .map_err(|_| cast_error("Text", "BigInt", &s)),
373        (Value::Text(s), DataType::Real) => s
374            .trim()
375            .parse::<f64>()
376            .map(Value::Real)
377            .map_err(|_| cast_error("Text", "Real", &s)),
378        (Value::Text(s), DataType::Boolean) => match s.trim().to_ascii_lowercase().as_str() {
379            "true" | "t" | "1" => Ok(Value::Boolean(true)),
380            "false" | "f" | "0" => Ok(Value::Boolean(false)),
381            _ => Err(cast_error("Text", "Boolean", &s)),
382        },
383
384        // Numerics → Text (canonical Display).
385        (Value::TinyInt(n), DataType::Text) => Ok(Value::Text(n.to_string())),
386        (Value::SmallInt(n), DataType::Text) => Ok(Value::Text(n.to_string())),
387        (Value::Integer(n), DataType::Text) => Ok(Value::Text(n.to_string())),
388        (Value::BigInt(n), DataType::Text) => Ok(Value::Text(n.to_string())),
389        (Value::Real(n), DataType::Text) => Ok(Value::Text(n.to_string())),
390        (Value::Boolean(b), DataType::Text) => {
391            Ok(Value::Text(if b { "true" } else { "false" }.to_string()))
392        }
393
394        // Explicit unsupported pair — surface a clear error.
395        (v, t) => Err(QueryError::TypeMismatch {
396            expected: format!("CAST to {t:?}"),
397            actual: format!("{v:?}"),
398        }),
399    }
400}
401
402fn cast_error(from: &str, to: &str, detail: &str) -> QueryError {
403    QueryError::TypeMismatch {
404        expected: format!("CAST from {from} to {to}"),
405        actual: detail.to_string(),
406    }
407}
408
409/// Truncate an `f64` toward zero into an integer, checking range.
410/// NaN / ±∞ surface as `TypeMismatch` rather than silent conversion.
411fn f64_to_int<T>(x: f64, target: &str) -> Result<T>
412where
413    T: TryFrom<i64>,
414{
415    if !x.is_finite() {
416        return Err(cast_error("Real", target, &format!("{x}")));
417    }
418    // Truncate toward zero (i.e. SQL `CAST(Real AS Integer)` semantics).
419    let truncated = x.trunc();
420    // Range check against i64 first to get into `TryFrom` territory.
421    #[allow(clippy::cast_possible_truncation)]
422    let as_i64 = if (i64::MIN as f64) <= truncated && truncated <= (i64::MAX as f64) {
423        truncated as i64
424    } else {
425        return Err(cast_error("Real", target, &format!("{x}")));
426    };
427    T::try_from(as_i64).map_err(|_| cast_error("Real", target, &format!("{x}")))
428}
429
430fn type_error(func: &str, expected: &str, got: &Value) -> QueryError {
431    QueryError::TypeMismatch {
432        expected: format!("{func} argument of type {expected}"),
433        actual: format!("{got:?}"),
434    }
435}
436
437/// Rescale a decimal's raw integer representation to a target scale.
438///
439/// Same semantics as SQL `ROUND(x, n)`: scale down with half-away-from-
440/// zero rounding; scale up with no loss. Returns a new `Decimal(_, n)`.
441fn decimal_round_to_scale(val: i128, from_scale: u8, to_scale: u8) -> Value {
442    if from_scale == to_scale {
443        return Value::Decimal(val, to_scale);
444    }
445    if to_scale > from_scale {
446        let diff = u32::from(to_scale - from_scale);
447        let factor = 10i128.pow(diff);
448        return Value::Decimal(val.saturating_mul(factor), to_scale);
449    }
450    // from_scale > to_scale — round half away from zero.
451    let diff = u32::from(from_scale - to_scale);
452    let divisor = 10i128.pow(diff);
453    let half = divisor / 2;
454    let rounded = if val >= 0 {
455        (val + half) / divisor
456    } else {
457        (val - half) / divisor
458    };
459    Value::Decimal(rounded, to_scale)
460}
461
462fn decimal_ceil(val: i128, scale: u8) -> Value {
463    let divisor = 10i128.pow(u32::from(scale));
464    let floor_val = val / divisor;
465    let remainder = val % divisor;
466    let ceil = if remainder > 0 {
467        floor_val + 1
468    } else {
469        floor_val
470    };
471    Value::Decimal(ceil, 0)
472}
473
474fn decimal_floor(val: i128, scale: u8) -> Value {
475    let divisor = 10i128.pow(u32::from(scale));
476    let floor_val = val / divisor;
477    let remainder = val % divisor;
478    // Negative with non-zero remainder rounds down (away from zero).
479    let floor = if remainder < 0 {
480        floor_val - 1
481    } else {
482        floor_val
483    };
484    Value::Decimal(floor, 0)
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn ctx_empty() -> (Vec<ColumnName>, Vec<Value>) {
492        (Vec::new(), Vec::new())
493    }
494
495    fn lit(v: Value) -> ScalarExpr {
496        ScalarExpr::Literal(v)
497    }
498
499    fn eval_standalone(expr: &ScalarExpr) -> Result<Value> {
500        let (cols, row) = ctx_empty();
501        evaluate(expr, &EvalContext::new(&cols, &row))
502    }
503
504    #[test]
505    fn upper_lower_length_trim() {
506        assert_eq!(
507            eval_standalone(&ScalarExpr::Upper(Box::new(lit(Value::Text(
508                "hello".into()
509            )))))
510            .unwrap(),
511            Value::Text("HELLO".into()),
512        );
513        assert_eq!(
514            eval_standalone(&ScalarExpr::Lower(Box::new(lit(Value::Text(
515                "WORLD".into()
516            )))))
517            .unwrap(),
518            Value::Text("world".into()),
519        );
520        assert_eq!(
521            eval_standalone(&ScalarExpr::Length(Box::new(lit(Value::Text(
522                "café".into()
523            )))))
524            .unwrap(),
525            Value::BigInt(4),
526            "LENGTH is char count, not byte count",
527        );
528        assert_eq!(
529            eval_standalone(&ScalarExpr::Trim(Box::new(lit(Value::Text(
530                "  hi  ".into(),
531            )))))
532            .unwrap(),
533            Value::Text("hi".into()),
534        );
535    }
536
537    #[test]
538    fn concat_propagates_null_like_postgres() {
539        let ex = ScalarExpr::Concat(vec![
540            lit(Value::Text("a".into())),
541            lit(Value::Null),
542            lit(Value::Text("b".into())),
543        ]);
544        assert_eq!(eval_standalone(&ex).unwrap(), Value::Null);
545    }
546
547    #[test]
548    fn abs_preserves_subtype() {
549        assert_eq!(
550            eval_standalone(&ScalarExpr::Abs(Box::new(lit(Value::Integer(-5))))).unwrap(),
551            Value::Integer(5),
552        );
553        assert_eq!(
554            eval_standalone(&ScalarExpr::Abs(Box::new(lit(Value::Real(-1.5))))).unwrap(),
555            Value::Real(1.5),
556        );
557    }
558
559    #[test]
560    fn round_with_scale_rounds_decimal() {
561        // 123.45 → ROUND(x, 1) → 123.5 (half-away-from-zero).
562        let rounded = eval_standalone(&ScalarExpr::RoundScale(
563            Box::new(lit(Value::Decimal(12345, 2))),
564            1,
565        ))
566        .unwrap();
567        assert_eq!(rounded, Value::Decimal(1235, 1));
568
569        // 123.44 → ROUND(x, 1) → 123.4 (no rounding up).
570        let rounded = eval_standalone(&ScalarExpr::RoundScale(
571            Box::new(lit(Value::Decimal(12344, 2))),
572            1,
573        ))
574        .unwrap();
575        assert_eq!(rounded, Value::Decimal(1234, 1));
576
577        // Negative half-away-from-zero: -123.45 → -123.5.
578        let rounded = eval_standalone(&ScalarExpr::RoundScale(
579            Box::new(lit(Value::Decimal(-12345, 2))),
580            1,
581        ))
582        .unwrap();
583        assert_eq!(rounded, Value::Decimal(-1235, 1));
584    }
585
586    #[test]
587    fn ceil_and_floor_decimal() {
588        let c =
589            eval_standalone(&ScalarExpr::Ceil(Box::new(lit(Value::Decimal(12345, 2))))).unwrap();
590        assert_eq!(c, Value::Decimal(124, 0));
591        let f =
592            eval_standalone(&ScalarExpr::Floor(Box::new(lit(Value::Decimal(12345, 2))))).unwrap();
593        assert_eq!(f, Value::Decimal(123, 0));
594    }
595
596    #[test]
597    fn coalesce_returns_first_non_null() {
598        let ex = ScalarExpr::Coalesce(vec![
599            lit(Value::Null),
600            lit(Value::Null),
601            lit(Value::BigInt(42)),
602            lit(Value::BigInt(99)),
603        ]);
604        assert_eq!(eval_standalone(&ex).unwrap(), Value::BigInt(42));
605    }
606
607    #[test]
608    fn nullif_returns_null_when_equal() {
609        let eq = ScalarExpr::Nullif(
610            Box::new(lit(Value::Text("x".into()))),
611            Box::new(lit(Value::Text("x".into()))),
612        );
613        assert_eq!(eval_standalone(&eq).unwrap(), Value::Null);
614        let ne = ScalarExpr::Nullif(
615            Box::new(lit(Value::Text("x".into()))),
616            Box::new(lit(Value::Text("y".into()))),
617        );
618        assert_eq!(eval_standalone(&ne).unwrap(), Value::Text("x".into()));
619    }
620
621    #[test]
622    fn column_reference_resolves() {
623        let cols = vec![ColumnName::new(String::from("name"))];
624        let row = vec![Value::Text("Ada".into())];
625        let ctx = EvalContext::new(&cols, &row);
626        let ex = ScalarExpr::Upper(Box::new(ScalarExpr::Column(ColumnName::new(String::from(
627            "name",
628        )))));
629        assert_eq!(evaluate(&ex, &ctx).unwrap(), Value::Text("ADA".into()));
630    }
631
632    #[test]
633    fn null_input_propagates_through_scalar_fns() {
634        for expr in [
635            ScalarExpr::Upper(Box::new(lit(Value::Null))),
636            ScalarExpr::Lower(Box::new(lit(Value::Null))),
637            ScalarExpr::Length(Box::new(lit(Value::Null))),
638            ScalarExpr::Trim(Box::new(lit(Value::Null))),
639            ScalarExpr::Abs(Box::new(lit(Value::Null))),
640            ScalarExpr::Round(Box::new(lit(Value::Null))),
641            ScalarExpr::Ceil(Box::new(lit(Value::Null))),
642            ScalarExpr::Floor(Box::new(lit(Value::Null))),
643            ScalarExpr::Cast(Box::new(lit(Value::Null)), DataType::Integer),
644        ] {
645            assert_eq!(eval_standalone(&expr).unwrap(), Value::Null);
646        }
647    }
648
649    #[test]
650    fn cast_integer_widening_and_narrowing() {
651        // Widening: always ok.
652        let w = eval_standalone(&ScalarExpr::Cast(
653            Box::new(lit(Value::TinyInt(42))),
654            DataType::BigInt,
655        ))
656        .unwrap();
657        assert_eq!(w, Value::BigInt(42));
658
659        // Narrowing ok when in range.
660        let ok = eval_standalone(&ScalarExpr::Cast(
661            Box::new(lit(Value::BigInt(127))),
662            DataType::TinyInt,
663        ))
664        .unwrap();
665        assert_eq!(ok, Value::TinyInt(127));
666
667        // Narrowing overflow errors out rather than silently truncating.
668        let err = eval_standalone(&ScalarExpr::Cast(
669            Box::new(lit(Value::BigInt(i64::from(i16::MAX) + 1))),
670            DataType::SmallInt,
671        ));
672        assert!(err.is_err(), "narrowing overflow must be an error");
673    }
674
675    #[test]
676    fn cast_text_to_numeric_parses() {
677        assert_eq!(
678            eval_standalone(&ScalarExpr::Cast(
679                Box::new(lit(Value::Text("42".into()))),
680                DataType::Integer,
681            ))
682            .unwrap(),
683            Value::Integer(42),
684        );
685        assert_eq!(
686            eval_standalone(&ScalarExpr::Cast(
687                Box::new(lit(Value::Text("1.5".into()))),
688                DataType::Real,
689            ))
690            .unwrap(),
691            Value::Real(1.5),
692        );
693        assert!(
694            eval_standalone(&ScalarExpr::Cast(
695                Box::new(lit(Value::Text("nope".into()))),
696                DataType::Integer,
697            ))
698            .is_err(),
699            "unparseable text must error rather than coerce to 0"
700        );
701    }
702
703    #[test]
704    fn cast_numeric_to_text_formats_canonically() {
705        assert_eq!(
706            eval_standalone(&ScalarExpr::Cast(
707                Box::new(lit(Value::BigInt(99))),
708                DataType::Text,
709            ))
710            .unwrap(),
711            Value::Text("99".into()),
712        );
713        assert_eq!(
714            eval_standalone(&ScalarExpr::Cast(
715                Box::new(lit(Value::Boolean(true))),
716                DataType::Text,
717            ))
718            .unwrap(),
719            Value::Text("true".into()),
720        );
721    }
722
723    #[test]
724    fn cast_real_to_int_truncates_toward_zero() {
725        assert_eq!(
726            eval_standalone(&ScalarExpr::Cast(
727                Box::new(lit(Value::Real(1.9))),
728                DataType::Integer,
729            ))
730            .unwrap(),
731            Value::Integer(1),
732        );
733        assert_eq!(
734            eval_standalone(&ScalarExpr::Cast(
735                Box::new(lit(Value::Real(-1.9))),
736                DataType::Integer,
737            ))
738            .unwrap(),
739            Value::Integer(-1),
740        );
741        assert!(
742            eval_standalone(&ScalarExpr::Cast(
743                Box::new(lit(Value::Real(f64::NAN))),
744                DataType::Integer,
745            ))
746            .is_err(),
747            "NaN cast must error"
748        );
749    }
750
751    #[test]
752    fn cast_text_to_boolean_accepts_common_literals() {
753        for (s, want) in [
754            ("true", true),
755            ("TRUE", true),
756            ("t", true),
757            ("1", true),
758            ("false", false),
759            ("F", false),
760            ("0", false),
761        ] {
762            assert_eq!(
763                eval_standalone(&ScalarExpr::Cast(
764                    Box::new(lit(Value::Text(s.into()))),
765                    DataType::Boolean,
766                ))
767                .unwrap(),
768                Value::Boolean(want),
769                "cast('{s}' as boolean)",
770            );
771        }
772    }
773}