Skip to main content

spg_engine/
eval.rs

1//! Expression evaluator. Given a parsed `Expr`, a `Row`, and the row's column
2//! schema, produce a `Value`. v0.4 implements:
3//!
4//! - literals
5//! - column lookups (bare and qualified `t.col`)
6//! - unary minus / NOT
7//! - binary arithmetic, comparison, AND, OR
8//! - numeric widening (`Int → BigInt → Float`) at evaluation time
9//! - SQL three-valued logic for NULL:
10//!     * any arithmetic / comparison op with a NULL operand → NULL
11//!     * `TRUE OR NULL` → TRUE, `FALSE OR NULL` → NULL,
12//!     * `FALSE AND NULL` → FALSE, `TRUE AND NULL` → NULL,
13//!     * `NOT NULL` → NULL
14//!
15//! v0.4 deliberately does *not* implement: function calls, string
16//! concatenation, IS NULL / IS NOT NULL, BETWEEN, IN, etc. Those come later.
17
18use alloc::borrow::Cow;
19use alloc::format;
20use alloc::string::{String, ToString};
21use alloc::vec::Vec;
22
23use spg_sql::ast::{BinOp, ColumnName, Expr, Literal};
24use spg_storage::{ColumnSchema, Row, Value};
25
26mod binop;
27mod cast;
28pub mod compiled;
29mod datetime;
30mod encoding;
31mod format;
32mod functions;
33mod inet;
34mod math;
35mod regexp;
36mod resolve;
37mod strings;
38mod textsearch;
39mod values;
40
41pub use crate::conversions::format_money_array;
42pub(crate) use binop::{and_3vl, apply_binary_interval, apply_binary_by_ref};
43use binop::{apply_binary, apply_unary, compare, pow10_i128};
44pub use cast::{cast_to_vector, cast_value, parse_vector_text};
45pub(crate) use compiled::{
46    CompiledExpr, compile_expr, eval_compiled, eval_compiled_ref, fully_compilable,
47};
48use datetime::{
49    age, date_format_mysql, date_part, date_trunc, extract_field, from_unixtime, unix_timestamp_of,
50};
51use encoding::{decode_text, encode_text};
52pub use format::{
53    days_from_civil, format_bigint_array, format_bool_array, format_bytea_array, format_bytea_hex,
54    format_date, format_date_array, format_float_array, format_int_array, format_interval,
55    format_interval_array, format_money, format_numeric, format_numeric_array,
56    format_smallint_array, format_text_array, format_time, format_timestamp,
57    format_timestamp_array, format_timestamptz, format_timetz, format_uuid_array,
58    parse_date_literal, parse_timestamp_literal,
59};
60use functions::apply_function;
61use inet::{inet_host, inet_masklen, inet_network, inet_op_bool_result};
62pub(crate) use math::{f64_ceil, f64_floor, f64_sqrt};
63use math::{
64    f64_exp, f64_ln, f64_powi, f64_round_half_away, f64_trunc, prng_next_f64, prng_next_u64,
65};
66use regexp::{regexp_matches, regexp_replace, regexp_split_to_array};
67use resolve::{
68    collation_fold_for_compare, compare_is_case_insensitive, composite_eq, eval_expr_cow,
69    is_owned_compare_value, resolve_column, resolve_column_borrowed, text_prefix_chars,
70};
71pub(crate) use resolve::{column_collation, find_column_pos};
72use strings::{
73    TrimSide, format_string, pg_typeof_name, string_left_right, string_pad, string_trim, to_char,
74    value_to_format_text,
75};
76pub use textsearch::{
77    decode_tsquery_external, decode_tsvector_external, format_tsquery, format_tsvector,
78};
79use textsearch::{
80    fts_phraseto_tsquery, fts_plainto_tsquery, fts_setweight, fts_to_tsquery, fts_to_tsvector,
81    fts_ts_rank, fts_ts_rank_cd, fts_websearch_to_tsquery, ts_match, tsvector_concat,
82};
83pub use values::gen_random_uuid_bytes;
84use values::{value_cmp_for_min_max, value_to_f64, value_to_text, values_equal_for_nullif};
85
86/// Resolution context for evaluating a single row. `table_alias` is the alias
87/// (or table name) callers should accept as the qualifier on a column ref —
88/// e.g. `FROM users AS u` makes `u.name` valid and rejects `other.name`.
89#[derive(Clone)]
90#[allow(missing_debug_implementations)] // sequence_resolver is a dyn Fn — no Debug
91pub struct EvalContext<'a> {
92    pub columns: &'a [ColumnSchema],
93    pub table_alias: Option<&'a str>,
94    /// v6.1.1 — bound parameters for `$N` placeholders inside the
95    /// expression tree. Empty for simple queries; populated by the
96    /// prepared-statement Execute path with Bind values converted
97    /// to `Value`. Index N (1-based per PG) hits `params[N-1]`.
98    pub params: &'a [Value<'static>],
99    /// v7.12.1 — session text-search config (from `SET
100    /// default_text_search_config = '<name>'`). Resolved when the
101    /// engine builds an `EvalContext` and consumed by the FTS
102    /// function dispatcher when `to_tsvector(text)` /
103    /// `plainto_tsquery(text)` etc are called without an explicit
104    /// config arg. `None` falls through to `simple`.
105    pub default_text_search_config: Option<&'a str>,
106    /// v7.17.0 Phase 1.1 — `nextval` / `currval` / `setval`
107    /// resolver. The engine builds this around a `&mut Catalog`
108    /// so apply_function can mutate sequence state without
109    /// eval owning a catalog reference. When `None`, sequence
110    /// functions return an error (read-only contexts).
111    pub sequence_resolver: Option<&'a SequenceResolver<'a>>,
112}
113
114/// v7.17.0 — sequence-mutating callback used by `apply_function`
115/// for `nextval` / `currval` / `setval`. Implemented by the
116/// engine to thread `&mut Catalog` access through an immutable
117/// `&EvalContext`.
118pub type SequenceResolver<'a> = dyn Fn(SequenceOp) -> Result<i64, EvalError> + 'a;
119
120/// v7.17.0 — sequence operation requested by an Expr eval.
121#[derive(Debug, Clone)]
122pub enum SequenceOp {
123    Next(String),
124    Curr(String),
125    Set {
126        name: String,
127        value: i64,
128        is_called: bool,
129    },
130}
131
132impl<'a> EvalContext<'a> {
133    pub const fn new(columns: &'a [ColumnSchema], table_alias: Option<&'a str>) -> Self {
134        Self {
135            columns,
136            table_alias,
137            params: &[],
138            default_text_search_config: None,
139            sequence_resolver: None,
140        }
141    }
142
143    /// v7.17.0 — attach a sequence resolver. The engine wraps a
144    /// `&mut Catalog` in a closure that performs the requested
145    /// SequenceOp.
146    #[must_use]
147    pub const fn with_sequence_resolver(mut self, resolver: &'a SequenceResolver<'a>) -> Self {
148        self.sequence_resolver = Some(resolver);
149        self
150    }
151
152    /// v6.1.1 — attach a parameter buffer for `$N` placeholder
153    /// resolution. The slice must outlive the context; callers
154    /// construct it from the prepared statement's Bind values.
155    #[must_use]
156    pub const fn with_params(mut self, params: &'a [Value<'static>]) -> Self {
157        self.params = params;
158        self
159    }
160
161    /// v7.12.1 — attach the session's
162    /// `default_text_search_config`. Used by the FTS function
163    /// dispatcher when no explicit config arg is given.
164    #[must_use]
165    pub const fn with_default_text_search_config(mut self, cfg: Option<&'a str>) -> Self {
166        self.default_text_search_config = cfg;
167        self
168    }
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub enum EvalError {
173    ColumnNotFound {
174        name: String,
175    },
176    UnknownQualifier {
177        qualifier: String,
178    },
179    DivisionByZero,
180    TypeMismatch {
181        detail: String,
182    },
183    /// v6.1.1 — `$N` reference past the number of bound parameters.
184    /// Either the client sent too few in Bind, or the SQL has a
185    /// placeholder the prepared statement didn't account for.
186    PlaceholderOutOfRange {
187        n: u16,
188        bound: u16,
189    },
190}
191
192impl core::fmt::Display for EvalError {
193    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
194        match self {
195            Self::ColumnNotFound { name } => write!(f, "column not found: {name}"),
196            Self::UnknownQualifier { qualifier } => {
197                write!(f, "unknown table qualifier: {qualifier}")
198            }
199            Self::DivisionByZero => f.write_str("division by zero"),
200            Self::TypeMismatch { detail } => write!(f, "type mismatch: {detail}"),
201            Self::PlaceholderOutOfRange { n, bound } => write!(
202                f,
203                "parameter ${n} referenced but only {bound} bound by client"
204            ),
205        }
206    }
207}
208
209pub fn eval_expr(
210    expr: &Expr,
211    row: &Row<'static>,
212    ctx: &EvalContext<'_>,
213) -> Result<Value<'static>, EvalError> {
214    match expr {
215        Expr::AggregateOrdered { .. } => Err(EvalError::TypeMismatch {
216            detail: "aggregate ORDER BY is only valid inside an aggregating SELECT".into(),
217        }),
218        Expr::Literal(l) => Ok(literal_to_value(l)),
219        Expr::Column(c) => resolve_column(c, row, ctx),
220        Expr::Placeholder(n) => {
221            let idx = usize::from(*n).saturating_sub(1);
222            ctx.params
223                .get(idx)
224                .cloned()
225                .ok_or_else(|| EvalError::PlaceholderOutOfRange {
226                    n: *n,
227                    bound: u16::try_from(ctx.params.len()).unwrap_or(u16::MAX),
228                })
229        }
230        Expr::Unary { op, expr } => {
231            let v = eval_expr(expr, row, ctx)?;
232            apply_unary(*op, v)
233        }
234        Expr::Binary { lhs, op, rhs } => {
235            // v7.32 (P4 borrow channel) — comparison fast path. A pure
236            // comparison op only reads its operands and returns Bool,
237            // and for non-NUMERIC / non-INTERVAL / non-CI-collation
238            // operands `apply_binary` IS just the NULL-3VL check plus
239            // the ref-based `compare` (NUMERIC routes through fixed-
240            // point `apply_binary_numeric`; INTERVAL through
241            // `apply_binary_interval`; CI columns fold). So read the
242            // operands borrowed — a column cell is no longer cloned
243            // just to compare it (`WHERE thread_id != ''` alone cloned
244            // one Text cell per scanned row). Anything that needs the
245            // owned path falls through unchanged.
246            if matches!(
247                op,
248                BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
249            ) {
250                let lc = eval_expr_cow(lhs, row, ctx)?;
251                let rc = eval_expr_cow(rhs, row, ctx)?;
252                let owned_path = is_owned_compare_value(lc.as_ref())
253                    || is_owned_compare_value(rc.as_ref())
254                    || compare_is_case_insensitive(lhs, rhs, ctx);
255                if !owned_path {
256                    if lc.as_ref().is_null() || rc.as_ref().is_null() {
257                        return Ok(Value::Null);
258                    }
259                    return compare(*op, lc.as_ref(), rc.as_ref());
260                }
261                let (l, r) = collation_fold_for_compare(
262                    *op,
263                    lhs,
264                    rhs,
265                    lc.into_owned(),
266                    rc.into_owned(),
267                    ctx,
268                );
269                return apply_binary(*op, l, r);
270            }
271            let l = eval_expr(lhs, row, ctx)?;
272            let r = eval_expr(rhs, row, ctx)?;
273            // v7.17.0 Phase 2.5 — collation-aware text comparison.
274            // When either operand of a comparison op references a
275            // column declared `COLLATE "case_insensitive"` (or any
276            // MySQL `_ci` collation), case-fold both sides before
277            // the byte-wise compare so `WHERE name = 'foo'` matches
278            // stored `'Foo'`. Non-Text values fall straight through
279            // — the helper is a no-op outside Text-Text equality
280            // and inequality.
281            let (l, r) = collation_fold_for_compare(*op, lhs, rhs, l, r, ctx);
282            apply_binary(*op, l, r)
283        }
284        Expr::Cast { expr, target } => {
285            let v = eval_expr(expr, row, ctx)?;
286            cast_value(v, target.clone())
287        }
288        Expr::IsNull { expr, negated } => {
289            let v = eval_expr(expr, row, ctx)?;
290            let is_null = matches!(v, Value::Null);
291            Ok(Value::Bool(if *negated { !is_null } else { is_null }))
292        }
293        Expr::FunctionCall { name, args } => {
294            // v7.29 (round-22 phase 3) - prefix fast path: LEFT(col, n)
295            // on a TEXT column borrows the cell and clones only the
296            // prefix. The generic path clones the WHOLE cell first -
297            // a LEFT(body, 120) over 24k x 30 KB rows spent 383 ms
298            // copying bytes it then threw away (7 ms without LEFT).
299            if args.len() == 2
300                && name.eq_ignore_ascii_case("left")
301                && let Expr::Column(c) = &args[0]
302                && let Some(cell) = resolve_column_borrowed(c, row, ctx)?
303            {
304                {
305                    match cell {
306                        Value::Null => return Ok(Value::Null),
307                        Value::Text(t) => {
308                            let n_v = eval_expr(&args[1], row, ctx)?;
309                            if let Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) = n_v {
310                                let n = match n_v {
311                                    Value::SmallInt(x) => i64::from(x),
312                                    Value::Int(x) => i64::from(x),
313                                    Value::BigInt(x) => x,
314                                    _ => 0,
315                                };
316                                return Ok(Value::text(text_prefix_chars(t, n)));
317                            }
318                        }
319                        _ => {}
320                    }
321                }
322            }
323            let evaluated: Result<Vec<Value<'static>>, _> =
324                args.iter().map(|a| eval_expr(a, row, ctx)).collect();
325            apply_function(name, &evaluated?, ctx)
326        }
327        Expr::Like {
328            expr,
329            pattern,
330            negated,
331            case_insensitive,
332        } => {
333            let v = eval_expr(expr, row, ctx)?;
334            let p = eval_expr(pattern, row, ctx)?;
335            // NULL on either side propagates to NULL — same as PG.
336            let (text, pat) = match (v, p) {
337                (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
338                (Value::Text(a), Value::Text(b)) => (a, b),
339                (Value::Text(_), other) | (other, _) => {
340                    return Err(EvalError::TypeMismatch {
341                        detail: format!("LIKE requires text operands, got {:?}", other.data_type()),
342                    });
343                }
344            };
345            // v7.25 (round-17) — ILIKE folds both operands (PG
346            // lowercases per the default collation).
347            let m = if *case_insensitive {
348                like_match(&text.to_lowercase(), &pat.to_lowercase())
349            } else {
350                like_match(&text, &pat)
351            };
352            Ok(Value::Bool(if *negated { !m } else { m }))
353        }
354        Expr::Extract { field, source } => {
355            let v = eval_expr(source, row, ctx)?;
356            extract_field(*field, &v)
357        }
358        // v4.10: subquery nodes should have been resolved into
359        // Literal / InList nodes by Engine::resolve_select_subqueries
360        // before the row loop. Anything reaching here is a bug.
361        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {
362            Err(EvalError::TypeMismatch {
363                detail: "subquery reached row eval — engine resolver bug".into(),
364            })
365        }
366        // v7.30.2 (mailrs round-25) — flat `expr [NOT] IN (a, b, …)`.
367        // Iterative scan with PG three-valued logic: TRUE on the first
368        // Eq match; if nothing matched, NULL when the needle is NULL or
369        // any comparison was NULL; FALSE otherwise. Empty list (only
370        // reachable via an empty subquery result) is FALSE / TRUE even
371        // for a NULL needle — no comparison ever happens.
372        Expr::InList {
373            expr,
374            list,
375            negated,
376        } => {
377            let needle = eval_expr(expr, row, ctx)?;
378            let needle_null = matches!(needle, Value::Null);
379            let mut saw_null = needle_null && !list.is_empty();
380            let mut matched = false;
381            if !needle_null {
382                for item in list {
383                    let v = eval_expr(item, row, ctx)?;
384                    if matches!(v, Value::Null) {
385                        saw_null = true;
386                        continue;
387                    }
388                    match apply_binary(BinOp::Eq, needle.clone(), v)? {
389                        Value::Bool(true) => {
390                            matched = true;
391                            break;
392                        }
393                        Value::Bool(false) => {}
394                        Value::Null => saw_null = true,
395                        other => {
396                            return Err(EvalError::TypeMismatch {
397                                detail: format!(
398                                    "IN comparison didn't return Bool: {:?}",
399                                    other.data_type()
400                                ),
401                            });
402                        }
403                    }
404                }
405            }
406            let inner = if matched {
407                Value::Bool(true)
408            } else if saw_null {
409                Value::Null
410            } else {
411                Value::Bool(false)
412            };
413            Ok(match (negated, inner) {
414                (true, Value::Bool(b)) => Value::Bool(!b),
415                (_, v) => v,
416            })
417        }
418        // v4.12: window functions should have been rewritten into
419        // synthetic __win_N column references by
420        // exec_select_with_window before row eval. Anything
421        // reaching here is similarly a bug.
422        Expr::WindowFunction { .. } => Err(EvalError::TypeMismatch {
423            detail: "window function reached row eval — engine rewrite bug".into(),
424        }),
425        // v7.10.10 — `ARRAY[expr, expr, …]` constructor.
426        // v7.11.13 — element-type detection: all integers →
427        // IntArray (or BigIntArray when widening), any Text →
428        // TextArray. Non-TEXT non-integer elements (Bool, Float)
429        // stringify into TextArray as the safe default.
430        Expr::Array(items) => {
431            let mut materialised: Vec<Value<'static>> = Vec::with_capacity(items.len());
432            for elem in items {
433                materialised.push(eval_expr(elem, row, ctx)?);
434            }
435            let mut has_text = false;
436            let mut has_bigint = false;
437            let mut has_int = false;
438            for v in &materialised {
439                match v {
440                    Value::Null => {}
441                    Value::Int(_) | Value::SmallInt(_) => has_int = true,
442                    Value::BigInt(_) => has_bigint = true,
443                    Value::Text(_) | Value::Json(_) => has_text = true,
444                    _ => has_text = true,
445                }
446            }
447            if has_text || (!has_int && !has_bigint) {
448                let out: Vec<Option<String>> = materialised
449                    .into_iter()
450                    .map(|v| match v {
451                        Value::Null => None,
452                        Value::Text(s) | Value::Json(s) => Some(s.into_owned()),
453                        other => Some(value_to_text_for_array(&other)),
454                    })
455                    .collect();
456                return Ok(Value::TextArray(out));
457            }
458            if has_bigint {
459                let out: Vec<Option<i64>> = materialised
460                    .into_iter()
461                    .map(|v| match v {
462                        Value::Null => None,
463                        Value::Int(n) => Some(i64::from(n)),
464                        Value::SmallInt(n) => Some(i64::from(n)),
465                        Value::BigInt(n) => Some(n),
466                        _ => unreachable!(),
467                    })
468                    .collect();
469                return Ok(Value::BigIntArray(out));
470            }
471            let out: Vec<Option<i32>> = materialised
472                .into_iter()
473                .map(|v| match v {
474                    Value::Null => None,
475                    Value::Int(n) => Some(n),
476                    Value::SmallInt(n) => Some(i32::from(n)),
477                    _ => unreachable!(),
478                })
479                .collect();
480            Ok(Value::IntArray(out))
481        }
482        // v7.10.12 — `arr[i]` PG-style 1-based indexing.
483        // Out-of-range indices (including i ≤ 0) return NULL.
484        Expr::ArraySubscript { target, index } => {
485            let target_v = eval_expr(target, row, ctx)?;
486            let idx_v = eval_expr(index, row, ctx)?;
487            if matches!(target_v, Value::Null) || matches!(idx_v, Value::Null) {
488                return Ok(Value::Null);
489            }
490            let i: i64 = match idx_v {
491                Value::Int(n) => i64::from(n),
492                Value::BigInt(n) => n,
493                Value::SmallInt(n) => i64::from(n),
494                other => {
495                    return Err(EvalError::TypeMismatch {
496                        detail: format!(
497                            "array subscript must be integer, got {:?}",
498                            other.data_type()
499                        ),
500                    });
501                }
502            };
503            if i < 1 {
504                return Ok(Value::Null);
505            }
506            let pos = (i - 1) as usize;
507            match target_v {
508                Value::TextArray(items) => match items.get(pos) {
509                    Some(Some(s)) => Ok(Value::text(s.clone())),
510                    Some(None) | None => Ok(Value::Null),
511                },
512                Value::IntArray(items) => match items.get(pos) {
513                    Some(Some(n)) => Ok(Value::Int(*n)),
514                    Some(None) | None => Ok(Value::Null),
515                },
516                Value::BigIntArray(items) => match items.get(pos) {
517                    Some(Some(n)) => Ok(Value::BigInt(*n)),
518                    Some(None) | None => Ok(Value::Null),
519                },
520                other => Err(EvalError::TypeMismatch {
521                    detail: format!(
522                        "subscript target must be an array, got {:?}",
523                        other.data_type()
524                    ),
525                }),
526            }
527        }
528        // v7.10.12 — `x op ANY(arr)` / `x op ALL(arr)`. PG
529        // 3VL: ANY → true if any element compares-true; NULL if
530        // no true but some NULL; false otherwise. ALL: false if
531        // any compares-false; NULL if no false but some NULL;
532        // true otherwise.
533        Expr::AnyAll {
534            expr,
535            op,
536            array,
537            is_any,
538        } => {
539            let lhs = eval_expr(expr, row, ctx)?;
540            let arr = eval_expr(array, row, ctx)?;
541            if matches!(arr, Value::Null) {
542                return Ok(Value::Null);
543            }
544            let elems: Vec<Option<Value>> = match arr {
545                Value::TextArray(items) => items.into_iter().map(|o| o.map(Value::text)).collect(),
546                Value::IntArray(items) => items.into_iter().map(|o| o.map(Value::Int)).collect(),
547                Value::BigIntArray(items) => {
548                    items.into_iter().map(|o| o.map(Value::BigInt)).collect()
549                }
550                other => {
551                    return Err(EvalError::TypeMismatch {
552                        detail: format!(
553                            "ANY/ALL right-hand side must be an array, got {:?}",
554                            other.data_type()
555                        ),
556                    });
557                }
558            };
559            let mut saw_null = matches!(lhs, Value::Null);
560            let mut saw_match = false;
561            let mut saw_mismatch = false;
562            for elem in elems {
563                let elem_v = match elem {
564                    Some(v) => v,
565                    None => {
566                        saw_null = true;
567                        continue;
568                    }
569                };
570                if matches!(lhs, Value::Null) {
571                    saw_null = true;
572                    continue;
573                }
574                match apply_binary(*op, lhs.clone(), elem_v) {
575                    Ok(Value::Bool(true)) => saw_match = true,
576                    Ok(Value::Bool(false)) => saw_mismatch = true,
577                    Ok(Value::Null) => saw_null = true,
578                    Ok(other) => {
579                        return Err(EvalError::TypeMismatch {
580                            detail: format!(
581                                "ANY/ALL comparison didn't return Bool: {:?}",
582                                other.data_type()
583                            ),
584                        });
585                    }
586                    Err(e) => return Err(e),
587                }
588            }
589            let result = if *is_any {
590                if saw_match {
591                    Value::Bool(true)
592                } else if saw_null {
593                    Value::Null
594                } else {
595                    Value::Bool(false)
596                }
597            } else if saw_mismatch {
598                Value::Bool(false)
599            } else if saw_null {
600                Value::Null
601            } else {
602                Value::Bool(true)
603            };
604            Ok(result)
605        }
606        // v7.13.0 — CASE WHEN … END (mailrs round-5 G9).
607        // Short-circuit on the first matching branch. Searched form
608        // (operand=None) treats each branch's WHEN as a Bool
609        // predicate. Simple form (operand=Some) compares with =.
610        // ELSE on no match; NULL if no ELSE.
611        Expr::Case {
612            operand,
613            branches,
614            else_branch,
615        } => {
616            let operand_value = match operand {
617                Some(o) => Some(eval_expr(o, row, ctx)?),
618                None => None,
619            };
620            for (when_expr, then_expr) in branches {
621                let when_value = eval_expr(when_expr, row, ctx)?;
622                let matched = match &operand_value {
623                    None => matches!(when_value, Value::Bool(true)),
624                    Some(op_v) => matches!(
625                        apply_binary(spg_sql::ast::BinOp::Eq, op_v.clone(), when_value)?,
626                        Value::Bool(true)
627                    ),
628                };
629                if matched {
630                    return eval_expr(then_expr, row, ctx);
631                }
632            }
633            match else_branch {
634                Some(e) => eval_expr(e, row, ctx),
635                None => Ok(Value::Null),
636            }
637        }
638    }
639}
640
641/// v7.10.10 — best-effort text rendering for non-TEXT array
642/// elements (numbers, bools, etc.). The PG rule is that
643/// `ARRAY[1, 2]` is `int[]`, but SPG's v7.10 only models TEXT[],
644/// so we widen by stringifying. NUMERIC formatting goes through
645/// the existing canonical helpers to stay consistent with
646/// `format_numeric` / `format_date` etc.
647fn value_to_text_for_array(v: &Value) -> String {
648    match v {
649        Value::Text(s) | Value::Json(s) => s.to_string(),
650        Value::Int(n) => n.to_string(),
651        Value::BigInt(n) => n.to_string(),
652        Value::SmallInt(n) => n.to_string(),
653        Value::Bool(b) => {
654            if *b {
655                "true".into()
656            } else {
657                "false".into()
658            }
659        }
660        Value::Float(x) => format!("{x}"),
661        Value::Date(d) => format_date(*d),
662        Value::Timestamp(t) => format_timestamp(*t),
663        Value::Numeric { scaled, scale } => format_numeric(*scaled, *scale),
664        _ => format!("{v:?}"),
665    }
666}
667
668/// SQL `LIKE` matcher. Wildcards are `%` (any run, possibly empty) and `_`
669/// (exactly one char). `\` escapes the next pattern char so `\%` matches a
670/// literal `%`. Matches the whole input — no implicit anchoring needed
671/// since SQL `LIKE` is always full-string.
672fn like_match(text: &str, pattern: &str) -> bool {
673    let text: Vec<char> = text.chars().collect();
674    let pat: Vec<char> = pattern.chars().collect();
675    like_match_inner(&text, 0, &pat, 0)
676}
677
678fn like_match_inner(text: &[char], mut ti: usize, pat: &[char], mut pi: usize) -> bool {
679    while pi < pat.len() {
680        match pat[pi] {
681            '%' => {
682                // Collapse consecutive `%` and try every possible split.
683                while pi < pat.len() && pat[pi] == '%' {
684                    pi += 1;
685                }
686                if pi == pat.len() {
687                    return true;
688                }
689                for k in ti..=text.len() {
690                    if like_match_inner(text, k, pat, pi) {
691                        return true;
692                    }
693                }
694                return false;
695            }
696            '_' => {
697                if ti >= text.len() {
698                    return false;
699                }
700                ti += 1;
701                pi += 1;
702            }
703            '\\' if pi + 1 < pat.len() => {
704                let want = pat[pi + 1];
705                if ti >= text.len() || text[ti] != want {
706                    return false;
707                }
708                ti += 1;
709                pi += 2;
710            }
711            c => {
712                if ti >= text.len() || text[ti] != c {
713                    return false;
714                }
715                ti += 1;
716                pi += 1;
717            }
718        }
719    }
720    ti == text.len()
721}
722
723/// v7.24 (round-15) — `string_to_array(text, delimiter)`.
724fn fn_string_to_array(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
725    let [text_arg, delim_arg] = args else {
726        return Err(EvalError::TypeMismatch {
727            detail: alloc::format!("string_to_array expects 2 arguments, got {}", args.len()),
728        });
729    };
730    let text = match text_arg {
731        Value::Null => return Ok(Value::Null),
732        Value::Text(t) => t,
733        other => {
734            return Err(EvalError::TypeMismatch {
735                detail: alloc::format!("string_to_array expects text, got {:?}", other.data_type()),
736            });
737        }
738    };
739    // PG (9.1+): empty input → empty array, regardless of delimiter.
740    if text.is_empty() {
741        return Ok(Value::TextArray(Vec::new()));
742    }
743    let parts: Vec<Option<String>> = match delim_arg {
744        // NULL delimiter → one element per character.
745        Value::Null => text.chars().map(|c| Some(c.to_string())).collect(),
746        Value::Text(d) if d.is_empty() => alloc::vec![Some(text.to_string())],
747        Value::Text(d) => text
748            .split(d.as_ref())
749            .map(|p| Some(p.to_string()))
750            .collect(),
751        other => {
752            return Err(EvalError::TypeMismatch {
753                detail: alloc::format!(
754                    "string_to_array delimiter must be text, got {:?}",
755                    other.data_type()
756                ),
757            });
758        }
759    };
760    Ok(Value::TextArray(parts))
761}
762
763/// v6.4.3 — `error_on_null(v)`. Returns `v` unchanged if non-NULL;
764/// errors otherwise. Convenience to assert NOT NULL inside an
765/// expression without wrapping it in COALESCE + raise hacks.
766fn error_on_null(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
767    if args.len() != 1 {
768        return Err(EvalError::TypeMismatch {
769            detail: format!("error_on_null() takes 1 arg, got {}", args.len()),
770        });
771    }
772    if matches!(args[0], Value::Null) {
773        return Err(EvalError::TypeMismatch {
774            detail: "error_on_null(): argument is NULL".into(),
775        });
776    }
777    Ok(args[0].clone().into_owned())
778}
779
780/// Helper: coerce a Value to an Option<String> for regex args. NULL
781/// propagates as None (caller short-circuits to Value::Null).
782fn text_arg(v: &Value) -> Result<Option<String>, EvalError> {
783    match v {
784        Value::Text(s) => Ok(Some(s.to_string())),
785        Value::Null => Ok(None),
786        other => Err(EvalError::TypeMismatch {
787            detail: alloc::format!(
788                "regex function expects TEXT arg, got {:?}",
789                other.data_type()
790            ),
791        }),
792    }
793}
794
795// Month-name tables shared by the date formatters in `eval::strings`
796// (`date_format_mysql`) and `eval::datetime` via `use super::`. Kept in
797// `eval.rs` alongside `civil_from_days` so the calendar primitives live
798// in one place.
799const MONTH_FULL: [&str; 12] = [
800    "January",
801    "February",
802    "March",
803    "April",
804    "May",
805    "June",
806    "July",
807    "August",
808    "September",
809    "October",
810    "November",
811    "December",
812];
813const MONTH_ABBR: [&str; 12] = [
814    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
815];
816
817/// Howard Hinnant's `civil_from_days` — converts days since the Unix
818/// epoch back to a proleptic-Gregorian (year, month, day) triple. Stays
819/// in `eval.rs` (shared with the date SQL functions here and with
820/// `eval::strings`); the inverse `days_from_civil` lives in
821/// `eval::format`. Both keep the engine off `std` time facilities.
822#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
823fn civil_from_days(days: i32) -> (i32, u32, u32) {
824    let z = i64::from(days) + 719_468;
825    let era = z.div_euclid(146_097);
826    // doe ∈ [0, 146_097); fits in u32 with room to spare. Same for
827    // every other quantity below — `as u32` truncations are safe by
828    // construction.
829    let doe = (z - era * 146_097) as u32;
830    let yoe = (doe.saturating_sub(doe / 1460) + doe / 36524 - doe / 146_096) / 365;
831    let y_base = i64::from(yoe) + era * 400;
832    let doy = doe.saturating_sub(365 * yoe + yoe / 4 - yoe / 100);
833    let mp = (5 * doy + 2) / 153;
834    let d = doy.saturating_sub((153 * mp + 2) / 5) + 1;
835    let m = if mp < 10 { mp + 3 } else { mp - 9 };
836    let y = if m <= 2 { y_base + 1 } else { y_base };
837    (y as i32, m, d)
838}
839
840/// Add `months` (signed) to a `(year, month, day)` triple using PG's
841/// clamp-to-last-day rule (so `'2024-01-31' + 1 month` → `'2024-02-29'`).
842fn add_months_to_civil(y: i32, m: u32, d: u32, months: i32) -> (i32, u32, u32) {
843    let total_months = i64::from(y) * 12 + i64::from(m) - 1 + i64::from(months);
844    let new_year = i32::try_from(total_months.div_euclid(12)).unwrap_or(i32::MAX);
845    let new_month_zero = total_months.rem_euclid(12);
846    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
847    let new_month = (new_month_zero as u32) + 1;
848    let max_day = days_in_month(new_year, new_month);
849    (new_year, new_month, d.min(max_day))
850}
851
852const fn days_in_month(y: i32, m: u32) -> u32 {
853    match m {
854        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
855        2 => {
856            // Proleptic Gregorian leap rule.
857            if y.rem_euclid(4) == 0 && (y.rem_euclid(100) != 0 || y.rem_euclid(400) == 0) {
858                29
859            } else {
860                28
861            }
862        }
863        // 4 / 6 / 9 / 11 plus any out-of-range month (callers normalise
864        // first, but be defensive) get the 30-day fallback.
865        _ => 30,
866    }
867}
868
869pub(crate) fn literal_to_value(l: &Literal) -> Value<'static> {
870    match l {
871        Literal::Integer(n) => {
872            if let Ok(small) = i32::try_from(*n) {
873                Value::Int(small)
874            } else {
875                Value::BigInt(*n)
876            }
877        }
878        Literal::Float(x) => Value::Float(*x),
879        Literal::String(s) => Value::text(s.clone()),
880        Literal::Vector(v) => Value::vector(v.clone()),
881        Literal::TextArray(items) => Value::TextArray(items.clone()),
882        Literal::IntArray(items) => Value::IntArray(items.clone()),
883        Literal::BigIntArray(items) => Value::BigIntArray(items.clone()),
884        Literal::Bool(b) => Value::Bool(*b),
885        Literal::Null => Value::Null,
886        Literal::Interval {
887            months,
888            days,
889            micros,
890            ..
891        } => Value::Interval {
892            months: *months,
893            days: *days,
894            micros: *micros,
895        },
896    }
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use alloc::vec;
903    use spg_sql::ast::UnOp;
904    use spg_storage::{ColumnSchema, DataType, Row};
905
906    fn col(name: &str, ty: DataType) -> ColumnSchema {
907        ColumnSchema::new(name, ty, true)
908    }
909
910    fn ctx<'a>(cols: &'a [ColumnSchema], alias: Option<&'a str>) -> EvalContext<'a> {
911        EvalContext::new(cols, alias)
912    }
913
914    /// v7.32 (P4 borrow channel) differential: the borrowed comparison
915    /// fast path in `eval_expr`'s Binary arm must be byte-for-byte the
916    /// pre-P4 owned path (`apply_binary` on cloned operands) across a
917    /// cross-type value matrix and every comparison operator — covering
918    /// the fast-path types (Text/Int/Float/Date/Timestamp/Bool/Null) and
919    /// the owned-fallback types (Numeric/Interval).
920    #[test]
921    fn borrowed_compare_equals_owned_apply_binary() {
922        let vals = vec![
923            Value::Null,
924            Value::Bool(true),
925            Value::Bool(false),
926            Value::SmallInt(3),
927            Value::Int(3),
928            Value::Int(-1),
929            Value::BigInt(3),
930            Value::BigInt(100),
931            Value::Float(3.0),
932            Value::Float(2.5),
933            Value::text(String::new()),
934            Value::text("a"),
935            Value::text("b"),
936            Value::Date(10),
937            Value::Timestamp(1000),
938            Value::Numeric {
939                scaled: 30,
940                scale: 1,
941            },
942            Value::Interval {
943                months: 0,
944                days: 0,
945                micros: 5,
946            },
947        ];
948        let ops = [
949            BinOp::Eq,
950            BinOp::NotEq,
951            BinOp::Lt,
952            BinOp::LtEq,
953            BinOp::Gt,
954            BinOp::GtEq,
955        ];
956        let cs = vec![col("x", DataType::Int), col("y", DataType::Int)];
957        let c = ctx(&cs, None);
958        let lhs = Expr::Column(ColumnName {
959            qualifier: None,
960            name: "x".into(),
961        });
962        let rhs = Expr::Column(ColumnName {
963            qualifier: None,
964            name: "y".into(),
965        });
966        for l in &vals {
967            for r in &vals {
968                let row = Row::new(vec![l.clone(), r.clone()]);
969                for op in ops {
970                    let got = eval_expr(
971                        &Expr::Binary {
972                            lhs: alloc::boxed::Box::new(lhs.clone()),
973                            op,
974                            rhs: alloc::boxed::Box::new(rhs.clone()),
975                        },
976                        &row,
977                        &c,
978                    );
979                    // Pre-P4 reference: owned operands through apply_binary
980                    // (collation fold is a no-op for non-CI columns).
981                    let want = apply_binary(op, l.clone(), r.clone());
982                    assert_eq!(
983                        format!("{got:?}"),
984                        format!("{want:?}"),
985                        "op={op:?} l={l:?} r={r:?}"
986                    );
987                }
988            }
989        }
990    }
991
992    fn lit(n: i64) -> Expr {
993        Expr::Literal(Literal::Integer(n))
994    }
995
996    fn null() -> Expr {
997        Expr::Literal(Literal::Null)
998    }
999
1000    fn col_ref(name: &str) -> Expr {
1001        Expr::Column(ColumnName {
1002            qualifier: None,
1003            name: name.into(),
1004        })
1005    }
1006
1007    #[test]
1008    fn literal_evaluates_to_value() {
1009        let r = Row::new(vec![]);
1010        let cs: [ColumnSchema; 0] = [];
1011        let c = ctx(&cs, None);
1012        assert_eq!(eval_expr(&lit(42), &r, &c).unwrap(), Value::Int(42));
1013        assert_eq!(
1014            eval_expr(&Expr::Literal(Literal::Float(1.5)), &r, &c).unwrap(),
1015            Value::Float(1.5)
1016        );
1017        assert_eq!(eval_expr(&null(), &r, &c).unwrap(), Value::Null);
1018    }
1019
1020    #[test]
1021    fn column_lookup_unqualified() {
1022        let cs = vec![col("a", DataType::Int), col("b", DataType::Text)];
1023        let r = Row::new(vec![Value::Int(7), Value::text("hi")]);
1024        let c = ctx(&cs, None);
1025        assert_eq!(eval_expr(&col_ref("a"), &r, &c).unwrap(), Value::Int(7));
1026        assert_eq!(eval_expr(&col_ref("b"), &r, &c).unwrap(), Value::text("hi"));
1027    }
1028
1029    #[test]
1030    fn column_not_found_errors() {
1031        let cs = vec![col("a", DataType::Int)];
1032        let r = Row::new(vec![Value::Int(0)]);
1033        let c = ctx(&cs, None);
1034        let err = eval_expr(&col_ref("ghost"), &r, &c).unwrap_err();
1035        assert!(matches!(err, EvalError::ColumnNotFound { ref name } if name == "ghost"));
1036    }
1037
1038    #[test]
1039    fn qualified_column_matches_alias() {
1040        let cs = vec![col("a", DataType::Int)];
1041        let r = Row::new(vec![Value::Int(5)]);
1042        let c = ctx(&cs, Some("u"));
1043        let qualified = Expr::Column(ColumnName {
1044            qualifier: Some("u".into()),
1045            name: "a".into(),
1046        });
1047        assert_eq!(eval_expr(&qualified, &r, &c).unwrap(), Value::Int(5));
1048    }
1049
1050    #[test]
1051    fn qualified_column_unknown_alias_errors() {
1052        let cs = vec![col("a", DataType::Int)];
1053        let r = Row::new(vec![Value::Int(5)]);
1054        let c = ctx(&cs, Some("u"));
1055        let wrong = Expr::Column(ColumnName {
1056            qualifier: Some("x".into()),
1057            name: "a".into(),
1058        });
1059        assert!(matches!(
1060            eval_expr(&wrong, &r, &c).unwrap_err(),
1061            EvalError::UnknownQualifier { .. }
1062        ));
1063    }
1064
1065    #[test]
1066    fn arithmetic_with_widening() {
1067        let r = Row::new(vec![]);
1068        let cs: [ColumnSchema; 0] = [];
1069        let c = ctx(&cs, None);
1070        let e = Expr::Binary {
1071            lhs: alloc::boxed::Box::new(lit(2)),
1072            op: BinOp::Add,
1073            rhs: alloc::boxed::Box::new(Expr::Literal(Literal::Float(0.5))),
1074        };
1075        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Float(2.5));
1076    }
1077
1078    #[test]
1079    fn division_by_zero_errors() {
1080        let r = Row::new(vec![]);
1081        let cs: [ColumnSchema; 0] = [];
1082        let c = ctx(&cs, None);
1083        let e = Expr::Binary {
1084            lhs: alloc::boxed::Box::new(lit(1)),
1085            op: BinOp::Div,
1086            rhs: alloc::boxed::Box::new(lit(0)),
1087        };
1088        assert_eq!(
1089            eval_expr(&e, &r, &c).unwrap_err(),
1090            EvalError::DivisionByZero
1091        );
1092    }
1093
1094    #[test]
1095    fn comparison_returns_bool() {
1096        let r = Row::new(vec![]);
1097        let cs: [ColumnSchema; 0] = [];
1098        let c = ctx(&cs, None);
1099        let e = Expr::Binary {
1100            lhs: alloc::boxed::Box::new(lit(1)),
1101            op: BinOp::Lt,
1102            rhs: alloc::boxed::Box::new(lit(2)),
1103        };
1104        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Bool(true));
1105    }
1106
1107    #[test]
1108    fn null_propagates_through_arithmetic() {
1109        let r = Row::new(vec![]);
1110        let cs: [ColumnSchema; 0] = [];
1111        let c = ctx(&cs, None);
1112        let e = Expr::Binary {
1113            lhs: alloc::boxed::Box::new(lit(1)),
1114            op: BinOp::Add,
1115            rhs: alloc::boxed::Box::new(null()),
1116        };
1117        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Null);
1118    }
1119
1120    #[test]
1121    fn and_three_valued_logic() {
1122        let r = Row::new(vec![]);
1123        let cs: [ColumnSchema; 0] = [];
1124        let c = ctx(&cs, None);
1125        let tt = |a: bool, b_null: bool| Expr::Binary {
1126            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(a))),
1127            op: BinOp::And,
1128            rhs: alloc::boxed::Box::new(if b_null {
1129                null()
1130            } else {
1131                Expr::Literal(Literal::Bool(true))
1132            }),
1133        };
1134        // FALSE AND NULL → FALSE
1135        assert_eq!(
1136            eval_expr(&tt(false, true), &r, &c).unwrap(),
1137            Value::Bool(false)
1138        );
1139        // TRUE AND NULL → NULL
1140        assert_eq!(eval_expr(&tt(true, true), &r, &c).unwrap(), Value::Null);
1141        // TRUE AND TRUE → TRUE
1142        assert_eq!(
1143            eval_expr(&tt(true, false), &r, &c).unwrap(),
1144            Value::Bool(true)
1145        );
1146    }
1147
1148    #[test]
1149    fn or_three_valued_logic() {
1150        let r = Row::new(vec![]);
1151        let cs: [ColumnSchema; 0] = [];
1152        let c = ctx(&cs, None);
1153        let or_with_null = |a: bool| Expr::Binary {
1154            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(a))),
1155            op: BinOp::Or,
1156            rhs: alloc::boxed::Box::new(null()),
1157        };
1158        // TRUE OR NULL → TRUE
1159        assert_eq!(
1160            eval_expr(&or_with_null(true), &r, &c).unwrap(),
1161            Value::Bool(true)
1162        );
1163        // FALSE OR NULL → NULL
1164        assert_eq!(
1165            eval_expr(&or_with_null(false), &r, &c).unwrap(),
1166            Value::Null
1167        );
1168    }
1169
1170    #[test]
1171    fn not_on_null_is_null() {
1172        let r = Row::new(vec![]);
1173        let cs: [ColumnSchema; 0] = [];
1174        let c = ctx(&cs, None);
1175        let e = Expr::Unary {
1176            op: UnOp::Not,
1177            expr: alloc::boxed::Box::new(null()),
1178        };
1179        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Null);
1180    }
1181
1182    #[test]
1183    fn text_comparison_lexicographic() {
1184        let r = Row::new(vec![]);
1185        let cs: [ColumnSchema; 0] = [];
1186        let c = ctx(&cs, None);
1187        let e = Expr::Binary {
1188            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::String("apple".into()))),
1189            op: BinOp::Lt,
1190            rhs: alloc::boxed::Box::new(Expr::Literal(Literal::String("banana".into()))),
1191        };
1192        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Bool(true));
1193    }
1194
1195    #[test]
1196    fn interval_format_basics() {
1197        // v7.37.5 β — three-arg signature. PG byte-equal:
1198        // `'1 day'` ≠ `'24 hours'` now, the format reflects it.
1199        assert_eq!(format_interval(0, 0, 0), "0");
1200        assert_eq!(format_interval(0, 1, 0), "1 day");
1201        assert_eq!(format_interval(0, -1, 0), "-1 days");
1202        assert_eq!(format_interval(0, 0, 86_400_000_000), "24:00:00");
1203        assert_eq!(format_interval(0, 0, 3_600_000_000), "01:00:00");
1204        assert_eq!(format_interval(0, 1, 9_000_000), "1 day 00:00:09");
1205        assert_eq!(format_interval(14, 0, 0), "1 year 2 mons");
1206        assert_eq!(format_interval(-1, 0, 0), "-1 mons");
1207    }
1208
1209    #[test]
1210    fn interval_format_pg_byte_equal_day_vs_24h() {
1211        // v7.37.5 β — the PG-canonical distinction `'1 day'` ≠ `'24 hours'`
1212        // is preserved in the formatter, not just the parser.
1213        assert_eq!(format_interval(0, 1, 0), "1 day");
1214        assert_eq!(format_interval(0, 0, 86_400_000_000), "24:00:00");
1215        assert_ne!(
1216            format_interval(0, 1, 0),
1217            format_interval(0, 0, 86_400_000_000),
1218        );
1219    }
1220}