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